/** * Unit tests: sibling-clone drift detection. * * Issue: a single absolute `repoPath` per registry entry causes silent * graph drift when the same logical repo lives at multiple on-disk * paths (worktrees, multi-agent workspaces, etc.). We persist a * canonical `remoteUrl` at index time and use it to: * - find sibling clones registered under different paths * - detect when the caller's `cwd` is in a sibling clone whose HEAD * has drifted from the indexed `lastCommit` * * These tests cover the persistence + helpers; the LocalBackend * stderr-warning side-effect is exercised end-to-end via the same * `checkCwdMatch` API. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'path'; import { execSync, execFileSync } from 'child_process'; // Wrap child_process exports in spies that pass through to the real // implementation. Test setup (initRepoWithCommit, etc.) keeps working // against real git; individual tests can clear + assert call counts to // prove no subprocess was launched. Both module specifiers are mocked // because git-staleness.ts imports from 'node:child_process' while this // file (and storage/git.ts) import from 'child_process'. vi.mock('child_process', async () => { const actual = await vi.importActual('child_process'); return { ...actual, execSync: vi.fn(actual.execSync), execFileSync: vi.fn(actual.execFileSync) }; }); vi.mock('node:child_process', async () => { const actual = await vi.importActual('node:child_process'); return { ...actual, execSync: vi.fn(actual.execSync), execFileSync: vi.fn(actual.execFileSync) }; }); import { registerRepo, readRegistry, findSiblingClones, type RepoMeta, } from '../../src/storage/repo-manager.js'; import { checkCwdMatch } from '../../src/core/git-staleness.js'; import { createTempDir } from '../helpers/test-db.js'; const initRepoWithCommit = (dir: string, remoteUrl?: string): string => { execSync('git init -q', { cwd: dir }); execSync('git config user.email test@example.com', { cwd: dir }); execSync('git config user.name test', { cwd: dir }); execSync('git commit --allow-empty -q -m initial', { cwd: dir }); if (remoteUrl) execSync(`git remote add origin ${remoteUrl}`, { cwd: dir }); return execSync('git rev-parse HEAD', { cwd: dir }).toString().trim(); }; describe('registry persists remoteUrl', () => { let tmpHome: Awaited>; let tmpRepo: Awaited>; let savedHome: string | undefined; beforeEach(async () => { tmpHome = await createTempDir('gitnexus-sibling-home-'); tmpRepo = await createTempDir('gitnexus-sibling-repo-'); savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; }); afterEach(async () => { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); }); it('round-trips remoteUrl from RepoMeta into the registry', async () => { const meta: RepoMeta = { repoPath: tmpRepo.dbPath, lastCommit: 'abc123', indexedAt: new Date().toISOString(), remoteUrl: 'https://example.com/foo/bar', }; await registerRepo(tmpRepo.dbPath, meta); const entries = await readRegistry(); expect(entries).toHaveLength(1); expect(entries[0].remoteUrl).toBe('https://example.com/foo/bar'); }); it('omits remoteUrl from registry when meta has none (back-compat)', async () => { const meta: RepoMeta = { repoPath: tmpRepo.dbPath, lastCommit: 'abc123', indexedAt: new Date().toISOString(), }; await registerRepo(tmpRepo.dbPath, meta); const entries = await readRegistry(); expect(entries[0].remoteUrl).toBeUndefined(); }); }); describe('findSiblingClones', () => { let tmpHome: Awaited>; let savedHome: string | undefined; beforeEach(async () => { tmpHome = await createTempDir('gitnexus-sibling-find-home-'); savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; }); afterEach(async () => { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); }); it('returns other registered entries with the same remoteUrl', async () => { const a = await createTempDir('clone-a-'); const b = await createTempDir('clone-b-'); const c = await createTempDir('clone-c-'); try { const remote = 'https://example.com/foo/bar'; const baseMeta = { lastCommit: 'x', indexedAt: new Date().toISOString(), }; await registerRepo(a.dbPath, { ...baseMeta, repoPath: a.dbPath, remoteUrl: remote }); await registerRepo(b.dbPath, { ...baseMeta, repoPath: b.dbPath, remoteUrl: remote }); await registerRepo(c.dbPath, { ...baseMeta, repoPath: c.dbPath, remoteUrl: 'https://example.com/other/repo', }); const siblings = await findSiblingClones(remote, a.dbPath); expect(siblings.map((s) => s.path).sort()).toEqual([path.resolve(b.dbPath)]); } finally { await a.cleanup(); await b.cleanup(); await c.cleanup(); } }); it('returns [] when remoteUrl is undefined (no fingerprint to match)', async () => { const a = await createTempDir('clone-a-'); try { await registerRepo(a.dbPath, { repoPath: a.dbPath, lastCommit: 'x', indexedAt: new Date().toISOString(), }); const siblings = await findSiblingClones(undefined, a.dbPath); expect(siblings).toEqual([]); } finally { await a.cleanup(); } }); }); describe('checkCwdMatch', () => { let tmpHome: Awaited>; let savedHome: string | undefined; beforeEach(async () => { tmpHome = await createTempDir('gitnexus-cwd-match-home-'); savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; }); afterEach(async () => { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); }); it('returns match=path when cwd is inside the registered entry', async () => { const repo = await createTempDir('cwd-repo-'); try { const head = initRepoWithCommit(repo.dbPath, 'https://example.com/foo/bar'); await registerRepo(repo.dbPath, { repoPath: repo.dbPath, lastCommit: head, indexedAt: new Date().toISOString(), remoteUrl: 'https://example.com/foo/bar', }); const m = await checkCwdMatch(repo.dbPath); expect(m.match).toBe('path'); expect(m.entry?.path).toBe(path.resolve(repo.dbPath)); } finally { await repo.cleanup(); } }); it('detects sibling-by-remote when sibling HEAD differs from indexed commit', async () => { const indexed = await createTempDir('cwd-indexed-'); const sibling = await createTempDir('cwd-sibling-'); try { const remote = 'https://example.com/foo/bar'; const indexedHead = initRepoWithCommit(indexed.dbPath, remote); // Sibling is a separate `git init` with the same remote URL — // that's enough for the remote-URL-based fingerprint to match. // Use a distinct commit message so the sibling's SHA cannot // coincidentally collide with the indexed one even when both // commits land in the same second. execSync('git init -q', { cwd: sibling.dbPath }); execSync('git config user.email test@example.com', { cwd: sibling.dbPath }); execSync('git config user.name test', { cwd: sibling.dbPath }); execSync('git commit --allow-empty -q -m sibling-distinct', { cwd: sibling.dbPath }); execSync(`git remote add origin ${remote}`, { cwd: sibling.dbPath }); await registerRepo(indexed.dbPath, { repoPath: indexed.dbPath, lastCommit: indexedHead, indexedAt: new Date().toISOString(), remoteUrl: remote, }); const m = await checkCwdMatch(sibling.dbPath); expect(m.match).toBe('sibling-by-remote'); expect(m.entry?.path).toBe(path.resolve(indexed.dbPath)); // Path format differs between git and Node.js on Windows (8.3 short // vs long names from os.tmpdir()). Verify the git root was resolved // and it's not the indexed repo (it's the sibling clone's root). expect(m.cwdGitRoot).toBeTruthy(); expect(m.cwdGitRoot).not.toBe(path.resolve(indexed.dbPath)); expect(m.hint).toBeTruthy(); } finally { await indexed.cleanup(); await sibling.cleanup(); } }); it('returns match=none when cwd is unrelated to any registered repo', async () => { const indexed = await createTempDir('cwd-none-indexed-'); const stranger = await createTempDir('cwd-none-stranger-'); try { const indexedHead = initRepoWithCommit(indexed.dbPath, 'https://example.com/foo/bar'); initRepoWithCommit(stranger.dbPath, 'https://example.com/totally/different'); await registerRepo(indexed.dbPath, { repoPath: indexed.dbPath, lastCommit: indexedHead, indexedAt: new Date().toISOString(), remoteUrl: 'https://example.com/foo/bar', }); const m = await checkCwdMatch(stranger.dbPath); expect(m.match).toBe('none'); } finally { await indexed.cleanup(); await stranger.cleanup(); } }); it('returns match=none for a non-git cwd before resolving sibling remotes', async () => { const indexed = await createTempDir('cwd-home-indexed-'); const nonGitCwd = await createTempDir('cwd-home-non-git-'); try { const indexedHead = initRepoWithCommit(indexed.dbPath, 'https://example.com/foo/bar'); await registerRepo(indexed.dbPath, { repoPath: indexed.dbPath, lastCommit: indexedHead, indexedAt: new Date().toISOString(), remoteUrl: 'https://example.com/foo/bar', }); // Clear after setup so we only count subprocess calls made by // checkCwdMatch itself. The fix in #1138 must guarantee that no // git subprocess is launched when the cwd is outside any .git // ancestor — a return value of 'none' alone does not prove that // (the pre-fix code also returned 'none', just by failing the // spawn). The mocked module exports are vi.fn() wrappers that // pass through to real implementations; clearing tracks only the // calls made by checkCwdMatch below. vi.mocked(execSync).mockClear(); vi.mocked(execFileSync).mockClear(); const nodeCp = await import('node:child_process'); vi.mocked(nodeCp.execSync).mockClear(); vi.mocked(nodeCp.execFileSync).mockClear(); const m = await checkCwdMatch(nonGitCwd.dbPath); expect(m.match).toBe('none'); expect(execSync).not.toHaveBeenCalled(); expect(execFileSync).not.toHaveBeenCalled(); expect(nodeCp.execSync).not.toHaveBeenCalled(); expect(nodeCp.execFileSync).not.toHaveBeenCalled(); } finally { await indexed.cleanup(); await nonGitCwd.cleanup(); } }); it('reports sibling-by-remote with a stale hint when cwd HEAD has advanced', async () => { // Polecat-style scenario from the issue: index at path A, query // from cwd=path B (same repo), get a warning rather than // silently-stale data. We can't easily share commits between two // separate temp `git init` repos, so we instead verify that the // cwd HEAD is captured and the hint mentions either drift or a // HEAD mismatch. const indexed = await createTempDir('cwd-stale-indexed-'); const sibling = await createTempDir('cwd-stale-sibling-'); try { const remote = 'https://example.com/foo/bar'; initRepoWithCommit(indexed.dbPath, remote); // Use a fabricated indexed commit that doesn't exist in the // sibling clone — git rev-list will fail and `drift` is left // undefined. The hint must still flag this as a stale-or-divergent // sibling clone. Named to make test intent obvious; not git's // all-zero "null" OID, which has special semantics in some git // commands. const FAKE_INDEXED_COMMIT = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; initRepoWithCommit(sibling.dbPath, remote); await registerRepo(indexed.dbPath, { repoPath: indexed.dbPath, lastCommit: FAKE_INDEXED_COMMIT, indexedAt: new Date().toISOString(), remoteUrl: remote, }); const m = await checkCwdMatch(sibling.dbPath); expect(m.match).toBe('sibling-by-remote'); expect(m.cwdHead).toBeTruthy(); expect(m.cwdHead).not.toBe(FAKE_INDEXED_COMMIT); expect(m.hint).toMatch(/sibling clone/); } finally { await indexed.cleanup(); await sibling.cleanup(); } }); it('omits hint when sibling cwd HEAD matches the indexed commit (no drift)', async () => { // Same-commit sibling: the relationship is real (and surfaces in // `match: 'sibling-by-remote'`) but there is nothing to warn // about. `LocalBackend.maybeWarnSiblingDrift` short-circuits in // exactly this case, so confirming `hint` is unset here pins the // contract those two pieces of code rely on. const indexed = await createTempDir('cwd-same-indexed-'); const sibling = await createTempDir('cwd-same-sibling-'); try { const remote = 'https://example.com/foo/bar'; initRepoWithCommit(indexed.dbPath, remote); const siblingHead = initRepoWithCommit(sibling.dbPath, remote); // Register the indexed entry with the SIBLING's HEAD as // `lastCommit`. That is the on-disk reality when both clones // happen to be at the same commit hash — e.g. immediately // after both fast-forwarded to the same `main`. await registerRepo(indexed.dbPath, { repoPath: indexed.dbPath, lastCommit: siblingHead, indexedAt: new Date().toISOString(), remoteUrl: remote, }); const m = await checkCwdMatch(sibling.dbPath); expect(m.match).toBe('sibling-by-remote'); expect(m.cwdHead).toBe(siblingHead); expect(m.hint).toBeUndefined(); } finally { await indexed.cleanup(); await sibling.cleanup(); } }); it('handles root-level path roots (e.g. "/") in checkCwdMatch', async () => { const rootPath = path.resolve('/'); await registerRepo(rootPath, { repoPath: rootPath, lastCommit: 'somecommit', indexedAt: new Date().toISOString(), }); // Exact match const m1 = await checkCwdMatch(rootPath); expect(m1.match).toBe('path'); expect(m1.entry?.path).toBe(rootPath); // Nested path match const nested = path.join(rootPath, 'src'); const m2 = await checkCwdMatch(nested); expect(m2.match).toBe('path'); expect(m2.entry?.path).toBe(rootPath); }); if (process.platform === 'win32') { it('handles Windows drive-root (e.g. "C:\\") in checkCwdMatch', async () => { const rootPath = 'C:\\'; await registerRepo(rootPath, { repoPath: rootPath, lastCommit: 'somecommit', indexedAt: new Date().toISOString(), }); // Exact match const m1 = await checkCwdMatch(rootPath); expect(m1.match).toBe('path'); expect(m1.entry?.path).toBe(rootPath); // Nested path match const nested = 'C:\\src'; const m2 = await checkCwdMatch(nested); expect(m2.match).toBe('path'); expect(m2.entry?.path).toBe(rootPath); }); } });