GitNexus/gitnexus/test/unit/auto-sync-runner.test.ts
Matt 8b21ce3b95
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 (#3290)
* 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>
2026-09-16 20:12:45 +01:00

2404 lines
87 KiB
TypeScript

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import {
addRepoToGroup,
getAutoSyncRepoIdentity,
getConfiguredRepoPath,
getAutoSyncWatchPaths,
readAutoSyncWatchStatus,
resolveActualConcurrency,
runAutoSyncOnce,
startAutoSyncWatch,
stopAutoSyncWatch,
} from '../../src/core/auto-sync/index.js';
import type {
AutoSyncConfig,
AutoSyncRunDeps,
AutoSyncWatchPaths,
} from '../../src/core/auto-sync/index.js';
import { AutoSyncAnalysisError } from '../../src/core/auto-sync/analysis-worker-launch.js';
const config: AutoSyncConfig = {
configPath: '/tmp/.gitnexus/watch_config.yml',
syncIntervalMinutes: 10,
repoGitTimeoutMs: 10_000,
analyzeTimeoutMs: 1_800_000,
maxConcurrency: 1,
analyzeFailureThreshold: 3,
projects: [
{
localPath: '/tmp/repos',
groupName: 'back_end',
pdg: false,
overwriteLocalChanges: false,
branches: ['master'],
remoteUrls: ['git@gitee.com:qts_server/qts_account.git'],
},
],
};
const cloneRoot = {
root: '/tmp/repos',
quarantineRoot: '/tmp/.gitnexus/watch/quarantine',
quarantineRetentionDays: 14,
};
const verifiedWatchCommand = 'node /gitnexus/dist/cli/index.js auto-sync start';
const verifiedProcessStartTime = 'Tue Aug 4 12:00:00 2026';
function withCloneRoot(deps: Partial<AutoSyncRunDeps>): Partial<AutoSyncRunDeps> {
return {
resolveCloneRoot: vi.fn(async () => cloneRoot),
getExistingPdgMode: vi.fn(async () => false),
...deps,
};
}
async function runWithOnDiskPdgState(options: {
metadata?: Record<string, unknown>;
createLbug?: boolean;
pdg?: boolean;
}) {
const base = path.join(process.cwd(), '.tmp-test');
await fs.mkdir(base, { recursive: true });
const localPath = await fs.realpath(await fs.mkdtemp(path.join(base, 'auto-sync-pdg-mode-')));
const targetDir = path.join(localPath, 'github.com', 'team', 'repo');
const indexDir = path.join(targetDir, '.gitnexus');
await fs.mkdir(indexDir, { recursive: true });
if (options.metadata) {
await fs.writeFile(path.join(indexDir, 'gitnexus.json'), JSON.stringify(options.metadata));
}
if (options.createLbug) await fs.mkdir(path.join(indexDir, 'lbug'));
const runAnalysis = vi.fn(async () => ({ stats: { files: 1 } }) as any);
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const localConfig: AutoSyncConfig = {
...config,
projects: [
{
...config.projects[0],
localPath,
groupName: undefined,
pdg: options.pdg,
branches: ['main'],
remoteUrls: ['git@github.com:team/repo.git'],
},
],
};
const deps: Partial<AutoSyncRunDeps> = {
resolveCloneRoot: vi.fn(async () => ({
root: localPath,
quarantineRoot: path.join(localPath, 'quarantine'),
quarantineRetentionDays: 14,
})),
cloneOrPull: vi.fn(async () => targetDir),
getCurrentBranch: vi.fn(async () => 'main'),
getCurrentCommit: vi.fn(async () => 'commit-1'),
runAnalysis,
registerRepo: vi.fn(async () => 'repo'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
};
try {
const result = await runAutoSyncOnce(localConfig, { deps, logger });
return { result, runAnalysis, logger, targetDir };
} finally {
await fs.rm(localPath, { recursive: true, force: true });
}
}
async function writeWatchOwner(
paths: AutoSyncWatchPaths,
pid: number,
ownerId = `owner-${pid}`,
): Promise<string> {
await fs.mkdir(path.dirname(paths.pidPath), { recursive: true });
await fs.writeFile(paths.pidPath, `${pid}\n`);
await fs.writeFile(
paths.mutexPath,
`${JSON.stringify({ pid, ownerId: `mutex-${ownerId}`, processStartTime: verifiedProcessStartTime, hostname: os.hostname() })}\n`,
);
await fs.writeFile(
paths.ownerPath,
`${JSON.stringify({ pid, ownerId, processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`,
);
await fs.writeFile(
paths.statusPath,
`${JSON.stringify({
state: 'running',
pid,
ownerId,
updatedAt: '2026-06-30T00:00:00.000Z',
})}\n`,
);
return ownerId;
}
describe('auto-sync runner', () => {
it('runs clone, analyzes changed commits, registers the repo, and syncs changed groups', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'success',
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => true),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
expect(deps.cloneOrPull).toHaveBeenCalledWith(
'git@gitee.com:qts_server/qts_account.git',
'/tmp/repos/gitee.com/qts_server/qts_account',
undefined,
{
allowedCloneRoot: '/tmp/repos',
expectedRepoName: 'qts_account',
quarantineRoot: '/tmp/.gitnexus/watch/quarantine',
allowAutoSyncSsh: true,
timeoutMs: 10_000,
branch: 'master',
overwriteLocalChanges: false,
},
);
expect(deps.getCurrentBranch).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
10_000,
);
expect(deps.runAnalysis).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
{
branch: 'master',
skipAgentsMd: true,
skipSkills: true,
pdg: false,
atomicIncremental: true,
},
1_800_000,
undefined,
undefined,
1,
);
expect(deps.registerRepo).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
expect.objectContaining({
lastCommit: 'commit-2',
branch: 'master',
remoteUrl: 'git@gitee.com:qts_server/qts_account.git',
}),
{ name: 'gitee.com/qts_server/qts_account' },
);
expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end');
expect(deps.writeCommitInfo).toHaveBeenCalledWith([
expect.objectContaining({
remoteUrl: 'git@gitee.com:qts_server/qts_account.git',
codeCommitId: 'commit-2',
analyzedCommitId: 'commit-2',
status: 'success',
}),
]);
});
it('enables PDG atomically at an unchanged commit when project configuration opts in', async () => {
const pdgConfig: AutoSyncConfig = {
...config,
projects: [{ ...config.projects[0], pdg: true }],
};
const stateKey = '/tmp/repos/gitee.com/qts_server/qts_account|master';
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
getExistingPdgMode: vi.fn(async () => false),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({
[stateKey]: {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'failed',
analyzeConsecutiveFailures: 3,
lastAnalyzeError: 'old non-PDG failure',
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(pdgConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
expect(deps.runAnalysis).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
expect.objectContaining({ pdg: true, atomicIncremental: true }),
1_800_000,
undefined,
undefined,
1,
);
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
[stateKey]: expect.objectContaining({ requestedPdg: true }),
}),
);
});
it('preserves an existing PDG index when legacy project configuration omits pdg', async () => {
const legacyConfig: AutoSyncConfig = {
...config,
projects: [{ ...config.projects[0], pdg: undefined }],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
getExistingPdgMode: vi.fn(async () => true),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(legacyConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(deps.runAnalysis).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ preserveExistingPdg: true, atomicIncremental: true }),
expect.any(Number),
undefined,
undefined,
1,
);
});
it.each([
['PDG metadata', { branch: 'main', pdg: { version: 5 } }, true],
['non-PDG metadata', { branch: 'main' }, false],
['no prior index', undefined, false],
] as const)(
'derives the omitted PDG setting from %s on disk',
async (_label, metadata, expectedPdg) => {
const { result, runAnalysis, logger, targetDir } = await runWithOnDiskPdgState({ metadata });
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
expect(runAnalysis).toHaveBeenCalledWith(
targetDir,
expect.objectContaining({ preserveExistingPdg: true, atomicIncremental: true }),
expect.any(Number),
undefined,
undefined,
1,
);
if (expectedPdg) {
expect(logger.info).toHaveBeenCalledWith(
`[auto-sync] Preserving existing PDG mode for ${targetDir}; project configuration does not set pdg.`,
);
}
},
);
it('refuses to overwrite an index when its on-disk PDG mode is ambiguous', async () => {
const { result, runAnalysis, logger, targetDir } = await runWithOnDiskPdgState({
createLbug: true,
});
expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(runAnalysis).not.toHaveBeenCalled();
const error = String(logger.error.mock.calls[0]?.[0]);
expect(error).toContain(
`Cannot determine whether the existing index at ${path.join(targetDir, '.gitnexus', 'lbug')} contains PDG data`,
);
expect(error).toContain('Refusing to analyze so the live graph is preserved.');
});
it('uses an explicit PDG mode to recover an index whose metadata is ambiguous', async () => {
const { result, runAnalysis, logger, targetDir } = await runWithOnDiskPdgState({
createLbug: true,
pdg: true,
});
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
expect(runAnalysis).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ pdg: true, atomicIncremental: true }),
expect.any(Number),
undefined,
undefined,
1,
);
const warning = String(logger.warn.mock.calls[0]?.[0]);
expect(warning).toBe(
`[auto-sync] Existing PDG mode could not be determined for ${targetDir}; applying explicit pdg=true.`,
);
});
it('warns before an explicit PDG opt-out removes existing PDG data', async () => {
const warn = vi.fn();
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
getExistingPdgMode: vi.fn(async () => true),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn, error: vi.fn() },
});
expect(warn).toHaveBeenCalledWith(
'[auto-sync] PDG is explicitly disabled for /tmp/repos/gitee.com/qts_server/qts_account; the next successful rebuild will remove existing PDG data.',
);
expect(deps.runAnalysis).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ pdg: false, atomicIncremental: true }),
expect.any(Number),
undefined,
undefined,
1,
);
});
it('registers into the branch slot the analyze worker placed the index in', async () => {
// Without this the parent always takes the primary/flat arm and relabels a
// pinned branch entry with whatever this tick happened to sync.
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(async () => 'master'),
getCurrentCommit: vi.fn(async () => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
resolveBranchPlacement: vi.fn(async () => ({ branch: 'master' })),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => true),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(deps.resolveBranchPlacement).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
'master',
);
expect(deps.registerRepo).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
expect.anything(),
{ name: 'gitee.com/qts_server/qts_account', branch: 'master' },
);
});
it('syncs a group when a repo is newly added to the group', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => true),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(deps.addRepoToGroup).toHaveBeenCalledWith(
config.projects[0],
'gitee.com/qts_server/qts_account',
'gitee.com/qts_server/qts_account',
);
expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end');
});
it('syncs a group after successful re-analysis even when membership already exists', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-3'),
runAnalysis: vi.fn(async () => ({ stats: { files: 2 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-2',
analyzedCommitId: 'commit-2',
lastAnalyzeStatus: 'success',
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(result.analyzed).toBe(1);
expect(deps.addRepoToGroup).toHaveBeenCalledWith(
config.projects[0],
'gitee.com/qts_server/qts_account',
'gitee.com/qts_server/qts_account',
);
expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end');
});
it('uses distinct registry and group identities for repositories with the same basename', async () => {
const duplicateConfig: AutoSyncConfig = {
...config,
projects: [
{
localPath: '/tmp/repos-a',
groupName: 'back_end',
branches: ['main'],
remoteUrls: ['git@github.com:team-a/service.git'],
},
{
localPath: '/tmp/repos-b',
groupName: 'back_end',
branches: ['main'],
remoteUrls: ['git@gitlab.com:team-b/service.git'],
},
],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
resolveCloneRoot: vi.fn(async (localPath: string) => ({
...cloneRoot,
root: localPath,
})),
cloneOrPull: vi.fn(async (_url, targetDir) => targetDir),
getCurrentBranch: vi.fn(() => 'main'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'service'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => true),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(duplicateConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(deps.registerRepo).toHaveBeenNthCalledWith(
1,
'/tmp/repos-a/github.com/team-a/service',
expect.anything(),
{ name: 'github.com/team-a/service' },
);
expect(deps.registerRepo).toHaveBeenNthCalledWith(
2,
'/tmp/repos-b/gitlab.com/team-b/service',
expect.anything(),
{ name: 'gitlab.com/team-b/service' },
);
expect(deps.addRepoToGroup).toHaveBeenCalledWith(
duplicateConfig.projects[0],
'github.com/team-a/service',
'github.com/team-a/service',
);
expect(deps.addRepoToGroup).toHaveBeenCalledWith(
duplicateConfig.projects[1],
'gitlab.com/team-b/service',
'gitlab.com/team-b/service',
);
});
it('normalizes the .git suffix case in auto-sync repository identities', () => {
expect(getAutoSyncRepoIdentity('git@GitHub.com:team/service.GIT')).toBe(
'github.com/team/service',
);
});
it('skips analysis when commit id has not changed', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
runAnalysis: vi.fn(),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'success',
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => true),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(result.analyzed).toBe(0);
expect(result.skippedAnalysis).toBe(1);
expect(deps.runAnalysis).not.toHaveBeenCalled();
expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end');
});
it('retries a failed group sync on the next unchanged commit without re-analysis', async () => {
let persistedState: any = {
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'success',
groupSyncPending: true,
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
};
const syncGroupByName = vi
.fn()
.mockRejectedValueOnce(new Error('group temporarily unavailable'))
.mockResolvedValueOnce(undefined);
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
runAnalysis: vi.fn(),
registerRepo: vi.fn(),
loadState: vi.fn(async () => structuredClone(persistedState)),
saveState: vi.fn(async (state) => {
persistedState = structuredClone(state);
}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName,
getAvailableMemoryGB: vi.fn(() => 8),
});
const runOptions = {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
const first = await runAutoSyncOnce(config, runOptions);
const second = await runAutoSyncOnce(config, runOptions);
expect(first.failed).toBe(1);
expect(second.failed).toBe(0);
expect(deps.runAnalysis).not.toHaveBeenCalled();
expect(syncGroupByName).toHaveBeenCalledTimes(2);
expect(
persistedState['/tmp/repos/gitee.com/qts_server/qts_account|master'].groupSyncPending,
).toBe(false);
});
it('uses remote identity under local_path as the clone target', async () => {
expect(
getConfiguredRepoPath(
config.projects[0],
'qts_account',
'git@gitee.com:qts_server/qts_account.git',
),
).toBe('/tmp/repos/gitee.com/qts_server/qts_account');
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(deps.cloneOrPull).toHaveBeenCalledWith(
'git@gitee.com:qts_server/qts_account.git',
'/tmp/repos/gitee.com/qts_server/qts_account',
undefined,
{
allowedCloneRoot: '/tmp/repos',
expectedRepoName: 'qts_account',
quarantineRoot: '/tmp/.gitnexus/watch/quarantine',
allowAutoSyncSsh: true,
timeoutMs: 10_000,
branch: 'master',
overwriteLocalChanges: false,
},
);
});
it('passes watch cancellation controls to the isolated analysis runner', async () => {
const controller = new AbortController();
const onAnalysisCancellationRequested = vi.fn();
const runAnalysis = vi.fn(async () => ({ stats: { files: 1 } }) as any);
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis,
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
signal: controller.signal,
onAnalysisCancellationRequested,
});
expect(runAnalysis).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
{
branch: 'master',
skipAgentsMd: true,
skipSkills: true,
pdg: false,
atomicIncremental: true,
},
1_800_000,
controller.signal,
onAnalysisCancellationRequested,
1,
);
});
it('falls back through configured branches and analyzes the first pullable branch', async () => {
const warnLogger = vi.fn();
const errorLogger = vi.fn();
const branchConfig: AutoSyncConfig = {
...config,
projects: [{ ...config.projects[0], branches: ['missing', 'develop'] }],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async (_remoteUrl, _targetDir, _progress, options) => {
if (options?.branch === 'missing') throw new Error('remote branch not found');
return '/tmp/repos/gitee.com/qts_server/qts_account';
}),
getCurrentBranch: vi.fn(() => 'develop'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(branchConfig, {
deps,
logger: { info: vi.fn(), warn: warnLogger, error: errorLogger },
});
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
expect(deps.cloneOrPull).toHaveBeenNthCalledWith(
1,
'git@gitee.com:qts_server/qts_account.git',
'/tmp/repos/gitee.com/qts_server/qts_account',
undefined,
expect.objectContaining({ branch: 'missing' }),
);
expect(deps.cloneOrPull).toHaveBeenNthCalledWith(
2,
'git@gitee.com:qts_server/qts_account.git',
'/tmp/repos/gitee.com/qts_server/qts_account',
undefined,
expect.objectContaining({ branch: 'develop' }),
);
expect(deps.runAnalysis).toHaveBeenCalledWith(
'/tmp/repos/gitee.com/qts_server/qts_account',
{
branch: 'develop',
skipAgentsMd: true,
skipSkills: true,
pdg: false,
atomicIncremental: true,
},
1_800_000,
undefined,
undefined,
1,
);
expect(warnLogger).toHaveBeenCalledWith(
'[auto-sync] Branch missing unavailable for git@gitee.com:qts_server/qts_account.git: remote branch not found',
);
expect(errorLogger).not.toHaveBeenCalled();
});
it('records branch_unavailable when all configured branches fail', async () => {
const warnLogger = vi.fn();
const errorLogger = vi.fn();
const branchConfig: AutoSyncConfig = {
...config,
projects: [{ ...config.projects[0], branches: ['missing', 'develop'] }],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => {
throw new Error('remote branch not found');
}),
getCurrentBranch: vi.fn(),
getCurrentCommit: vi.fn(),
runAnalysis: vi.fn(),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(branchConfig, {
deps,
logger: { info: vi.fn(), warn: warnLogger, error: errorLogger },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(deps.cloneOrPull).toHaveBeenCalledTimes(2);
expect(deps.writeCommitInfo).toHaveBeenCalledWith([
expect.objectContaining({
branch: 'missing',
status: 'branch_unavailable',
}),
]);
expect(deps.getCurrentCommit).not.toHaveBeenCalled();
expect(warnLogger).toHaveBeenCalledTimes(2);
expect(warnLogger).toHaveBeenCalledWith(
'[auto-sync] Branch missing unavailable for git@gitee.com:qts_server/qts_account.git: remote branch not found',
);
expect(warnLogger).toHaveBeenCalledWith(
'[auto-sync] Branch develop unavailable for git@gitee.com:qts_server/qts_account.git: remote branch not found',
);
expect(errorLogger).toHaveBeenCalledTimes(1);
expect(errorLogger).toHaveBeenCalledWith(
'[auto-sync] Repository sync failed for git@gitee.com:qts_server/qts_account.git; no configured branch could be pulled: missing: remote branch not found; develop: remote branch not found',
);
});
it('records branch_unavailable when checkout ends on an unexpected branch', async () => {
const warnLogger = vi.fn();
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'develop'),
getCurrentCommit: vi.fn(),
runAnalysis: vi.fn(),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => true),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: warnLogger, error: vi.fn() },
});
expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(deps.getCurrentCommit).not.toHaveBeenCalled();
expect(deps.runAnalysis).not.toHaveBeenCalled();
expect(deps.addRepoToGroup).not.toHaveBeenCalled();
expect(warnLogger).toHaveBeenCalledWith(
'[auto-sync] Branch master for git@gitee.com:qts_server/qts_account.git synced but current branch is develop; trying next branch.',
);
});
it('records branch_unavailable when the checked out repository is detached', async () => {
const warnLogger = vi.fn();
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => undefined),
getCurrentCommit: vi.fn(),
runAnalysis: vi.fn(),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => true),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: warnLogger, error: vi.fn() },
});
expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(deps.getCurrentCommit).not.toHaveBeenCalled();
expect(deps.runAnalysis).not.toHaveBeenCalled();
expect(deps.addRepoToGroup).not.toHaveBeenCalled();
expect(warnLogger).toHaveBeenCalledWith(
'[auto-sync] Branch master for git@gitee.com:qts_server/qts_account.git synced but current branch is <detached>; trying next branch.',
);
});
it('isolates repository and analysis failures without syncing groups for failed analysis', async () => {
const errorLogger = vi.fn();
const failingConfig: AutoSyncConfig = {
...config,
projects: [
{
...config.projects[0],
remoteUrls: [
'git@gitee.com:qts_server/failing_sync.git',
'git@gitee.com:qts_server/qts_account.git',
],
},
],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async (remoteUrl) => {
if (remoteUrl.includes('failing_sync')) throw new Error('sync failed');
return '/tmp/repos/gitee.com/qts_server/qts_account';
}),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => {
throw new Error('analysis failed');
}),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'success',
groupSyncPending: true,
lastSyncTime: '2026-06-29T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => true),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(failingConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 2 });
expect(deps.cloneOrPull).toHaveBeenCalledTimes(2);
expect(deps.registerRepo).not.toHaveBeenCalled();
expect(deps.addRepoToGroup).toHaveBeenCalledWith(
failingConfig.projects[0],
'gitee.com/qts_server/qts_account',
'gitee.com/qts_server/qts_account',
);
expect(deps.syncGroupByName).not.toHaveBeenCalled();
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
codeCommitId: 'commit-2',
lastAnalyzeStatus: 'failed',
}),
}),
);
expect(errorLogger).toHaveBeenCalledWith(
expect.stringContaining(
'Repository sync failed for git@gitee.com:qts_server/failing_sync.git',
),
);
expect(errorLogger).toHaveBeenCalledWith(
expect.stringContaining('Analysis failed for /tmp/repos/gitee.com/qts_server/qts_account'),
);
});
it('records the resolved target directory when a post-sync operation fails', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async (_url, targetDir) => targetDir),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => {
throw new Error('git log failed');
}),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await expect(
runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}),
).resolves.toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(deps.writeCommitInfo).toHaveBeenCalledWith([
expect.objectContaining({
remoteUrl: 'git@gitee.com:qts_server/qts_account.git',
localPath: '/tmp/repos/gitee.com/qts_server/qts_account',
status: 'sync_failed',
}),
]);
});
it('isolates clone-root resolution failures to the affected project', async () => {
const isolatedConfig: AutoSyncConfig = {
...config,
projects: [
{ ...config.projects[0], localPath: '/bad/repos' },
{ ...config.projects[0], localPath: '/tmp/repos' },
],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
resolveCloneRoot: vi.fn(async (localPath: string) => {
if (localPath === '/bad/repos') throw new Error('unsafe clone root');
return cloneRoot;
}),
cloneOrPull: vi.fn(async (_url, targetDir) => targetDir),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'repo'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await expect(
runAutoSyncOnce(isolatedConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}),
).resolves.toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 1 });
expect(deps.cloneOrPull).toHaveBeenCalledTimes(1);
expect(deps.saveState).toHaveBeenCalledTimes(1);
expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1);
});
it('persists state and commit info when repository registration fails', async () => {
const errorLogger = vi.fn();
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async (_url, targetDir) => targetDir),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => {
throw new Error('registry busy');
}),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
lastAnalyzeStatus: 'failed',
analyzedCommitId: undefined,
lastAnalyzeError: 'Repository registration failed: registry busy',
}),
}),
);
expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1);
expect(errorLogger).toHaveBeenCalledWith(
'[auto-sync] Repository registration failed: registry busy',
);
});
it('reports group sync failures after successful analysis', async () => {
const errorLogger = vi.fn();
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {
throw new Error('group sync failed');
}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger },
});
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 1 });
expect(deps.addRepoToGroup).toHaveBeenCalledWith(
config.projects[0],
'gitee.com/qts_server/qts_account',
'gitee.com/qts_server/qts_account',
);
expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end');
expect(errorLogger).toHaveBeenCalledWith(
expect.stringContaining('Group sync failed for back_end'),
);
});
it('caps actual concurrency by available memory and runs clone/analyze work concurrently', async () => {
const events: string[] = [];
let releaseFirstClone: (() => void) | undefined;
const concurrentConfig: AutoSyncConfig = {
...config,
maxConcurrency: 4,
projects: [
{
...config.projects[0],
groupName: undefined,
remoteUrls: ['git@github.com:owner/one.git', 'git@gitlab.com:owner/two.git'],
},
],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async (remoteUrl) => {
events.push(`clone-start:${remoteUrl}`);
if (remoteUrl.includes('/one.git')) {
await new Promise<void>((resolve) => {
releaseFirstClone = resolve;
setTimeout(resolve, 0);
});
} else {
releaseFirstClone?.();
}
events.push(`clone-end:${remoteUrl}`);
return remoteUrl.includes('/one.git') ? '/tmp/repos/one' : '/tmp/repos/two';
}),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn((repoPath) =>
repoPath.endsWith('/one') ? 'one-commit' : 'two-commit',
),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'repo'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 4),
});
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const result = await runAutoSyncOnce(concurrentConfig, { deps, logger });
expect(result.synced).toBe(2);
expect(logger.info).toHaveBeenCalledWith(
'[auto-sync] Starting sync loop with max_concurrency=2 analyze_failure_threshold=3.',
);
expect(events.slice(0, 2)).toEqual([
'clone-start:git@github.com:owner/one.git',
'clone-start:git@gitlab.com:owner/two.git',
]);
expect(deps.registerRepo).toHaveBeenCalledTimes(2);
expect(deps.saveState).toHaveBeenCalledTimes(1);
expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1);
});
it('keeps same-basename remotes in distinct clone directories', async () => {
const duplicateConfig: AutoSyncConfig = {
...config,
maxConcurrency: 2,
projects: [
{
...config.projects[0],
branches: ['main'],
remoteUrls: ['git@github.com:owner/repo.git', 'git@gitlab.com:group/repo.git'],
},
],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async (_url, targetDir) => targetDir),
getCurrentBranch: vi.fn(() => 'main'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async (_path, _meta, options) => options?.name ?? 'repo'),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await expect(
runAutoSyncOnce(duplicateConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}),
).resolves.toEqual({ synced: 2, analyzed: 2, skippedAnalysis: 0, failed: 0 });
expect(deps.cloneOrPull).toHaveBeenNthCalledWith(
1,
'git@github.com:owner/repo.git',
'/tmp/repos/github.com/owner/repo',
undefined,
expect.any(Object),
);
expect(deps.cloneOrPull).toHaveBeenNthCalledWith(
2,
'git@gitlab.com:group/repo.git',
'/tmp/repos/gitlab.com/group/repo',
undefined,
expect.any(Object),
);
expect(deps.saveState).toHaveBeenCalledTimes(1);
expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1);
});
it('rejects non auto-sync SSH URLs at runner boundary', async () => {
const invalidConfig: AutoSyncConfig = {
...config,
projects: [{ ...config.projects[0], remoteUrls: ['https://github.com/owner/repo.git'] }],
};
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(invalidConfig, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
});
expect(result.failed).toBe(1);
expect(deps.cloneOrPull).not.toHaveBeenCalled();
});
it('resets consecutive analyze failures when the code commit changes, then records this failure', async () => {
const errorLogger = vi.fn();
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => {
throw new Error('parser crashed\nwith stack');
}),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'failed',
// New code commit (commit-1 → commit-2) zeros this before the failed analysis increments to 1.
analyzeConsecutiveFailures: 1,
lastAnalyzeError: 'old error',
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
analyzeConsecutiveFailures: 1,
lastAnalyzeError: 'parser crashed with stack',
lastAnalyzeStatus: 'failed',
}),
}),
);
expect(deps.writeCommitInfo).toHaveBeenCalledWith([
expect.objectContaining({
status: 'failed',
analyzeConsecutiveFailures: 1,
analyzeFailureThreshold: 3,
lastAnalyzeError: 'parser crashed with stack',
}),
]);
expect(errorLogger).toHaveBeenCalledWith(
'[auto-sync] Analysis failed for /tmp/repos/gitee.com/qts_server/qts_account; consecutive failures 1/3: parser crashed with stack',
);
});
it('does not count leftover-worker timeout toward the analyze failure threshold', async () => {
const errorLogger = vi.fn();
const leftover = new AutoSyncAnalysisError(
'Analysis timed out after 50ms. The analyze worker did not exit within 5000ms; it was left running so its native work is not interrupted.',
{ abandonedWorker: true, retryable: true },
);
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
runAnalysis: vi.fn(async () => {
throw leftover;
}),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'failed',
analyzeConsecutiveFailures: 1,
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({
synced: 1,
analyzed: 0,
skippedAnalysis: 0,
failed: 1,
abandonedAnalysisWorker: true,
});
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
analyzeConsecutiveFailures: 1,
lastAnalyzeStatus: 'failed',
}),
}),
);
expect(errorLogger).toHaveBeenCalledWith(
expect.stringContaining(
'retryable leftover-worker or index-lock wait (not counted toward threshold)',
),
);
});
it('does not count a retryable live-holder index-lock timeout toward the analyze failure threshold', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
runAnalysis: vi.fn(async () => {
throw new AutoSyncAnalysisError('waited for the index lock', {
code: 'index-lock-timeout',
retryable: true,
});
}),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
analyzeConsecutiveFailures: 0,
lastAnalyzeStatus: 'failed',
}),
}),
);
});
it('still counts a non-retryable index-lock guard timeout toward the analyze failure threshold', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-1'),
runAnalysis: vi.fn(async () => {
throw new AutoSyncAnalysisError('index lock guard timeout', {
code: 'index-lock-timeout',
retryable: false,
});
}),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
analyzeConsecutiveFailures: 1,
lastAnalyzeStatus: 'failed',
}),
}),
);
});
it('records a null analysis failure without masking it with a TypeError', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => {
throw null;
}),
registerRepo: vi.fn(),
loadState: vi.fn(async () => ({})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
await expect(
runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
now: () => new Date('2026-06-30T00:00:00.000Z'),
}),
).resolves.toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 });
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
lastAnalyzeError: 'null',
}),
}),
);
});
it('retries analysis on a new commit after consecutive failures reached the threshold', async () => {
const errorLogger = vi.fn();
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'failed',
analyzeConsecutiveFailures: 3,
lastAnalyzeError: 'parser crashed',
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
expect(deps.runAnalysis).toHaveBeenCalledTimes(1);
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
analyzeConsecutiveFailures: 0,
lastAnalyzeError: undefined,
lastAnalyzeStatus: 'success',
}),
}),
);
expect(deps.writeCommitInfo).toHaveBeenCalledWith([
expect.objectContaining({
status: 'success',
analyzeConsecutiveFailures: 0,
analyzeFailureThreshold: 3,
lastAnalyzeError: undefined,
}),
]);
expect(errorLogger).not.toHaveBeenCalled();
});
it('clears prior analyze failure count after a successful analyze', async () => {
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
getCurrentBranch: vi.fn(() => 'master'),
getCurrentCommit: vi.fn(() => 'commit-2'),
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
registerRepo: vi.fn(async () => 'qts_account'),
loadState: vi.fn(async () => ({
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
codeCommitId: 'commit-1',
analyzedCommitId: 'commit-1',
lastAnalyzeStatus: 'failed',
analyzeConsecutiveFailures: 2,
lastAnalyzeError: 'old error',
lastSyncTime: '2026-01-01T00:00:00.000Z',
},
})),
saveState: vi.fn(async () => {}),
writeCommitInfo: vi.fn(async () => {}),
addRepoToGroup: vi.fn(async () => false),
syncGroupByName: vi.fn(async () => {}),
getAvailableMemoryGB: vi.fn(() => 8),
});
const result = await runAutoSyncOnce(config, {
deps,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
now: () => new Date('2026-06-30T00:00:00.000Z'),
});
expect(result.analyzed).toBe(1);
expect(deps.saveState).toHaveBeenCalledWith(
expect.objectContaining({
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
analyzeConsecutiveFailures: 0,
lastAnalyzeError: undefined,
lastAnalyzeStatus: 'success',
}),
}),
);
});
it('resolves actual concurrency from configured value and memory', () => {
expect(resolveActualConcurrency(8, 10)).toBe(5);
expect(resolveActualConcurrency(8, 1)).toBe(1);
expect(resolveActualConcurrency(2, 10)).toBe(2);
});
it('detects existing groupPath to registryName mappings as already joined', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-group-'));
try {
process.env.GITNEXUS_HOME = tempDir;
const groupDir = path.join(tempDir, 'groups', 'back_end');
await fs.mkdir(groupDir, { recursive: true });
await fs.writeFile(
path.join(groupDir, 'group.yaml'),
['version: 1', 'name: back_end', 'repos:', ' hr/hiring/backend: qts_account'].join('\n'),
);
await expect(
addRepoToGroup({ groupName: 'back_end' }, 'hr/hiring/backend', 'qts_account'),
).resolves.toBe(false);
await expect(fs.readFile(path.join(groupDir, 'group.yaml'), 'utf-8')).resolves.toContain(
'hr/hiring/backend: qts_account',
);
} finally {
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
describe('auto-sync starter', () => {
it('registers a clearable timer with a valid fixed config', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
const timer = { unref: vi.fn() };
const setIntervalFn = vi.fn(() => timer) as unknown as typeof setInterval;
const clearIntervalFn = vi.fn() as unknown as typeof clearInterval;
const runOnce = vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }));
const stderr = { write: vi.fn() };
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' group_name: back_end',
' branch: master',
' remote_urls:',
' - git@gitee.com:qts_server/qts_account.git',
].join('\n'),
);
const handle = await startAutoSyncWatch({
setIntervalFn,
clearIntervalFn,
runOnce,
stderr,
keepAlive: false,
deps: { isProcessAlive: vi.fn(() => false) },
});
expect(handle).not.toBeNull();
expect(runOnce).toHaveBeenCalledTimes(1);
expect(setIntervalFn).toHaveBeenCalledWith(expect.any(Function), 300_000);
expect(timer.unref).toHaveBeenCalled();
await vi.waitFor(() => {
expect(stderr.write).toHaveBeenCalledWith(
expect.stringContaining('[auto-sync] Watch loop started at '),
);
expect(stderr.write).toHaveBeenCalledWith(
'[auto-sync] Watch loop finished: synced=0 analyzed=0 skipped=0 failed=0.\n',
);
});
await handle?.stop();
expect(clearIntervalFn).toHaveBeenCalledWith(timer);
} finally {
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('coalesces overlapping scheduled ticks into one immediate follow-up run', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
const timer = { unref: vi.fn() };
let scheduled: (() => void) | undefined;
const setIntervalFn = vi.fn((fn: () => void) => {
scheduled = fn;
return timer;
}) as unknown as typeof setInterval;
const stderr = { write: vi.fn() };
const releaseRuns: Array<() => void> = [];
const runOnce = vi.fn(
() =>
new Promise<any>((resolve) => {
releaseRuns.push(() =>
resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }),
);
}),
);
let handle: Awaited<ReturnType<typeof startAutoSyncWatch>> | undefined;
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr });
scheduled?.();
scheduled?.();
expect(runOnce).toHaveBeenCalledTimes(1);
expect(stderr.write).toHaveBeenCalledWith(
'[auto-sync] Previous run is still active; queued one immediate follow-up.\n',
);
releaseRuns.shift()?.();
await vi.waitFor(() => expect(runOnce).toHaveBeenCalledTimes(2));
releaseRuns.shift()?.();
await handle?.stop();
handle = undefined;
} finally {
releaseRuns.splice(0).forEach((release) => release());
await handle?.stop();
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('defers a coalesced follow-up when the finished run left an analyze worker running', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
const timer = { unref: vi.fn() };
let scheduled: (() => void) | undefined;
const setIntervalFn = vi.fn((fn: () => void) => {
scheduled = fn;
return timer;
}) as unknown as typeof setInterval;
const stderr = { write: vi.fn() };
const releaseRuns: Array<() => void> = [];
const runOnce = vi.fn(
() =>
new Promise<any>((resolve) => {
releaseRuns.push(() =>
resolve({
synced: 1,
analyzed: 0,
skippedAnalysis: 0,
failed: 1,
abandonedAnalysisWorker: true,
}),
);
}),
);
let handle: Awaited<ReturnType<typeof startAutoSyncWatch>> | undefined;
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr });
scheduled?.();
scheduled?.();
expect(runOnce).toHaveBeenCalledTimes(1);
releaseRuns.shift()?.();
await vi.waitFor(() => {
expect(stderr.write).toHaveBeenCalledWith(
'[auto-sync] Previous run left an analyze worker running; deferring the coalesced follow-up to the next interval.\n',
);
});
expect(runOnce).toHaveBeenCalledTimes(1);
await handle?.stop();
handle = undefined;
} finally {
releaseRuns.splice(0).forEach((release) => release());
await handle?.stop();
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('does not start a new run from a queued interval tick after stop', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
const timer = { unref: vi.fn() };
let scheduled: (() => void) | undefined;
const setIntervalFn = vi.fn((fn: () => void) => {
scheduled = fn;
return timer;
}) as unknown as typeof setInterval;
const stderr = { write: vi.fn() };
let releaseRun: (() => void) | undefined;
const runOnce = vi.fn(
() =>
new Promise<any>((resolve) => {
releaseRun = () => resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 });
}),
);
let handle: Awaited<ReturnType<typeof startAutoSyncWatch>> | undefined;
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr });
expect(runOnce).toHaveBeenCalledTimes(1);
scheduled?.();
const stopping = handle!.stop();
releaseRun?.();
await stopping;
handle = undefined;
scheduled?.();
expect(runOnce).toHaveBeenCalledTimes(1);
} finally {
releaseRun?.();
await handle?.stop();
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('cancels the active run before removing watch ownership files', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
const cancelled = vi.fn();
const runOnce = vi.fn(
(_config, options) =>
new Promise<any>((resolve) => {
options?.signal?.addEventListener(
'abort',
() => {
cancelled();
resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 });
},
{ once: true },
);
}),
);
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
const handle = await startAutoSyncWatch({
runOnce,
keepAlive: false,
deps: { isProcessAlive: vi.fn(() => false) },
});
const paths = getAutoSyncWatchPaths(tempDir);
await handle!.stop();
expect(cancelled).toHaveBeenCalledTimes(1);
await expect(fs.access(paths.pidPath)).rejects.toThrow();
await expect(fs.access(paths.ownerPath)).rejects.toThrow();
} finally {
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('refuses a second running watch for the same GITNEXUS_HOME', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
const stderr = { write: vi.fn() };
try {
await writeWatchOwner(paths, 12345);
const handle = await startAutoSyncWatch({
paths,
stderr,
deps: {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => verifiedWatchCommand),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
},
});
expect(handle).toBeNull();
expect(stderr.write).toHaveBeenCalledWith(
'[auto-sync] Watch is already running with pid 12345.\n',
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('recovers an abandoned watch mutex after the owner exits', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
const stderr = { write: vi.fn() };
try {
process.env.GITNEXUS_HOME = tempDir;
await writeWatchOwner(paths, 12345, 'abandoned-owner');
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
const handle = await startAutoSyncWatch({
paths,
stderr,
runOnce: vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })),
keepAlive: false,
deps: { isProcessAlive: vi.fn(() => false) },
});
expect(handle).not.toBeNull();
expect(await fs.readFile(paths.pidPath, 'utf-8')).toBe(`${process.pid}\n`);
expect(await fs.readFile(paths.ownerPath, 'utf-8')).not.toContain('abandoned-owner');
await handle?.stop();
await expect(fs.access(paths.mutexPath)).rejects.toThrow();
} finally {
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('does not delete a half-initialized lease when pid has not been written yet', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
const stderr = { write: vi.fn() };
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.mkdir(path.dirname(paths.pidPath), { recursive: true });
await fs.mkdir(paths.mutexPath);
await fs.writeFile(
paths.ownerPath,
`${JSON.stringify({ pid: 12345, ownerId: 'starting-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`,
);
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
const handle = await startAutoSyncWatch({
paths,
stderr,
runOnce: vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })),
deps: {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => verifiedWatchCommand),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
},
});
expect(handle).toBeNull();
expect(stderr.write).toHaveBeenCalledWith(
'[auto-sync] Watch is already running with pid 12345.\n',
);
expect(await fs.readFile(paths.ownerPath, 'utf-8')).toContain('starting-owner');
await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined();
await expect(fs.access(paths.pidPath)).rejects.toThrow();
} finally {
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('does not delete a live half-initialized lease when stop runs before pid is written', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
const stderr = { write: vi.fn() };
try {
await fs.mkdir(path.dirname(paths.pidPath), { recursive: true });
await fs.mkdir(paths.mutexPath);
await fs.writeFile(
paths.ownerPath,
`${JSON.stringify({ pid: 12345, ownerId: 'starting-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`,
);
await expect(
stopAutoSyncWatch({
paths,
stderr,
deps: { isProcessAlive: vi.fn(() => true) },
}),
).resolves.toBe('refused');
expect(stderr.write).toHaveBeenCalledWith(
'[auto-sync] Watch appears to be starting with pid 12345; pid file is not ready.\n',
);
expect(await fs.readFile(paths.ownerPath, 'utf-8')).toContain('starting-owner');
await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined();
await expect(fs.access(paths.statusPath)).rejects.toThrow();
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('does not delete a stale half-initialized lease from the stopper', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
try {
await fs.mkdir(path.dirname(paths.pidPath), { recursive: true });
await fs.mkdir(paths.mutexPath);
await fs.writeFile(
paths.ownerPath,
`${JSON.stringify({ pid: 12345, ownerId: 'stale-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`,
);
await expect(
stopAutoSyncWatch({
paths,
stderr: { write: vi.fn() },
deps: { isProcessAlive: vi.fn(() => false) },
}),
).resolves.toBe('refused');
expect(await fs.readFile(paths.ownerPath, 'utf-8')).toContain('stale-owner');
await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined();
await expect(fs.access(paths.statusPath)).rejects.toThrow();
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('reports not_running when no watch lease exists', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
try {
await expect(
stopAutoSyncWatch({
paths: getAutoSyncWatchPaths(tempDir),
stderr: { write: vi.fn() },
}),
).resolves.toBe('not_running');
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('writes an owner-fenced stop request without deleting a live watch lease', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
try {
const ownerId = await writeWatchOwner(paths, 12345, 'verified-owner');
await expect(
stopAutoSyncWatch({
paths,
timeoutMs: 0,
stderr: { write: vi.fn() },
deps: {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => verifiedWatchCommand),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
},
}),
).resolves.toBe('timeout');
expect(
JSON.parse(
await fs.readFile(
path.join(path.dirname(paths.pidPath), `watch.stop.${ownerId}.json`),
'utf-8',
),
),
).toMatchObject({
pid: 12345,
ownerId,
processStartTime: verifiedProcessStartTime,
requestedAt: expect.any(String),
});
await expect(fs.readFile(paths.pidPath, 'utf-8')).resolves.toBe('12345\n');
await expect(fs.access(paths.ownerPath)).resolves.toBeUndefined();
await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined();
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('refuses to request stop for a reused pid', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
try {
await writeWatchOwner(paths, 12345);
await expect(
stopAutoSyncWatch({
paths,
stderr: { write: vi.fn() },
deps: {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => 'node unrelated-service.js'),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
},
}),
).resolves.toBe('refused');
await expect(
readAutoSyncWatchStatus(paths, {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => 'node unrelated-service.js'),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
}),
).resolves.toMatchObject({
state: 'error',
pid: 12345,
message: expect.stringContaining('not a GitNexus auto-sync process'),
updatedAt: '2026-06-30T00:00:00.000Z',
});
await expect(fs.readdir(path.dirname(paths.pidPath))).resolves.not.toContainEqual(
expect.stringMatching(/^watch\.stop\./),
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('ignores a tampered ownerId that would escape the watch directory', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
try {
await writeWatchOwner(paths, 12345, '../../victim');
await expect(
stopAutoSyncWatch({
paths,
stderr: { write: vi.fn() },
deps: {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => verifiedWatchCommand),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
},
}),
).resolves.toBe('refused');
await expect(fs.readdir(path.dirname(paths.pidPath))).resolves.not.toContainEqual(
expect.stringMatching(/victim/),
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('does not trust a stored error status for an unverified live pid', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
try {
const ownerId = await writeWatchOwner(paths, 12345);
await fs.writeFile(
paths.statusPath,
`${JSON.stringify({
state: 'error',
pid: 12345,
ownerId,
message: 'stale stored failure',
updatedAt: '2026-06-30T00:00:00.000Z',
})}\n`,
);
await expect(
readAutoSyncWatchStatus(paths, {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => 'node unrelated-service.js'),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
}),
).resolves.toMatchObject({
state: 'error',
pid: 12345,
message: expect.stringContaining('not a GitNexus auto-sync process'),
updatedAt: '2026-06-30T00:00:00.000Z',
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('preserves stored updatedAt when the watch pid is stale', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
try {
await writeWatchOwner(paths, 12345);
await expect(
readAutoSyncWatchStatus(paths, {
isProcessAlive: vi.fn(() => false),
}),
).resolves.toMatchObject({
state: 'stale',
pid: 12345,
updatedAt: '2026-06-30T00:00:00.000Z',
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('stops a watch only when its own owner-fenced request is polled', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
const callbacks: Array<() => void> = [];
const timer = { unref: vi.fn() };
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
const handle = await startAutoSyncWatch({
paths,
keepAlive: false,
setIntervalFn: vi.fn((callback: () => void) => {
callbacks.push(callback);
return timer;
}) as unknown as typeof setInterval,
clearIntervalFn: vi.fn() as unknown as typeof clearInterval,
runOnce: vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })),
deps: { readProcessStartTime: vi.fn(() => verifiedProcessStartTime) },
});
expect(handle).not.toBeNull();
const owner = JSON.parse(await fs.readFile(paths.ownerPath, 'utf-8'));
await fs.writeFile(
path.join(path.dirname(paths.pidPath), `watch.stop.${owner.ownerId}.json`),
`${JSON.stringify({
pid: process.pid,
ownerId: owner.ownerId,
processStartTime: verifiedProcessStartTime,
requestedAt: new Date().toISOString(),
})}\n`,
);
callbacks[0]!();
await vi.waitFor(async () => expect(fs.access(paths.pidPath)).rejects.toThrow());
await expect(fs.access(paths.ownerPath)).rejects.toThrow();
await expect(fs.access(paths.mutexPath)).rejects.toThrow();
} finally {
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('reports cancelling until a timed-out analysis run settles', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
let releaseRun!: () => void;
let requestCancellation!: () => void;
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
const handle = await startAutoSyncWatch({
paths,
keepAlive: false,
runOnce: vi.fn(
(_config, options) =>
new Promise<any>((resolve) => {
requestCancellation = options.onAnalysisCancellationRequested;
releaseRun = () => resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 });
}),
),
deps: { readProcessStartTime: vi.fn(() => verifiedProcessStartTime) },
});
requestCancellation();
await vi.waitFor(async () => {
await expect(
readAutoSyncWatchStatus(paths, {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => verifiedWatchCommand),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
}),
).resolves.toMatchObject({ state: 'cancelling' });
});
releaseRun();
await vi.waitFor(async () => {
await expect(
readAutoSyncWatchStatus(paths, {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => verifiedWatchCommand),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
}),
).resolves.toMatchObject({ state: 'running' });
});
await handle?.stop();
} finally {
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('keeps watch ownership while stop waits for an active run to settle', async () => {
const previousHome = process.env.GITNEXUS_HOME;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-'));
const paths = getAutoSyncWatchPaths(tempDir);
let releaseRun!: () => void;
try {
process.env.GITNEXUS_HOME = tempDir;
await fs.writeFile(
path.join(tempDir, 'watch_config.yml'),
[
'sync_interval_minutes: 5',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
const handle = await startAutoSyncWatch({
paths,
keepAlive: false,
runOnce: vi.fn(
() =>
new Promise<any>((resolve) => {
releaseRun = () => resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 });
}),
),
deps: { readProcessStartTime: vi.fn(() => verifiedProcessStartTime) },
});
const stopping = handle!.stop();
await vi.waitFor(async () => {
await expect(
readAutoSyncWatchStatus(paths, {
isProcessAlive: vi.fn(() => true),
readProcessCommand: vi.fn(() => verifiedWatchCommand),
readProcessStartTime: vi.fn(() => verifiedProcessStartTime),
}),
).resolves.toMatchObject({ state: 'stopping' });
});
await expect(fs.access(paths.pidPath)).resolves.toBeUndefined();
await expect(fs.access(paths.ownerPath)).resolves.toBeUndefined();
await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined();
releaseRun();
await stopping;
await expect(fs.access(paths.pidPath)).rejects.toThrow();
await expect(fs.access(paths.ownerPath)).rejects.toThrow();
await expect(fs.access(paths.mutexPath)).rejects.toThrow();
} finally {
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previousHome;
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});