mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
* feat(auto-sync): preserve PDG indexes across updates * docs(auto-sync): document durable PDG synchronization * Address PR review feedback (#3290) - Correct requestedPdg state docs for threshold-skipped syncs - Defer coalesced follow-up and skip failure-threshold counts for leftover-worker / retryable lock waits - Document the pdg tri-state and caveat the 30m/5m example Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): raise Windows Ladybug #605 hang budget off the CI tail Windows 3/3 typically finishes this native race in ~15s but has a 56s tail; 60s false-positives as deadlock. Keep the POSIX 60s detector and the completion/.shadow/row-count contract. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
772 lines
29 KiB
TypeScript
772 lines
29 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
extractRepoNameFromRemoteUrl,
|
|
getAutoSyncMutexPath,
|
|
getAutoSyncStatePath,
|
|
getAutoSyncWatchDir,
|
|
getProjectCommitInfoPath,
|
|
loadAutoSyncConfig,
|
|
parseAutoSyncConfig,
|
|
parseBranchCandidates,
|
|
parseDurationMs,
|
|
quarantineAutoSyncPartial,
|
|
resolveConfiguredCloneRoot,
|
|
loadAutoSyncState,
|
|
resetAutoSyncState,
|
|
saveAutoSyncState,
|
|
shouldAnalyzeCommit,
|
|
validateAutoSyncRemoteUrl,
|
|
validateAutoSyncBranchName,
|
|
writeProjectCommitInfo,
|
|
} from '../../src/core/auto-sync/index.js';
|
|
import { acquireFileLock } from '../../src/storage/file-lock.js';
|
|
|
|
describe('auto-sync', () => {
|
|
let tempDir: string;
|
|
let gitnexusHome: string;
|
|
let oldHome: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
const base = path.join(process.cwd(), '.tmp-test');
|
|
await fs.mkdir(base, { recursive: true });
|
|
tempDir = await fs.realpath(await fs.mkdtemp(path.join(base, 'gitnexus-auto-sync-')));
|
|
gitnexusHome = path.join(tempDir, '.gitnexus');
|
|
await fs.mkdir(gitnexusHome);
|
|
oldHome = process.env.GITNEXUS_HOME;
|
|
process.env.GITNEXUS_HOME = gitnexusHome;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (oldHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
else process.env.GITNEXUS_HOME = oldHome;
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('places watch runtime artifacts under the watch directory by default', () => {
|
|
expect(getAutoSyncWatchDir(gitnexusHome)).toBe(path.join(gitnexusHome, 'watch'));
|
|
expect(getAutoSyncMutexPath(gitnexusHome)).toBe(
|
|
path.join(gitnexusHome, 'watch', 'watch.mutex'),
|
|
);
|
|
expect(getAutoSyncStatePath(gitnexusHome)).toBe(
|
|
path.join(gitnexusHome, 'watch', 'auto-sync-state.json'),
|
|
);
|
|
expect(getProjectCommitInfoPath(gitnexusHome)).toBe(
|
|
path.join(gitnexusHome, 'watch', 'project_commit_info.txt'),
|
|
);
|
|
});
|
|
|
|
it('refuses to reset state while the watch mutex is held', async () => {
|
|
const statePath = getAutoSyncStatePath(gitnexusHome);
|
|
const infoPath = getProjectCommitInfoPath(gitnexusHome);
|
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
await fs.writeFile(statePath, '{"kept":true}\n');
|
|
await fs.writeFile(infoPath, 'kept\n');
|
|
const release = await acquireFileLock(getAutoSyncMutexPath(gitnexusHome));
|
|
|
|
try {
|
|
await expect(resetAutoSyncState(gitnexusHome)).resolves.toBe(false);
|
|
await expect(fs.readFile(statePath, 'utf-8')).resolves.toContain('kept');
|
|
await expect(fs.readFile(infoPath, 'utf-8')).resolves.toBe('kept\n');
|
|
} finally {
|
|
await release();
|
|
}
|
|
});
|
|
|
|
it('resets derived state while holding the watch mutex', async () => {
|
|
const statePath = getAutoSyncStatePath(gitnexusHome);
|
|
const infoPath = getProjectCommitInfoPath(gitnexusHome);
|
|
const mutexPath = getAutoSyncMutexPath(gitnexusHome);
|
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
await fs.writeFile(statePath, '{}\n');
|
|
await fs.writeFile(infoPath, 'derived\n');
|
|
|
|
await expect(resetAutoSyncState(gitnexusHome)).resolves.toBe(true);
|
|
|
|
await expect(fs.access(statePath)).rejects.toThrow();
|
|
await expect(fs.access(infoPath)).rejects.toThrow();
|
|
await expect(fs.access(mutexPath)).rejects.toThrow();
|
|
});
|
|
|
|
it('loads watch_config.yml from GITNEXUS_HOME and normalizes branch candidates', async () => {
|
|
await fs.writeFile(
|
|
path.join(gitnexusHome, 'watch_config.yml'),
|
|
[
|
|
'sync_interval_minutes: 120',
|
|
'max_concurrency: 3',
|
|
'repo_git_timeout: 12s',
|
|
'analyze_timeout: 45m',
|
|
'analyze_failure_threshold: 2',
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' group_name: back_end',
|
|
' pdg: true',
|
|
' overwrite_local_changes: true',
|
|
' branches: [test, master, test]',
|
|
' remote_urls:',
|
|
' - git@gitee.com:qts_server/qts_account.git',
|
|
].join('\n'),
|
|
);
|
|
|
|
const loaded = await loadAutoSyncConfig();
|
|
|
|
expect(loaded.ok).toBe(true);
|
|
if (!loaded.ok) throw new Error('expected config to load');
|
|
expect(loaded.config.configPath).toBe(path.join(gitnexusHome, 'watch_config.yml'));
|
|
expect(loaded.config.syncIntervalMinutes).toBe(120);
|
|
expect(loaded.config.maxConcurrency).toBe(3);
|
|
expect(loaded.config.repoGitTimeoutMs).toBe(12_000);
|
|
expect(loaded.config.analyzeTimeoutMs).toBe(2_700_000);
|
|
expect(loaded.config.analyzeFailureThreshold).toBe(2);
|
|
expect(loaded.config.projects[0]).toMatchObject({
|
|
localPath: '/tmp/repos',
|
|
groupName: 'back_end',
|
|
pdg: true,
|
|
overwriteLocalChanges: true,
|
|
branches: ['test', 'master'],
|
|
remoteUrls: ['git@gitee.com:qts_server/qts_account.git'],
|
|
});
|
|
});
|
|
|
|
it('defaults repo_git_timeout and max_concurrency and allows empty group_name', async () => {
|
|
await fs.writeFile(
|
|
path.join(gitnexusHome, 'watch_config.yml'),
|
|
[
|
|
'sync_interval_minutes: 10',
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' group_name: ""',
|
|
' branch: master',
|
|
' remote_urls:',
|
|
' - git@github.com:owner/repo.git',
|
|
].join('\n'),
|
|
);
|
|
|
|
const loaded = await loadAutoSyncConfig();
|
|
|
|
expect(loaded.ok).toBe(true);
|
|
if (!loaded.ok) throw new Error('expected config');
|
|
expect(loaded.config.repoGitTimeoutMs).toBe(10_000);
|
|
expect(loaded.config.analyzeTimeoutMs).toBe(300_000);
|
|
expect(loaded.config.maxConcurrency).toBe(1);
|
|
expect(loaded.config.analyzeFailureThreshold).toBe(3);
|
|
expect(loaded.config.projects[0].groupName).toBeUndefined();
|
|
expect(loaded.config.projects[0].pdg).toBeUndefined();
|
|
expect(loaded.config.projects[0].overwriteLocalChanges).toBe(false);
|
|
});
|
|
|
|
it('rejects boolean max_concurrency instead of coercing it to 1', () => {
|
|
expect(() =>
|
|
parseAutoSyncConfig(
|
|
[
|
|
'sync_interval_minutes: 10',
|
|
'max_concurrency: true',
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' branch: master',
|
|
' remote_urls:',
|
|
' - git@github.com:owner/repo.git',
|
|
].join('\n'),
|
|
'/tmp/watch_config.yml',
|
|
),
|
|
).toThrow('max_concurrency must be a positive integer');
|
|
});
|
|
|
|
it('rejects repo_git_timeout values above the Node timer limit', () => {
|
|
expect(() =>
|
|
parseAutoSyncConfig(
|
|
[
|
|
'sync_interval_minutes: 10',
|
|
'repo_git_timeout: 2147483648ms',
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' branch: main',
|
|
' remote_urls:',
|
|
' - https://github.com/owner/repo.git',
|
|
].join('\n'),
|
|
'/tmp/watch_config.yml',
|
|
),
|
|
).toThrow('repo_git_timeout must not exceed 2147483647ms');
|
|
});
|
|
|
|
it('rejects a repo_git_timeout that exceeds the interval or an hour', () => {
|
|
const config = (timeout: string) =>
|
|
[
|
|
'sync_interval_minutes: 10',
|
|
`repo_git_timeout: ${timeout}`,
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' branch: main',
|
|
' remote_urls:',
|
|
' - git@github.com:owner/repo.git',
|
|
].join('\n');
|
|
|
|
// A bare number means seconds, so this is ~7 days, not 10 minutes.
|
|
expect(() => parseAutoSyncConfig(config('600000'), '/tmp/watch_config.yml')).toThrow(
|
|
'a bare number is interpreted as seconds',
|
|
);
|
|
expect(() => parseAutoSyncConfig(config('600000ms'), '/tmp/watch_config.yml')).not.toThrow();
|
|
});
|
|
|
|
it('allows a 30 minute analysis timeout with 5 minute polling', async () => {
|
|
await fs.writeFile(
|
|
path.join(gitnexusHome, 'watch_config.yml'),
|
|
[
|
|
'sync_interval_minutes: 5',
|
|
'analyze_timeout: 30m',
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' branch: master',
|
|
' remote_urls:',
|
|
' - git@github.com:owner/repo.git',
|
|
].join('\n'),
|
|
);
|
|
|
|
const loaded = await loadAutoSyncConfig();
|
|
|
|
expect(loaded.ok).toBe(true);
|
|
if (!loaded.ok) throw new Error('expected valid config');
|
|
expect(loaded.config.analyzeTimeoutMs).toBe(1_800_000);
|
|
});
|
|
|
|
it('rejects analyze_timeout values above the Node timer limit', () => {
|
|
expect(() =>
|
|
parseAutoSyncConfig(
|
|
[
|
|
'sync_interval_minutes: 5',
|
|
'analyze_timeout: 2147483648ms',
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' branch: master',
|
|
' remote_urls:',
|
|
' - git@github.com:owner/repo.git',
|
|
].join('\n'),
|
|
'/tmp/watch_config.yml',
|
|
),
|
|
).toThrow('analyze_timeout must not exceed 2147483647ms');
|
|
});
|
|
|
|
it('rejects non-boolean per-project pdg configuration', () => {
|
|
expect(() =>
|
|
parseAutoSyncConfig(
|
|
[
|
|
'sync_interval_minutes: 5',
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' branch: master',
|
|
' pdg: yes',
|
|
' remote_urls:',
|
|
' - git@github.com:owner/repo.git',
|
|
].join('\n'),
|
|
'/tmp/watch_config.yml',
|
|
),
|
|
).toThrow('projects[0].pdg must be a boolean');
|
|
});
|
|
|
|
it('rejects invalid analyze_failure_threshold values', async () => {
|
|
await fs.writeFile(
|
|
path.join(gitnexusHome, 'watch_config.yml'),
|
|
[
|
|
'sync_interval_minutes: 10',
|
|
'analyze_failure_threshold: 1',
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' branch: master',
|
|
' remote_urls:',
|
|
' - git@github.com:owner/repo.git',
|
|
].join('\n'),
|
|
);
|
|
|
|
const loaded = await loadAutoSyncConfig();
|
|
|
|
expect(loaded.ok).toBe(false);
|
|
if (loaded.ok) throw new Error('expected invalid config');
|
|
expect(loaded.message).toContain('analyze_failure_threshold must be an integer >= 2');
|
|
});
|
|
|
|
it('reports missing config without throwing', async () => {
|
|
const loaded = await loadAutoSyncConfig();
|
|
|
|
expect(loaded).toEqual({
|
|
ok: false,
|
|
reason: 'missing',
|
|
message: `[auto-sync] Missing config file: ${path.join(gitnexusHome, 'watch_config.yml')}. Auto sync is skipped.`,
|
|
});
|
|
});
|
|
|
|
it('reports invalid config without throwing', async () => {
|
|
await fs.writeFile(path.join(gitnexusHome, 'watch_config.yml'), 'projects: []\n');
|
|
|
|
const loaded = await loadAutoSyncConfig();
|
|
|
|
expect(loaded.ok).toBe(false);
|
|
if (loaded.ok) throw new Error('expected invalid config');
|
|
expect(loaded.reason).toBe('invalid');
|
|
expect(loaded.message).toContain('[auto-sync] Invalid watch_config.yml:');
|
|
expect(loaded.message).toContain('sync_interval_minutes must be a positive integer');
|
|
expect(loaded.message).toContain('projects must contain at least one project');
|
|
});
|
|
|
|
it('rejects missing, relative, and traversal local_path values at config load', async () => {
|
|
await fs.writeFile(
|
|
path.join(gitnexusHome, 'watch_config.yml'),
|
|
[
|
|
'sync_interval_minutes: 10',
|
|
'projects:',
|
|
' - local_path: ../repos',
|
|
' branch: master',
|
|
' remote_urls:',
|
|
' - git@github.com:team/repo.git',
|
|
].join('\n'),
|
|
);
|
|
|
|
const loaded = await loadAutoSyncConfig();
|
|
|
|
expect(loaded.ok).toBe(false);
|
|
if (loaded.ok) throw new Error('expected invalid config');
|
|
expect(loaded.message).toContain('local_path must be an absolute path');
|
|
});
|
|
|
|
it('hard-fails unsafe configured clone roots', async () => {
|
|
await expect(resolveConfiguredCloneRoot('/')).rejects.toThrow('unsafe auto-sync clone root');
|
|
await expect(resolveConfiguredCloneRoot(os.homedir())).rejects.toThrow(
|
|
'unsafe auto-sync clone root',
|
|
);
|
|
await expect(
|
|
resolveConfiguredCloneRoot(path.join(await fs.realpath(os.tmpdir()), 'repos')),
|
|
).rejects.toThrow('unsafe auto-sync clone root');
|
|
const root = path.join(tempDir, 'repos');
|
|
await expect(resolveConfiguredCloneRoot(`${root}/../repos`)).rejects.toThrow('normalized');
|
|
});
|
|
|
|
it('rejects GitNexus internal directory descendants as clone roots', async () => {
|
|
for (const internalDir of ['groups', 'indexes', 'quarantine']) {
|
|
const root = path.join(gitnexusHome, internalDir, 'repo-root');
|
|
await fs.mkdir(root, { recursive: true });
|
|
|
|
await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('GitNexus internal directory');
|
|
}
|
|
});
|
|
|
|
it('allows the default GitNexus repos directory as an auto-sync clone root', async () => {
|
|
const root = path.join(gitnexusHome, 'repos');
|
|
await fs.mkdir(root, { recursive: true });
|
|
|
|
await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual(
|
|
expect.objectContaining({
|
|
root,
|
|
quarantineRoot: path.join(gitnexusHome, 'watch', 'quarantine'),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'rejects symlinks in configured clone root paths',
|
|
async () => {
|
|
const realRoot = path.join(tempDir, 'real-root');
|
|
const linkRoot = path.join(tempDir, 'link-root');
|
|
await fs.mkdir(realRoot);
|
|
await fs.symlink(realRoot, linkRoot);
|
|
|
|
await expect(resolveConfiguredCloneRoot(linkRoot)).rejects.toThrow('symlink');
|
|
},
|
|
);
|
|
|
|
it('resolves safe configured clone roots and reports quarantine retention', async () => {
|
|
const root = path.join(tempDir, 'repos');
|
|
await fs.mkdir(root);
|
|
|
|
await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual(
|
|
expect.objectContaining({
|
|
root,
|
|
quarantineRoot: path.join(gitnexusHome, 'watch', 'quarantine'),
|
|
quarantineRetentionDays: 14,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('removes expired quarantine entries while preserving recent and unrelated files', async () => {
|
|
const root = path.join(tempDir, 'repos');
|
|
const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine');
|
|
const expired = path.join(quarantineRoot, 'auto-sync-expired-repo');
|
|
const recent = path.join(quarantineRoot, 'auto-sync-recent-repo');
|
|
const unrelated = path.join(quarantineRoot, 'operator-note.txt');
|
|
await fs.mkdir(expired, { recursive: true });
|
|
await fs.mkdir(recent);
|
|
await fs.writeFile(unrelated, 'keep');
|
|
const old = new Date(Date.now() - 15 * 24 * 60 * 60 * 1_000);
|
|
await fs.utimes(expired, old, old);
|
|
|
|
await resolveConfiguredCloneRoot(root);
|
|
|
|
await expect(fs.access(expired)).rejects.toThrow();
|
|
await expect(fs.access(recent)).resolves.toBeUndefined();
|
|
await expect(fs.readFile(unrelated, 'utf-8')).resolves.toBe('keep');
|
|
});
|
|
|
|
it('keeps only the newest quarantine entries per repository', async () => {
|
|
const root = path.join(tempDir, 'repos');
|
|
const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine');
|
|
await fs.mkdir(quarantineRoot, { recursive: true });
|
|
const uuid = '00000000-0000-4000-8000-000000000000';
|
|
const entryName = (stamp: string, repo: string) => `auto-sync-${stamp}-4242-${uuid}-${repo}`;
|
|
// Seven ticks of the same failing repo; age alone would keep them all.
|
|
const busy = ['01', '02', '03', '04', '05', '06', '07'].map((n) =>
|
|
entryName(`2026-08-2${n}T00-00-00-000Z`, 'busy-repo'),
|
|
);
|
|
const quiet = ['01', '02'].map((n) => entryName(`2026-08-2${n}T00-00-00-000Z`, 'quiet-repo'));
|
|
for (const name of [...busy, ...quiet]) {
|
|
await fs.mkdir(path.join(quarantineRoot, name), { recursive: true });
|
|
await fs.writeFile(path.join(quarantineRoot, `${name}.README.txt`), 'note');
|
|
}
|
|
|
|
await resolveConfiguredCloneRoot(root);
|
|
|
|
const survivors = await fs.readdir(quarantineRoot);
|
|
for (const name of busy.slice(-5)) {
|
|
expect(survivors).toContain(name);
|
|
expect(survivors).toContain(`${name}.README.txt`);
|
|
}
|
|
for (const name of busy.slice(0, 2)) {
|
|
expect(survivors).not.toContain(name);
|
|
expect(survivors).not.toContain(`${name}.README.txt`);
|
|
}
|
|
// A repo below the cap is untouched.
|
|
for (const name of quiet) expect(survivors).toContain(name);
|
|
});
|
|
|
|
it('falls back to copy and remove when quarantine crosses filesystems', async () => {
|
|
const target = path.join(tempDir, 'partial-repo');
|
|
const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine');
|
|
await fs.mkdir(target);
|
|
await fs.writeFile(path.join(target, 'partial.txt'), 'partial');
|
|
vi.spyOn(fs, 'rename').mockRejectedValueOnce(
|
|
Object.assign(new Error('cross-device link'), { code: 'EXDEV' }),
|
|
);
|
|
|
|
const destination = await quarantineAutoSyncPartial(target, quarantineRoot);
|
|
|
|
await expect(fs.readFile(path.join(destination, 'partial.txt'), 'utf-8')).resolves.toBe(
|
|
'partial',
|
|
);
|
|
await expect(fs.access(target)).rejects.toThrow();
|
|
});
|
|
|
|
it('gives concurrent partial clone quarantines unique destinations', async () => {
|
|
const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine');
|
|
const first = path.join(tempDir, 'one', 'partial-repo');
|
|
const second = path.join(tempDir, 'two', 'partial-repo');
|
|
await Promise.all([
|
|
fs.mkdir(first, { recursive: true }),
|
|
fs.mkdir(second, { recursive: true }),
|
|
]);
|
|
|
|
const [firstDestination, secondDestination] = await Promise.all([
|
|
quarantineAutoSyncPartial(first, quarantineRoot),
|
|
quarantineAutoSyncPartial(second, quarantineRoot),
|
|
]);
|
|
|
|
expect(firstDestination).not.toBe(secondDestination);
|
|
await expect(fs.access(firstDestination)).resolves.toBeUndefined();
|
|
await expect(fs.access(secondDestination)).resolves.toBeUndefined();
|
|
await expect(fs.access(first)).rejects.toThrow();
|
|
await expect(fs.access(second)).rejects.toThrow();
|
|
});
|
|
|
|
it('rejects group-writable configured clone roots', async () => {
|
|
if (process.platform === 'win32') return;
|
|
const root = path.join(tempDir, 'group-writable-repos');
|
|
await fs.mkdir(root, { mode: 0o770 });
|
|
await fs.chmod(root, 0o770);
|
|
|
|
await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('group-writable');
|
|
});
|
|
|
|
it('rejects sticky world-writable configured clone roots', async () => {
|
|
if (process.platform === 'win32') return;
|
|
const root = path.join(tempDir, 'sticky-world-writable-repos');
|
|
await fs.mkdir(root);
|
|
await fs.chmod(root, 0o1777);
|
|
|
|
await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('world-writable');
|
|
});
|
|
|
|
it('creates missing configured clone roots before watch clone work', async () => {
|
|
const root = path.join(tempDir, 'missing-repos');
|
|
|
|
await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual(
|
|
expect.objectContaining({
|
|
root,
|
|
quarantineRoot: path.join(gitnexusHome, 'watch', 'quarantine'),
|
|
}),
|
|
);
|
|
expect((await fs.stat(root)).isDirectory()).toBe(true);
|
|
});
|
|
|
|
it('parses branch strings and arrays with trimming and de-duplication', () => {
|
|
expect(parseBranchCandidates('test, master, test')).toEqual(['test', 'master']);
|
|
expect(parseBranchCandidates(['develop,master', 'develop'])).toEqual(['develop', 'master']);
|
|
});
|
|
|
|
it('rejects unsafe auto-sync branch names', () => {
|
|
expect(() => validateAutoSyncBranchName('feature/good-branch')).not.toThrow();
|
|
expect(() => validateAutoSyncBranchName('foo./bar')).toThrow('trailing-dot');
|
|
expect(() => validateAutoSyncBranchName('/main')).toThrow('must not start');
|
|
expect(() => validateAutoSyncBranchName('-upload-pack=evil')).toThrow('must not start');
|
|
expect(() => validateAutoSyncBranchName('feature bad')).toThrow('whitespace');
|
|
expect(() => validateAutoSyncBranchName('feature..bad')).toThrow('must not contain ".."');
|
|
expect(() => validateAutoSyncBranchName('bad:ref')).toThrow('not allowed');
|
|
expect(() => validateAutoSyncBranchName('feature.')).toThrow('must not end');
|
|
expect(() => validateAutoSyncBranchName('feature/')).toThrow('must not end');
|
|
expect(() => validateAutoSyncBranchName('feature//branch')).toThrow('consecutive');
|
|
expect(() => validateAutoSyncBranchName('feature@{x')).toThrow('must not contain "@{"');
|
|
expect(() => validateAutoSyncBranchName('.hidden')).toThrow('hidden');
|
|
expect(() => validateAutoSyncBranchName('foo/bar.lock')).toThrow('.lock');
|
|
});
|
|
|
|
it('extracts safe repository names from remote URLs', () => {
|
|
expect(extractRepoNameFromRemoteUrl('git@gitee.com:qts_server/qts_account.git')).toBe(
|
|
'qts_account',
|
|
);
|
|
expect(extractRepoNameFromRemoteUrl('git@gitlab.com:team/subgroup/repo-name.git')).toBe(
|
|
'repo-name',
|
|
);
|
|
});
|
|
|
|
it('rejects unsafe repository names without sanitizing them', () => {
|
|
// Rejected by the URL validator now, so the operator learns at config load
|
|
// rather than once per tick from inside the sync loop.
|
|
expect(() => extractRepoNameFromRemoteUrl('git@github.com:team/repo$name.git')).toThrow(
|
|
'repository name must use only',
|
|
);
|
|
expect(() => extractRepoNameFromRemoteUrl('git@github.com:team/repo\\name.git')).toThrow(
|
|
'repository name must use only',
|
|
);
|
|
expect(() => extractRepoNameFromRemoteUrl('git@github.com:team/..')).toThrow('traversal');
|
|
});
|
|
|
|
it('allows only github, gitlab, and gitee SSH SCP remote URLs', () => {
|
|
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo')).not.toThrow();
|
|
expect(() => validateAutoSyncRemoteUrl('git@github.com:im-fan/multica.git')).not.toThrow();
|
|
expect(() => validateAutoSyncRemoteUrl('git@gitlab.com:group/subgroup/repo.git')).not.toThrow();
|
|
expect(() =>
|
|
validateAutoSyncRemoteUrl('git@gitee.com:qts-ops/qts-code-engineering.git'),
|
|
).not.toThrow();
|
|
expect(() => validateAutoSyncRemoteUrl('https://github.com/owner/repo.git')).toThrow(
|
|
'must use',
|
|
);
|
|
expect(() => validateAutoSyncRemoteUrl('ssh://git@github.com/owner/repo.git')).toThrow(
|
|
'must use',
|
|
);
|
|
expect(() => validateAutoSyncRemoteUrl('user@github.com:owner/repo.git')).toThrow('must use');
|
|
expect(() => validateAutoSyncRemoteUrl('git@example.com:owner/repo.git')).toThrow(
|
|
'host must be',
|
|
);
|
|
// Traversal is a whole segment; consecutive dots inside a name are not.
|
|
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/foo..bar.git')).not.toThrow();
|
|
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/../escape.git')).toThrow(
|
|
'traversal',
|
|
);
|
|
// A separator smuggled into a segment is traversal on Windows even though
|
|
// the segment is not literally `..`.
|
|
expect(() => validateAutoSyncRemoteUrl(String.raw`git@github.com:..\..\outside/repo`)).toThrow(
|
|
'traversal',
|
|
);
|
|
expect(() => validateAutoSyncRemoteUrl(String.raw`git@github.com:owner\..\..\x/repo`)).toThrow(
|
|
'traversal',
|
|
);
|
|
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo.git?ref=main')).toThrow(
|
|
'must not include query strings or fragments',
|
|
);
|
|
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo.git#main')).toThrow(
|
|
'must not include query strings or fragments',
|
|
);
|
|
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/')).toThrow('path must include');
|
|
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner//repo')).toThrow(
|
|
'path must include',
|
|
);
|
|
});
|
|
|
|
it('parses repo git timeout durations', () => {
|
|
expect(parseDurationMs('10s')).toBe(10_000);
|
|
expect(parseDurationMs('2m')).toBe(120_000);
|
|
expect(parseDurationMs('5000ms')).toBe(5000);
|
|
expect(parseDurationMs('10')).toBe(10_000);
|
|
expect(parseDurationMs(10)).toBe(10_000);
|
|
});
|
|
|
|
it('keeps branch compatibility but rejects branch and branches together', async () => {
|
|
await fs.writeFile(
|
|
path.join(gitnexusHome, 'watch_config.yml'),
|
|
[
|
|
'sync_interval_minutes: 10',
|
|
'projects:',
|
|
' - local_path: /tmp/repos',
|
|
' branch: master',
|
|
' branches: [develop]',
|
|
' remote_urls:',
|
|
' - git@github.com:owner/repo.git',
|
|
].join('\n'),
|
|
);
|
|
|
|
const loaded = await loadAutoSyncConfig();
|
|
|
|
expect(loaded.ok).toBe(false);
|
|
if (loaded.ok) throw new Error('expected invalid config');
|
|
expect(loaded.message).toContain('must not set both branch and branches');
|
|
});
|
|
|
|
it('uses commit ids to skip unchanged analyses and retry failed prior analyses', () => {
|
|
expect(shouldAnalyzeCommit({ currentCommit: 'abc', previousAnalyzedCommit: 'abc' })).toBe(
|
|
false,
|
|
);
|
|
expect(
|
|
shouldAnalyzeCommit({
|
|
currentCommit: 'abc',
|
|
previousAnalyzedCommit: 'abc',
|
|
previousStatus: 'failed',
|
|
}),
|
|
).toBe(true);
|
|
expect(shouldAnalyzeCommit({ currentCommit: 'def', previousAnalyzedCommit: 'abc' })).toBe(true);
|
|
});
|
|
|
|
it('saves state atomically and reloads it', async () => {
|
|
const statePath = path.join(tempDir, 'auto-sync-state.json');
|
|
|
|
await saveAutoSyncState(
|
|
{
|
|
'/tmp/repos/qts_account|master': {
|
|
codeCommitId: 'abc',
|
|
analyzedCommitId: 'abc',
|
|
lastAnalyzeStatus: 'success',
|
|
requestedPdg: true,
|
|
analyzeConsecutiveFailures: 2,
|
|
lastAnalyzeError: 'old error',
|
|
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
|
},
|
|
},
|
|
statePath,
|
|
);
|
|
|
|
await expect(fs.readdir(tempDir)).resolves.not.toContain(
|
|
expect.stringContaining('auto-sync-state.json.tmp'),
|
|
);
|
|
await expect(loadAutoSyncState(statePath)).resolves.toEqual({
|
|
'/tmp/repos/qts_account|master': {
|
|
codeCommitId: 'abc',
|
|
analyzedCommitId: 'abc',
|
|
lastAnalyzeStatus: 'success',
|
|
requestedPdg: true,
|
|
analyzeConsecutiveFailures: 2,
|
|
lastAnalyzeError: 'old error',
|
|
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('returns empty state and reports corrupt state files', async () => {
|
|
const statePath = path.join(tempDir, 'auto-sync-state.json');
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
await fs.writeFile(statePath, '{not-json', 'utf-8');
|
|
|
|
await expect(loadAutoSyncState(statePath)).resolves.toEqual({});
|
|
|
|
expect(stderr).toHaveBeenCalledWith(
|
|
`[auto-sync] Ignoring corrupt state file: ${statePath}. State will be rebuilt.\n`,
|
|
);
|
|
});
|
|
|
|
it('propagates an unreadable state file instead of overwriting it with empty state', async () => {
|
|
// A directory stands in for any non-ENOENT read failure (EACCES, EIO).
|
|
// Returning {} here would make the next tick persist empty state over
|
|
// every repo's analyzed commit and failure counters.
|
|
const statePath = path.join(tempDir, 'unreadable-state.json');
|
|
await fs.mkdir(statePath, { recursive: true });
|
|
|
|
await expect(loadAutoSyncState(statePath)).rejects.toThrow();
|
|
});
|
|
|
|
it('drops malformed state entries while preserving valid entries', async () => {
|
|
const statePath = path.join(tempDir, 'auto-sync-state.json');
|
|
await fs.writeFile(
|
|
statePath,
|
|
JSON.stringify({
|
|
'/tmp/repos/valid|main': {
|
|
codeCommitId: 'abc',
|
|
analyzedCommitId: 'abc',
|
|
lastAnalyzeStatus: 'success',
|
|
analyzeConsecutiveFailures: 0,
|
|
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
|
},
|
|
'/tmp/repos/invalid|main': {
|
|
codeCommitId: 123,
|
|
analyzeConsecutiveFailures: -1,
|
|
lastSyncTime: null,
|
|
},
|
|
'/tmp/repos/invalid-pdg|main': {
|
|
codeCommitId: 'abc',
|
|
requestedPdg: 'true',
|
|
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(loadAutoSyncState(statePath)).resolves.toEqual({
|
|
'/tmp/repos/valid|main': {
|
|
codeCommitId: 'abc',
|
|
analyzedCommitId: 'abc',
|
|
lastAnalyzeStatus: 'success',
|
|
analyzeConsecutiveFailures: 0,
|
|
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('writes project_commit_info.txt atomically', async () => {
|
|
const infoPath = path.join(tempDir, 'project_commit_info.txt');
|
|
|
|
await writeProjectCommitInfo(
|
|
[
|
|
{
|
|
remoteUrl: 'git@github.com:owner/repo.git',
|
|
localPath: '/tmp/repos/repo',
|
|
branch: 'master',
|
|
codeCommitId: 'abc',
|
|
analyzedCommitId: 'abc',
|
|
status: 'success',
|
|
analyzeConsecutiveFailures: 0,
|
|
analyzeFailureThreshold: 3,
|
|
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
|
},
|
|
{
|
|
remoteUrl: 'git@github.com:owner/bad.git',
|
|
localPath: '/tmp/repos/bad',
|
|
branch: 'master',
|
|
codeCommitId: 'def',
|
|
analyzedCommitId: 'abc',
|
|
status: 'threshold_skipped',
|
|
analyzeConsecutiveFailures: 3,
|
|
analyzeFailureThreshold: 3,
|
|
lastAnalyzeError: 'parser crashed',
|
|
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
|
},
|
|
],
|
|
infoPath,
|
|
);
|
|
|
|
const content = await fs.readFile(infoPath, 'utf-8');
|
|
expect(content).toContain('remote: git@github.com:owner/repo.git');
|
|
expect(content).toContain('code_commit: abc');
|
|
expect(content).toContain('analyze_consecutive_failures: 0');
|
|
expect(content).toContain('analyze_failure_threshold: 3');
|
|
expect(content).toContain('status: threshold_skipped');
|
|
expect(content).toContain('last_analyze_error: parser crashed');
|
|
await expect(fs.readdir(tempDir)).resolves.not.toContain(
|
|
expect.stringContaining('project_commit_info.txt.tmp'),
|
|
);
|
|
});
|
|
});
|