GitNexus/gitnexus/test/unit/sibling-clone-drift.test.ts
Copilot 962f22482b
Some checks are pending
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / scope-parity (push) Waiting to run
CI / Save PR Metadata (push) Blocked by required conditions
CI / CI Gate (push) Blocked by required conditions
Release Candidate / Check if release candidate should run (push) Waiting to run
Release Candidate / ci (push) Blocked by required conditions
Release Candidate / Publish release candidate to npm (push) Blocked by required conditions
Release Candidate / Build & Push RC Docker images (push) Blocked by required conditions
feat(cli): Fingerprint indexed repos by remote URL to detect sibling-clone graph drift (#982)
* Initial plan

* feat: detect sibling-clone graph drift via remote URL fingerprint

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: address review feedback — fake commit, same-commit case, regex docs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e5decb67-7fec-40e7-b2a1-b5e94a0d393f

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(mcp): address review feedback — CI green, perf, dead branch, one-shot test

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc2259f7-94e4-4243-aaa9-e03b7c632d32

* Merge branch 'main' into copilot/fix-single-path-indexing-issue

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5840b3dd-e879-4854-a067-d1622bec2634

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* Merge branch 'main' into copilot/fix-single-path-indexing-issue

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9025262f-4dd4-4774-8f32-e14434100004

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: prettier format run-analyze.ts after merge with main

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a7be18dd-102f-4a7b-ac56-53fbd414fe3b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: realpath both sides of cwdGitRoot assertion for Windows 8.3 short-name compat

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b2a1c6a3-e454-4b87-b0e4-69d7c0d9a51b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(test): use path-agnostic assertion for cwdGitRoot on Windows (#1015)

git rev-parse --show-toplevel returns long path names on Windows
while os.tmpdir() returns 8.3 short names. fs.realpathSync does not
expand short names, so exact path comparison always fails on Windows
CI runners. Replace with behavioral assertions instead.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <copilot-swe-agent[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: evolution <wjc163@sina.cn>
2026-04-21 21:58:54 +01:00

308 lines
12 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 } from 'vitest';
import path from 'path';
import { execSync } from 'child_process';
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('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();
}
});
});