mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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>
403 lines
15 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
});
|