mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* adds an opt-in auto sync and analysis loop for GitNexus * adds an opt-in auto sync and analysis loop for GitNexus,gitnexus watch [init|start|restart|stop|status] * adds an opt-in auto sync and analysis loop for GitNexus,gitnexus watch [init|start|restart|stop|status] * fix: address PR review cleanup * Prettier code style * merge main * fix(watch): protect local repos and cancel active analysis * fix(watch): harden auto-sync lifecycle and locking - validate watch process identity before lifecycle operations\n- serialize registry, analysis, and LadybugDB access with recoverable locks\n- harden clone paths, symlinks, hooks, quarantine, and worker timeouts\n- install procps in the CLI image for reliable Docker watch control\n- add focused regression coverage for lifecycle, locks, clone, and registry behavior * update agents & claude md * merge main * fix(watch): harden auto-sync lifecycle * fix(watch): normalize SSH repo identity paths * fix(watch): normalize SSH repo identity paths * fix(watch): safely cancel analysis across platforms * fix(auto-sync): close worker and group sync failure paths * fix(auto-sync): drop retired allowStale from group sync allowStale was removed from SyncOptions, which broke typecheck and CI on this PR. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(watch): satisfy prefer-const and Prettier in auto-sync The watch timers are assigned exactly once, so prefer-const rejected the deferred `let` declarations. They are only read from `stop()` and the control poll, both of which run after the assignments, so binding them at creation is safe and drops the now-dead undefined guards. Remaining files are formatting only. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(watch): make lock identity absolute and stop three fail-open paths Lock owner identity was rendered by `ps -o lstart=` through localtime and the active locale, so the same live process produced a different string under a different TZ. A mismatch reads as PID reuse, so one daemon could reclaim a mutex another still held. Pin TZ=UTC and LC_ALL=C. The owner record also carried no hostname, so a holder on another machine was judged by this kernel's view of its PID — always "stale" — and its lock stolen whenever GITNEXUS_HOME is a shared volume. Record and compare the hostname, as the index lock already does. Ownership verification threw unconditionally on win32, which is reached once per project per tick, so watch reported `running` and then failed every repo forever. POSIX uid/mode cannot be checked there; skip those two assertions and keep the dangerous-root, symlink, containment and internal-root guards. Also: quarantine sweep now refuses a symlinked root instead of deleting through it; an unreadable state file propagates instead of being rewritten as empty state, which used to erase every repo's analyzed commit and failure count; a failed staging cleanup no longer strands a published lock with no release handle; and the concurrency runner settles every worker before surfacing a failure so cancellation cannot orphan a live analyze fork. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(watch): land the deferred review findings Six findings that were deferred from the review backlog, plus the docs they change. Worker heap: admission allowed `floor(availableMemoryGB / 2)` slots while every fork was handed the whole machine's heap cap, so the budget meant nothing as soon as an operator raised max_concurrency. Divide the cap by the repos actually analyzed in parallel. The default single-project path is unchanged. Registration: the parent registered without a branch, so it always took the primary/flat arm and relabelled a pinned branch entry on the branch-fallback path. Reproduce the worker's own resolveBranchPlacement decision instead. Cancellation: requestCancellation cleared the only timer and settled nothing, so a worker wedged past its safe point left the promise pending forever, wedging activeRun and hanging `watch stop`. Add a 5s grace after which the parent stops waiting and releases the IPC channel's hold on its event loop. The child is still never killed — it may be inside native work. overwrite_local_changes: `checkout --force` rewrites tracked files only, so untracked sources survived and were indexed as if they came from the remote. `git clean -fd -e /.gitnexus` after checkout; no -x/-X, so ignored paths and GitNexus's own storage survive. Quarantine: age alone never bounds a repo that fails every tick, since each partial clone is younger than the retention window. Keep the five newest per repo. Validation: repo_git_timeout is now bounded by the lesser of an hour and the sync interval, which is also the guard for the bare-number-means-seconds slip (`600000` meant ~7 days and cleared the timer ceiling). And the remote URL's final segment is validated at config load rather than failing once per tick inside the sync loop. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(watch): release an errored worker, and stop rejecting dotted repo names Three findings from the latest review pass. The 'error' handler settles immediately rather than waiting out the grace, so cleanup() clears the grace timer that would otherwise have released the child. An errored IPC channel does not mean the worker stopped, so release it on that path too — still no kill. The traversal guard tested the raw path for '..', which also rejected an ordinary name like owner/foo..bar that the repository-name rule accepts. Traversal is a whole segment, so test segments. The heap-cap test left two runs and their real timers pending; it now stubs timers and settles both promises. Registration coverage now pins the branch slot rather than leaving it implicit. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(watch): validate namespace segments and pin the stopped process identity Replacing the raw-string `..` test with a per-segment one dropped a guard: a segment like `..\..\outside` is not literally `..`, so it passed, and those segments build the clone path — on Windows the backslashes are separators. Hold every namespace segment to the same charset as the repo name, which keeps a separator out of a segment while still allowing an ordinary `foo..bar`. The final segment keeps its own check so a bad repo name keeps its own message. The stop wait polled liveness by pid alone, so a pid reused mid-wait would have it wait on an unrelated process and then report the watch stopped. Compare the process start time recorded for the owner, which also returns sooner. Registration now omits `branch` for a primary index instead of passing it as undefined, so that call keeps the shape it had before this branch. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): ship auto-sync as the remote daemon, reserve gitnexus watch. Keep analyze --watch for local incremental re-index and stop the top-level watch verb from starting a clone/pull loop. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auto-sync): reject invalid branch refs and verify status identity (#2493) Reject leading slashes and per-component trailing dots in configured branches, and verify the live watch owner before trusting a stored error status. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auto-sync): reject ownerIds that can escape the watch directory (#2493) Stop interpolating a tampered ownerId into the stop-request filename; only basename-safe values are treated as owners. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auto-sync): recognize auto-sync in the watch-process identity check (#2493) Stop/status were still looking for a standalone watch token after the command rename, so a live gitnexus auto-sync start process would be refused as unrelated. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(auto-sync): reject boolean max_concurrency instead of coercing it to 1 (#2493) Number(true) is 1, so a YAML boolean would have passed the integer check and silently meant one worker. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auto-sync): swallow status errors in the watch finally path (#2493) An uncaught updateStatus rejection in finally became an unhandled rejection. Skip the clone-root symlink test on Windows, where directory symlinks need privileges. Align the group-lock comment with fail-closed registry timeouts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auto-sync): catch cancelling status-write failures (#2493) Fire-and-forget updateStatus('cancelling') could become an unhandled rejection, the same class as the finally-path status write. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auto-sync): ignore queued interval ticks after stop (#2493) clearInterval does not cancel a timer callback already queued. Guard runSafely on stopping so shutdown cannot start a new un-cancellable run. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(auto-sync): report stored watch status timestamps (#2493) status should show when the watch last entered a state, not when the CLI queried it. The failure-count test still expects 1 after a new commit resets the streak; rename it so that reset is explicit. Co-authored-by: Cursor <cursoragent@cursor.com> * style(auto-sync): apply prettier to starter status logger (#2493) Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: weiyf <weiyf3634@163.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1949 lines
71 KiB
TypeScript
1949 lines
71 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';
|
|
|
|
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',
|
|
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),
|
|
...deps,
|
|
};
|
|
}
|
|
|
|
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 },
|
|
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('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 },
|
|
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 },
|
|
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('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('skips overlapping scheduled runs while a previous run is active', 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?.();
|
|
|
|
expect(runOnce).toHaveBeenCalledTimes(1);
|
|
expect(stderr.write).toHaveBeenCalledWith(
|
|
'[auto-sync] Previous run is still active; skipping overlapping run.\n',
|
|
);
|
|
|
|
releaseRuns.shift()?.();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
scheduled?.();
|
|
|
|
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('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);
|
|
|
|
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 });
|
|
}
|
|
});
|
|
});
|