mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
* fix(lbug): round GITNEXUS_LBUG_MAX_DB_SIZE up to a power of two The native buffer manager rejects any max DB size that is not a power of two, but the env parser accepted any positive integer, so a value such as 300000000000 failed at open and was then reported with a misleading "set a larger limit" hint. Round a positive safe integer up to the next power of two and warn once naming the raw and effective values. Values that are not safe integers now fall back to the 16 GiB default. Refs #3529 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat(lbug): add remedies for the address-space ceiling and past-EOF read Add two pure classifiers beside bufferPoolExhaustionRemedy. They parse only what the engine message states, label the byte count as the limit the engine reported (not the configured value), and name GITNEXUS_ATOMIC_INCREMENTAL=0 only for runs that knob changes. Neither asserts why the failure occurred. Refs #3529, #3530 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * feat(lbug): add size-evidence formatting for failure diagnostics formatFileEvidence is a pure formatter over stat-shaped input (apparent size, allocated blocks, raw statfs f_type), so sparse and regular cases are testable without a real sparse file. describeFileEvidence wraps it, never throws, and reports a missing file as unavailable. Refs #3530 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(analyze): honor GITNEXUS_ATOMIC_INCREMENTAL=0 and classify staged failures GITNEXUS_ATOMIC_INCREMENTAL was only read as === '1', so the documented =0 mitigation did nothing for watch and auto-sync, which pass atomicIncremental as an option. An explicit =0 now beats the caller's request. Full rebuilds and the escalation to staging still stage and are unaffected. In the analyze catch block, snapshot the live and staging files before the checkpoint driver stops and the database closes (closing can checkpoint the staging file), classify the error once, then log the remedy and the held evidence for a size-ceiling or past-EOF failure. The off-switch is named only when the failed run was an atomic-incremental staged write. A max-size message the classifiers do not recognise keeps the generic hint, and the original error is rethrown unchanged. The atomic-incremental staging log line now records the staged file size. Refs #3529, #3530 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: document max DB size rounding and GITNEXUS_ATOMIC_INCREMENTAL Note that GITNEXUS_LBUG_MAX_DB_SIZE must be a power of two and is rounded up, add GITNEXUS_ATOMIC_INCREMENTAL (1 and 0, and which builds 0 does not affect) to both README env tables, and add a Troubleshooting entry for the two intermittent embedding write-back failures and the evidence lines GitNexus now logs for them. Refs #3529, #3530 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(review): apply review findings Review fixes: - Label the GITNEXUS_LBUG_BUFFER_POOL_SIZE=0 sentinel in the failure evidence line instead of printing "buffer pool 0 B". - Replace FailureRemedyContext {staged, offSwitchApplies} with a single StagedBuildMode ('in-place' | 'staged-rebuild' | 'staged-incremental'), removing the { staged: false, offSwitchApplies: true } illegal state. - Re-attach the LBUG_MAX_DB_SIZE doc comment to the exported constant. - Cover the in-place classified failure through runFullAnalysis, and the buffer-pool sentinel label, with new tests. Also carries the simplification pass: failure evidence is collected only for a classified failure, the staged-build flag is reused, the env var is read once, and the evidence tests share one temp-dir helper. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(lbug): bound failure-evidence stats and record checkpoint activity Review findings #1 and #3 on #3570. describeFileEvidence raced nothing: a stat on a stalled 9p/NFS/virtiofs mount hung the analyze failure handler before the driver stopped, the database closed and the original error was rethrown. Each stat/statfs now has a 2000 ms deadline and fails open; a missing file reads "absent" instead of "unavailable", a stalled one reads "unavailable (timed out ...)". collectFailureEvidence (new) is the single evidence collector: it reads the checkpoint activity snapshot and dispatches every stat in one Promise.all before its first await, covering the live index, the staging copy and its .wal/.shadow/.wal.checkpoint sidecars. That is what lets a #3530 report tell a short main file from pages pending in the sidecars or from a CHECKPOINT GitNexus itself ran just before the failing DELETE. runCheckpointWithRetry now records completed count, last completion time and in-flight count. Both the periodic driver tick and checkpointOnce (the staged window-start CHECKPOINT) go through it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(lbug): state the off-switch cost, pin the rounded max size, isolate ambient env Review findings #5 (note), #6 (wiring) and #11 on #3570. offSwitchNote now says that GITNEXUS_ATOMIC_INCREMENTAL=0 can leave the live index partly written after a failed in-place write, and that the next run then rebuilds it in full. A new test stubs GITNEXUS_LBUG_MAX_DB_SIZE=300000000000, reloads lbug-config and asserts the native Database constructor receives 2 ** 39 and that exactly one rounding warning is logged. The existing tests all called the parsers directly and matched the constructor argument with expect.any(Number), so reverting the wiring left the suite green. vitest.config.ts sets GITNEXUS_ATOMIC_INCREMENTAL to the empty string (neither '0' nor '1', i.e. unset) like GITNEXUS_MEMORY, so an exported =0 no longer switches off every staging test. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(analyze): carry the failure diagnosis in the error message and label escalated runs Review findings #2, #4, #5, #7, #8 and the run-analyze half of #6 on #3570. #4: the remedy and `Failure evidence` lines were only sent to onLog, which `analyze --watch` wires only with GITNEXUS_VERBOSE=1 and the auto-sync launcher drops - the only two callers that request atomic-incremental staging. They are now appended to the rethrown error's message in place (identity and prototype preserved, native text first so classifiers anchored on it still match), which every caller prints. A thrown value that is not an Error falls back to a log. Auto-sync truncated that message to 240 characters before logging it; the log now allows 2000 while the persisted summary stays short. #8: remedy delivery is best effort, so a throwing onLog sink or an unwritable message can no longer replace the native error. #2: escalatedFullWrite is declared above the try, and an escalated atomic-incremental run is classified as a rebuild. Its remedy no longer points at GITNEXUS_ATOMIC_INCREMENTAL=0, which does not stop the escalation from staging. #5: when GITNEXUS_ATOMIC_INCREMENTAL=0 overrides a caller that requested staging, the run logs one line saying so and what a failed write costs. #7: the stage-time size line uses the same "staging copy" label as the failure evidence. #6: tests pin the evidence-before-driver-stop-before-close order, the exact configured max DB size in the evidence, and the appended text against the substring classifiers in the CLI and auto-sync. The READMEs describe the new evidence lines, ask reporters to include the stage-time line, and name GITNEXUS_WAL_MANUAL_CHECKPOINT=0 as the bisect knob for the checkpoint hypothesis. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Address PR review feedback (#3570) - wal-checkpoint-driver: count a checkpoint as completed only when tryFlushWAL actually ran one. It returns false when no connection is open, and the failure evidence renders the count as "N completed". - auto-sync runner: an oversized native error now logs its head and tail (1000 characters each) so the appended failure diagnosis, which is always the tail, survives. The persisted 240-character summary is unchanged. - lbug-config: the max-size remedy said GITNEXUS_LBUG_MAX_DB_SIZE "must be a power of 2", but non-powers are rounded up. It now says so, as do the matching lines in gitnexus/README.md. - failure-evidence and file-evidence tests use the default registered temp-dir prefix, so the stale-sidecar sweep recognises their fixtures. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Address PR review feedback (#3570) - run-analyze: stringify the thrown value through a helper that cannot throw, so an exotic throw (null-prototype object, throwing toString) cannot abort the failure handler before it stops the driver, closes the database and reclaims the staging copy. - run-analyze: a classified non-Error throw is wrapped in an Error (original kept as `cause`) so the diagnosis travels in the message like every other case, instead of in a log that the auto-sync launcher drops. This removes the separate log fallback. - auto-sync runner: the oversized-error log keeps head and tail within the 2000-character cap; the separator no longer pushes it to 2003. - lbug-config: the max-size rounding warning prints the operator's value as supplied (300000000000.9), not the integer it floored to. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Address PR review feedback (#3570) - lbug-config: the max-size remedy's rounding example was itself a power of two. It now shows a value being rounded: 300000000000 becomes 549755813888. - auto-sync-runner test: the comment said the whole log line is capped at 2000 characters; the assertion covers the error payload after the prefix. - extension-loader test: pin that the installer child is handed the rounded max DB size for a non-power-of-two override. The installer prefers that argument over the env var and has its own non-rounding parser, so this is what keeps a valid override working there. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Address PR review feedback (#3570) - Keep the whole failure diagnosis in auto-sync's capped log line: the tail now reserves the remedy and evidence lines (with a 240-char floor for the native head) instead of an even head/tail split. - Count only this run's checkpoints in the failure evidence: snapshot the process-global activity when the run begins and report the difference, so an earlier `analyze --watch` refresh no longer leaks in. - Diagnose classified failures thrown while opening the staging copy, via helpers shared with the write-path catch. - Halt checkpoint ticks before measuring failure evidence: start `walCheckpointDriver.stop()` (which clears its timer synchronously) before the stats, then await the drain before close. Note: pre-existing timeouts in run-analyze-fts-repair ("fails repair mode when no base meta exists", "abandons a pending set that fails EMBEDDING_RESUME_MAX_ATTEMPTS times in a row") reproduce on the unchanged PR head on this host and are not addressed by this commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2600 lines
95 KiB
TypeScript
2600 lines
95 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<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'qts_account'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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('passes embeddings from .gitnexusrc into runAnalysis', async () => {
|
|
const targetDir = '/tmp/repos/gitee.com/qts_server/qts_account';
|
|
await fs.mkdir(targetDir, { recursive: true });
|
|
await fs.writeFile(path.join(targetDir, '.gitnexusrc'), '{"embeddings": true}');
|
|
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
|
cloneOrPull: vi.fn(async () => targetDir),
|
|
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'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(async () => ({})),
|
|
saveState: vi.fn(async () => {}),
|
|
writeCommitInfo: vi.fn(async () => {}),
|
|
addRepoToGroup: vi.fn(async () => false),
|
|
syncGroupByName: vi.fn(async () => {}),
|
|
getAvailableMemoryGB: vi.fn(() => 8),
|
|
});
|
|
|
|
try {
|
|
await runAutoSyncOnce(config, {
|
|
deps,
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
expect(deps.runAnalysis).toHaveBeenCalledWith(
|
|
targetDir,
|
|
expect.objectContaining({ embeddings: true }),
|
|
1_800_000,
|
|
undefined,
|
|
undefined,
|
|
1,
|
|
);
|
|
} finally {
|
|
await fs.rm(path.join(targetDir, '.gitnexusrc'), { force: true });
|
|
}
|
|
});
|
|
|
|
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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => '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<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => '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<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => '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<AutoSyncRunDeps['loadState']>(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<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'qts_account'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-3'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 2 } }) as any),
|
|
registerRepo: vi.fn(async () => 'qts_account'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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',
|
|
overwriteLocalChanges: false,
|
|
groupName: 'back_end',
|
|
branches: ['main'],
|
|
remoteUrls: ['git@github.com:team-a/service.git'],
|
|
},
|
|
{
|
|
localPath: '/tmp/repos-b',
|
|
overwriteLocalChanges: false,
|
|
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(async () => 'main'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'service'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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',
|
|
);
|
|
expect(getAutoSyncRepoIdentity('https://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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-1'),
|
|
runAnalysis: vi.fn(),
|
|
registerRepo: vi.fn(),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-1'),
|
|
runAnalysis: vi.fn(),
|
|
registerRepo: vi.fn(),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'qts_account'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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('clones an allowlisted self-hosted remote under its host path', async () => {
|
|
const selfHosted: AutoSyncConfig = {
|
|
...config,
|
|
allowedHosts: ['gitlab.mycompany.com'],
|
|
projects: [
|
|
{
|
|
localPath: '/tmp/repos',
|
|
overwriteLocalChanges: false,
|
|
branches: ['main'],
|
|
remoteUrls: ['git@gitlab.mycompany.com:group/repo.git'],
|
|
},
|
|
],
|
|
};
|
|
expect(
|
|
getConfiguredRepoPath(
|
|
selfHosted.projects[0],
|
|
'repo',
|
|
'git@gitlab.mycompany.com:group/repo.git',
|
|
selfHosted.allowedHosts,
|
|
),
|
|
).toBe('/tmp/repos/gitlab.mycompany.com/group/repo');
|
|
|
|
const cloneOrPull = vi.fn(async () => '/tmp/repos/gitlab.mycompany.com/group/repo');
|
|
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
|
cloneOrPull,
|
|
getCurrentBranch: vi.fn(async () => 'main'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'repo'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(async () => ({})),
|
|
saveState: vi.fn(async () => {}),
|
|
writeCommitInfo: vi.fn(async () => {}),
|
|
addRepoToGroup: vi.fn(async () => false),
|
|
syncGroupByName: vi.fn(async () => {}),
|
|
getAvailableMemoryGB: vi.fn(() => 8),
|
|
});
|
|
|
|
await runAutoSyncOnce(selfHosted, {
|
|
deps,
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
});
|
|
|
|
expect(cloneOrPull).toHaveBeenCalledWith(
|
|
'git@gitlab.mycompany.com:group/repo.git',
|
|
'/tmp/repos/gitlab.mycompany.com/group/repo',
|
|
undefined,
|
|
expect.objectContaining({
|
|
allowAutoSyncSsh: true,
|
|
autoSyncAllowedHosts: ['gitlab.mycompany.com'],
|
|
branch: 'main',
|
|
expectedRepoName: 'repo',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('skips a self-hosted remote that is not listed in allowed_hosts', async () => {
|
|
const selfHosted: AutoSyncConfig = {
|
|
...config,
|
|
projects: [
|
|
{
|
|
localPath: '/tmp/repos',
|
|
overwriteLocalChanges: false,
|
|
branches: ['main'],
|
|
remoteUrls: ['git@gitlab.mycompany.com:group/repo.git'],
|
|
},
|
|
],
|
|
};
|
|
const cloneOrPull = vi.fn(async () => '/tmp/repos/should-not-clone');
|
|
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
const result = await runAutoSyncOnce(selfHosted, {
|
|
deps: withCloneRoot({
|
|
cloneOrPull,
|
|
getAvailableMemoryGB: vi.fn(() => 8),
|
|
}),
|
|
logger,
|
|
});
|
|
|
|
expect(result.failed).toBe(1);
|
|
expect(cloneOrPull).not.toHaveBeenCalled();
|
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('allowed_hosts'));
|
|
});
|
|
|
|
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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis,
|
|
registerRepo: vi.fn(async () => 'qts_account'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'develop'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'qts_account'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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<AutoSyncRunDeps['loadState']>(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(async () => 'develop'),
|
|
getCurrentCommit: vi.fn(),
|
|
runAnalysis: vi.fn(),
|
|
registerRepo: vi.fn(),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => undefined),
|
|
getCurrentCommit: vi.fn(),
|
|
runAnalysis: vi.fn(),
|
|
registerRepo: vi.fn(),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => {
|
|
throw new Error('analysis failed');
|
|
}),
|
|
registerRepo: vi.fn(),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => {
|
|
throw new Error('git log failed');
|
|
}),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'repo'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => {
|
|
throw new Error('registry busy');
|
|
}),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'qts_account'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async (repoPath: string) =>
|
|
repoPath.endsWith('/one') ? 'one-commit' : 'two-commit',
|
|
),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'repo'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'main'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async (_path, _meta, options) => options?.name ?? 'repo'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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('clones allowlisted HTTPS remotes at the runner boundary', async () => {
|
|
const httpsConfig: AutoSyncConfig = {
|
|
...config,
|
|
projects: [{ ...config.projects[0], remoteUrls: ['https://github.com/owner/repo.git'] }],
|
|
};
|
|
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
|
cloneOrPull: vi.fn(async () => '/tmp/repos/github.com/owner/repo'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(async () => ({})),
|
|
saveState: vi.fn(async () => {}),
|
|
writeCommitInfo: vi.fn(async () => {}),
|
|
addRepoToGroup: vi.fn(async () => false),
|
|
syncGroupByName: vi.fn(async () => {}),
|
|
getAvailableMemoryGB: vi.fn(() => 8),
|
|
getCurrentBranch: vi.fn(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'abc'),
|
|
runAnalysis: vi.fn(async () => ({ stats: {} })),
|
|
registerRepo: vi.fn(async () => 'repo'),
|
|
});
|
|
|
|
const error = vi.fn();
|
|
const result = await runAutoSyncOnce(httpsConfig, {
|
|
deps,
|
|
logger: { info: vi.fn(), warn: vi.fn(), error },
|
|
});
|
|
|
|
expect(error.mock.calls, JSON.stringify(error.mock.calls)).toEqual([]);
|
|
expect(result.failed).toBe(0);
|
|
expect(deps.cloneOrPull).toHaveBeenCalledWith(
|
|
'https://github.com/owner/repo.git',
|
|
'/tmp/repos/github.com/owner/repo',
|
|
undefined,
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => {
|
|
throw new Error('parser crashed\nwith stack');
|
|
}),
|
|
registerRepo: vi.fn(),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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.each([
|
|
['short', 'Cannot read from file: position: 4096, numBytesRead: 0'],
|
|
['oversized', `native-start ${'n'.repeat(3000)}`],
|
|
])(
|
|
'logs the whole analyze failure diagnosis but persists only a short summary for a %s native error (#3530)',
|
|
async (_label, nativeFailure) => {
|
|
const errorLogger = vi.fn();
|
|
// Realistic size: a remedy line plus seven evidence lines, about 1.3 KB — more
|
|
// than half the log cap, so an even head/tail split would cut the remedy.
|
|
const diagnosis = [
|
|
`start-of-remedy ${'A read reached end-of-file in the staged copy of the index. '.repeat(5)}`,
|
|
...Array.from(
|
|
{ length: 7 },
|
|
(_, i) => `Failure evidence — line ${i}: ${'staging copy apparent 4096 B, '.repeat(4)}`,
|
|
),
|
|
].join('\n');
|
|
const message = `${nativeFailure}\n${diagnosis}`;
|
|
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-1'),
|
|
runAnalysis: vi.fn(async () => {
|
|
throw new Error(message);
|
|
}),
|
|
registerRepo: vi.fn(),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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: errorLogger },
|
|
now: () => new Date('2026-06-30T00:00:00.000Z'),
|
|
});
|
|
|
|
expect(diagnosis.length).toBeGreaterThan(1000);
|
|
expect(errorLogger).toHaveBeenCalledWith(
|
|
expect.stringContaining(diagnosis.replace(/\s+/g, ' ')),
|
|
);
|
|
// The error payload (the part after the log prefix) stays within the 2000-character cap,
|
|
// separator included.
|
|
const logged: string = errorLogger.mock.calls[0][0];
|
|
const prefix =
|
|
'[auto-sync] Analysis failed for /tmp/repos/gitee.com/qts_server/qts_account; consecutive failures 1/3: ';
|
|
expect(logged.startsWith(prefix)).toBe(true);
|
|
expect(logged.length - prefix.length).toBeLessThanOrEqual(2000);
|
|
expect(errorLogger).toHaveBeenCalledWith(expect.stringContaining(nativeFailure.slice(0, 40)));
|
|
expect(deps.saveState).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
|
|
lastAnalyzeError: message.replace(/\s+/g, ' ').slice(0, 240),
|
|
}),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-1'),
|
|
runAnalysis: vi.fn(async () => {
|
|
throw leftover;
|
|
}),
|
|
registerRepo: vi.fn(),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => '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<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => '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<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => {
|
|
throw null;
|
|
}),
|
|
registerRepo: vi.fn(),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'qts_account'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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(async () => 'master'),
|
|
getCurrentCommit: vi.fn(async () => 'commit-2'),
|
|
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
|
registerRepo: vi.fn(async () => 'qts_account'),
|
|
loadState: vi.fn<AutoSyncRunDeps['loadState']>(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 });
|
|
}
|
|
});
|
|
});
|