GitNexus/gitnexus/test/unit/sibling-clone-drift.test.ts
SyedaAnshrahGillani dfe271b2a9
fix(core): ensure path prefix and traversal guards support root directories (#2559)
* fix(core): ensure path prefix and traversal guards support root directories

* chore(autofix): apply prettier + eslint fixes via /autofix command

* test(core): add test coverage for root-level and Windows drive-root paths

* fix(core): apply separator-aware prefix matching in augmentation engine

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Syeda Anshrah Gillani <gillani@cloudment.io>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-20 08:12:15 +01:00

403 lines
15 KiB
TypeScript

/**
* 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<typeof import('child_process')>('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<typeof import('node:child_process')>('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<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
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<ReturnType<typeof createTempDir>>;
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<ReturnType<typeof createTempDir>>;
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);
});
}
});