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>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-19 06:59:49 +00:00 • committed by GitHub
parent 36c54edc46
commit e7bf2de3a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 731 additions and 12 deletions

View file

@ -17,7 +17,7 @@ import {
addToGitignore,
registerRepo,
} from '../storage/repo-manager.js';
import { getGitRoot, isGitRepo } from '../storage/git.js';
import { getGitRoot, getRemoteUrl, isGitRepo } from '../storage/git.js';
export interface IndexOptions {
force?: boolean;
@ -107,6 +107,13 @@ export const indexCommand = async (inputPathParts?: string[], options?: IndexOpt
}
// ── Register in global registry ───────────────────────────────────
// Refresh the on-disk meta with a freshly captured `remoteUrl` if
// it's missing, so an `index` of an older `.gitnexus/` still gets
// sibling-clone fingerprinting on subsequent use without forcing a
// full re-analyze.
if (!meta.remoteUrl && isGitRepo(repoPath)) {
meta.remoteUrl = getRemoteUrl(repoPath);
}
await registerRepo(repoPath, meta);
await addToGitignore(repoPath);

View file

@ -4,6 +4,13 @@
*/
import { execFileSync } from 'node:child_process';
import path from 'path';
import {
readRegistry,
type RegistryEntry,
type CwdMatch,
} from '../storage/repo-manager.js';
import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
export interface StalenessInfo {
isStale: boolean;
@ -37,3 +44,109 @@ export function checkStaleness(repoPath: string, lastCommit: string): StalenessI
return { isStale: false, commitsBehind: 0 };
}
}
/**
* Compare a sibling-clone HEAD against an indexed `lastCommit`. Returns
* `undefined` when the indexed commit is not reachable from the sibling
* (e.g. divergent branches, shallow clone, missing ref). The caller
* should treat `undefined` as "drift unknown" rather than "no drift".
*/
function commitsAheadOfIndexed(siblingPath: string, indexedCommit: string): number | undefined {
if (!indexedCommit) return undefined;
try {
const result = execFileSync(
'git',
['rev-list', '--count', `${indexedCommit}..HEAD`],
{
cwd: siblingPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
},
).trim();
return parseInt(result, 10) || 0;
} catch {
return undefined;
}
}
/**
* Resolve a working directory against the global registry. Returns:
* - `match: 'path'` when `cwd` is inside a registered entry's path
* - `match: 'sibling-by-remote'` when `cwd` lives in a different on-disk clone
* of the same repo (same `remoteUrl`)
* - `match: 'none'` when neither match applies
*
* For sibling-by-remote matches, the caller's HEAD and the drift vs the
* indexed `lastCommit` are also returned so the MCP layer can warn
* before serving silently-stale answers (issue: silent graph drift
* across sibling clones).
*
* `path` matches deliberately use the longest-prefix rule so a cwd
* inside a sub-path of a registered repo still matches that repo, not
* a coincidentally-aliased shorter entry.
*/
export async function checkCwdMatch(cwd: string): Promise<CwdMatch> {
const entries = await readRegistry();
if (entries.length === 0) return { match: 'none' };
const isWin = process.platform === 'win32';
const norm = (p: string) => (isWin ? path.resolve(p).toLowerCase() : path.resolve(p));
const sep = path.sep;
const cwdResolved = path.resolve(cwd);
const cwdNorm = norm(cwdResolved);
// 1) Path-based match (longest prefix wins, boundary-safe).
let bestPath: RegistryEntry | undefined;
let bestLen = -1;
for (const e of entries) {
const p = norm(e.path);
if (cwdNorm === p || cwdNorm.startsWith(p + sep)) {
if (p.length > bestLen) {
bestPath = e;
bestLen = p.length;
}
}
}
if (bestPath) return { match: 'path', entry: bestPath };
// 2) Sibling-by-remote: locate the cwd's git root, get its remote
// URL, and look for any registered entry with the same fingerprint.
const cwdGitRoot = getGitRoot(cwdResolved);
if (!cwdGitRoot) return { match: 'none' };
const cwdRemote = getRemoteUrl(cwdGitRoot);
if (!cwdRemote) return { match: 'none' };
const sibling = entries.find((e) => e.remoteUrl === cwdRemote && norm(e.path) !== norm(cwdGitRoot));
if (!sibling) return { match: 'none' };
const cwdHead = getCurrentCommit(cwdGitRoot) || undefined;
const drift = commitsAheadOfIndexed(cwdGitRoot, sibling.lastCommit);
let hint: string;
if (cwdHead && cwdHead === sibling.lastCommit) {
hint =
`ℹ️ Indexed clone "${sibling.name}" lives at ${sibling.path}; ` +
`your cwd (${cwdGitRoot}) is a different clone of the same repo at the same commit — results apply.`;
} else if (drift && drift > 0) {
hint =
`⚠️ Index for "${sibling.name}" was built at ${sibling.path}; ` +
`your cwd (${cwdGitRoot}) is a sibling clone that is ${drift} commit${drift > 1 ? 's' : ''} ` +
`ahead of the indexed commit. Results may be stale or incorrect — re-run \`gitnexus analyze\` ` +
`to refresh the index.`;
} else {
hint =
`⚠️ Index for "${sibling.name}" was built at ${sibling.path}; ` +
`your cwd (${cwdGitRoot}) is a sibling clone whose HEAD differs from the indexed commit. ` +
`Results may be stale or incorrect — re-run \`gitnexus analyze\` to refresh the index.`;
}
return {
match: 'sibling-by-remote',
entry: sibling,
cwdGitRoot,
cwdHead,
drift,
hint,
};
}

View file

@ -30,7 +30,7 @@ import {
registerRepo,
cleanupOldKuzuFiles,
} from '../storage/repo-manager.js';
import { getCurrentCommit, hasGitDir } from '../storage/git.js';
import { getCurrentCommit, getRemoteUrl, hasGitDir } from '../storage/git.js';
import type { CachedEmbedding } from './embeddings/types.js';
import { generateAIContextFiles } from '../cli/ai-context.js';
import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
@ -324,6 +324,13 @@ export async function runFullAnalysis(
repoPath,
lastCommit: currentCommit,
indexedAt: new Date().toISOString(),
// Captured here (not at registration) so it travels with the
// on-disk meta.json — sibling-clone fingerprinting works for
// out-of-tree consumers (group-status, future tooling) without
// a second git shellout. `undefined` when the repo has no
// origin remote, which is fine: paths-only repos behave as
// before.
remoteUrl: hasGitDir(repoPath) ? getRemoteUrl(repoPath) : undefined,
stats: {
files: pipelineResult.totalFileCount,
nodes: stats.nodes,

View file

@ -25,12 +25,14 @@ import { parseDiffHunks, type FileDiff } from '../../storage/git.js';
import {
listRegisteredRepos,
cleanupOldKuzuFiles,
findSiblingClones,
type RegistryEntry,
} from '../../storage/repo-manager.js';
import { GroupService, type GroupToolPort } from '../../core/group/service.js';
import { collectBestChunks } from '../../core/embeddings/types.js';
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
import { PhaseTimer } from '../../core/search/phase-timer.js';
import { checkStaleness, checkCwdMatch } from '../../core/git-staleness.js';
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@ -197,6 +199,7 @@ interface RepoHandle {
lbugPath: string;
indexedAt: string;
lastCommit: string;
remoteUrl?: string;
stats?: RegistryEntry['stats'];
}
@ -207,6 +210,13 @@ export class LocalBackend {
private reinitPromises: Map<string, Promise<void>> = new Map();
private lastStalenessCheck: Map<string, number> = new Map();
private groupToolSvc: GroupService | null = null;
/**
* One-shot stderr warnings for sibling-clone drift, keyed by
* `${repoId}|${cwdGitRoot}`. Without this guard every tool call
* from inside a sibling clone would print the same warning,
* making MCP stderr unreadable.
*/
private warnedSiblingDrift: Set<string> = new Set();
/**
* Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers.
@ -273,6 +283,7 @@ export class LocalBackend {
lbugPath,
indexedAt: entry.indexedAt,
lastCommit: entry.lastCommit,
remoteUrl: entry.remoteUrl,
stats: entry.stats,
};
@ -331,12 +342,26 @@ export class LocalBackend {
*/
async resolveRepo(repoParam?: string): Promise<RepoHandle> {
const result = this.resolveRepoFromCache(repoParam);
if (result) return result;
if (result) {
// Issue: silent graph drift across sibling clones.
// If the caller's cwd lives in a *different* on-disk clone of
// the same repo (matched by `remoteUrl`), warn once per
// (repo, cwd) pair on stderr. We do not fail or refuse to
// serve — the index is still the best answer we have — but
// the operator/agent has to know the answer may be stale.
this.maybeWarnSiblingDrift(result).catch(() => {
/* best-effort; never throw from resolveRepo */
});
return result;
}
// Miss — refresh registry and try once more
await this.refreshRepos();
const retried = this.resolveRepoFromCache(repoParam);
if (retried) return retried;
if (retried) {
this.maybeWarnSiblingDrift(retried).catch(() => {});
return retried;
}
// Still no match — throw with helpful message
if (this.repos.size === 0) {
@ -474,18 +499,94 @@ export class LocalBackend {
* List all registered repos with their metadata.
* Re-reads the global registry so newly indexed repos are discovered
* without restarting the MCP server.
*
* Each entry includes:
* - `staleness`: if the indexed clone's own HEAD has moved past
* the recorded `lastCommit` (option D in the issue's fix list).
* - `siblings`: other registered entries sharing the same
* `remoteUrl` (option B's payoff: callers can see at a glance
* that another clone of the same logical repo is registered).
* - `remoteUrl`: the canonical origin URL recorded at index time.
*/
async listRepos(): Promise<
Array<{ name: string; path: string; indexedAt: string; lastCommit: string; stats?: any }>
Array<{
name: string;
path: string;
indexedAt: string;
lastCommit: string;
remoteUrl?: string;
stats?: any;
staleness?: { commitsBehind: number; hint?: string };
siblings?: Array<{ name: string; path: string; lastCommit: string }>;
}>
> {
await this.refreshRepos();
return [...this.repos.values()].map((h) => ({
name: h.name,
path: h.repoPath,
indexedAt: h.indexedAt,
lastCommit: h.lastCommit,
stats: h.stats,
}));
const handles = [...this.repos.values()];
return Promise.all(
handles.map(async (h) => {
const stale = checkStaleness(h.repoPath, h.lastCommit);
const siblings = await findSiblingClones(h.remoteUrl, h.repoPath);
return {
name: h.name,
path: h.repoPath,
indexedAt: h.indexedAt,
lastCommit: h.lastCommit,
remoteUrl: h.remoteUrl,
stats: h.stats,
staleness: stale.isStale
? { commitsBehind: stale.commitsBehind, hint: stale.hint }
: undefined,
siblings:
siblings.length > 0
? siblings.map((s) => ({
name: s.name,
path: s.path,
lastCommit: s.lastCommit,
}))
: undefined,
};
}),
);
}
/**
* Best-effort sibling-clone drift warning.
*
* When the resolved index has a `remoteUrl` recorded and the caller's
* `process.cwd()` is inside a *different* clone of the same repo, emit
* one stderr line per (repo, cwd) pair so the operator knows the
* graph may be stale relative to what's actually on disk under their
* cwd. Silent on path matches and on repos without a remote URL.
*
* Pure side-effect (stderr); never affects the returned handle.
*/
private async maybeWarnSiblingDrift(handle: RepoHandle): Promise<void> {
if (!handle.remoteUrl) return;
let cwd: string;
try {
cwd = process.cwd();
} catch {
return;
}
const match = await checkCwdMatch(cwd);
if (
match.match !== 'sibling-by-remote' ||
!match.entry ||
!match.cwdGitRoot ||
match.entry.path !== handle.repoPath ||
!match.hint
) {
return;
}
// Only warn when the sibling has actually drifted (or drift is
// unknown). If both clones are on the indexed commit, skip the
// noise — the caller is fine.
if (match.cwdHead && match.cwdHead === handle.lastCommit) return;
const key = `${handle.id}|${match.cwdGitRoot}`;
if (this.warnedSiblingDrift.has(key)) return;
this.warnedSiblingDrift.add(key);
console.error(`GitNexus: ${match.hint}`);
}
// ─── Tool Dispatch ───────────────────────────────────────────────

View file

@ -21,6 +21,60 @@ export const getCurrentCommit = (repoPath: string): string => {
}
};
/**
* Get a stable canonical identifier for the repo's `origin` remote, if any.
*
* Used to fingerprint two on-disk clones as the same logical repository
* (issue #XXX — silent graph drift across sibling clones). `path` alone
* is unreliable: worktrees, "clean clone for indexing" hygiene, and
* multi-agent workspaces routinely have the same repo at multiple
* absolute paths. The remote URL is the only on-disk signal that
* survives those conventions.
*
* Normalisation strategy:
* - Strip a trailing `.git` so `https://x/y` and `https://x/y.git` collapse.
* - Strip a trailing `/` for the same reason.
* - `git@github.com:foo/bar` and `https://github.com/foo/bar` are
* intentionally NOT collapsed — they are different remotes from
* git's perspective and we don't want to assert equivalence.
* - Lower-case the host portion so `GitHub.com` and `github.com`
* don't desync; preserves case in path because some hosts
* (Bitbucket Server) treat repo paths case-sensitively.
*
* Returns `undefined` when there is no origin remote, the directory
* isn't a git repo, or git itself isn't available.
*/
export const getRemoteUrl = (repoPath: string): string | undefined => {
let raw: string;
try {
raw = execSync('git config --get remote.origin.url', {
cwd: repoPath,
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
} catch {
return undefined;
}
if (!raw) return undefined;
let normalised = raw.replace(/\/$/, '').replace(/\.git$/, '');
// Lower-case the host segment of `scheme://[user@]host[:port]/...`
// and the host segment of `git@host:owner/repo` SCP form.
const sshMatch = normalised.match(/^(git@|[a-zA-Z0-9_-]+@)([^:/]+)(:.+)$/);
if (sshMatch) {
normalised = `${sshMatch[1]}${sshMatch[2].toLowerCase()}${sshMatch[3]}`;
} else {
const urlMatch = normalised.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/]+)(\/.*)?$/);
if (urlMatch) {
normalised = `${urlMatch[1]}${urlMatch[2].toLowerCase()}${urlMatch[3] ?? ''}`;
}
}
return normalised;
};
/**
* Find the git repository root from any path inside the repo
*/

View file

@ -14,6 +14,14 @@ export interface RepoMeta {
repoPath: string;
lastCommit: string;
indexedAt: string;
/**
* Canonical `origin` remote URL captured at index time. Used to
* fingerprint the same logical repo across multiple on-disk clones
* (worktrees, agent workspaces, "clean clone for indexing"). When
* absent (no remote configured, git unavailable, etc.) the repo is
* treated as path-only and sibling-clone detection is skipped.
*/
remoteUrl?: string;
stats?: {
files?: number;
nodes?: number;
@ -41,6 +49,8 @@ export interface RegistryEntry {
storagePath: string;
indexedAt: string;
lastCommit: string;
/** See {@link RepoMeta.remoteUrl}. Mirrored from meta at register time. */
remoteUrl?: string;
stats?: RepoMeta['stats'];
}
@ -372,6 +382,7 @@ export const registerRepo = async (
storagePath,
indexedAt: meta.indexedAt,
lastCommit: meta.lastCommit,
remoteUrl: meta.remoteUrl,
stats: meta.stats,
};
@ -474,3 +485,69 @@ export const saveCLIConfig = async (config: CLIConfig): Promise<void> => {
}
}
};
// ─── Sibling-clone detection ─────────────────────────────────────────────
//
// A "sibling clone" is a different on-disk path that points at the same
// logical repository (same `origin` remote URL) as a registered index.
// This shows up in three operationally important shapes (see issue):
//
// 1. The same repo is checked out under multiple paths (worktrees,
// multi-agent workspaces). Only one is indexed; the others silently
// diverge from the graph.
// 2. The indexed clone is itself behind its own HEAD (the existing
// `checkStaleness` already handles this case).
// 3. A query is issued from a `cwd` that lives inside a sibling clone
// whose HEAD has drifted from the indexed `lastCommit`.
//
// Detection is intentionally remote-URL-based and does NOT walk the
// filesystem hunting for unregistered clones — only registered entries
// are considered. The `cwd`-driven branch ({@link checkSiblingDrift})
// also accepts an unregistered cwd, because the live caller's working
// directory is the one place we can cheaply learn about an
// unregistered clone.
/**
* Find other registered entries whose `remoteUrl` matches the given
* one, excluding `selfPath` (case-insensitive on Windows). Entries
* without a `remoteUrl` are ignored — we cannot prove sibling-ness
* without a fingerprint.
*/
export const findSiblingClones = async (
remoteUrl: string | undefined,
selfPath: string,
): Promise<RegistryEntry[]> => {
if (!remoteUrl) return [];
const entries = await readRegistry();
const isWin = process.platform === 'win32';
const norm = (p: string) => (isWin ? path.resolve(p).toLowerCase() : path.resolve(p));
const self = norm(selfPath);
return entries.filter((e) => e.remoteUrl === remoteUrl && norm(e.path) !== self);
};
/**
* Description of how a working directory relates to a registered index.
*
* `match` semantics:
* - `path` — `cwd` is inside the registered entry's path.
* - `sibling-by-remote` — `cwd` is in a different on-disk clone of the
* same repo (same `remoteUrl`).
* - `none` — no relationship found.
*/
export interface CwdMatch {
match: 'path' | 'sibling-by-remote' | 'none';
entry?: RegistryEntry;
/** The git toplevel of `cwd`, when `cwd` is inside a git work tree. */
cwdGitRoot?: string;
/** HEAD of the cwd's clone, when resolvable. */
cwdHead?: string;
/**
* Number of commits the registered `lastCommit` is behind the
* sibling-clone HEAD, when both refs are known to the cwd's clone.
* `undefined` when the comparison cannot be performed (e.g. the
* indexed commit isn't reachable from cwd).
*/
drift?: number;
/** Human-readable hint, set whenever the situation warrants warning. */
hint?: string;
}

View file

@ -17,6 +17,7 @@ import { API_IMPACT_SEED_DATA, API_IMPACT_FTS_INDEXES } from '../fixtures/api-im
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
withTestLbugDB(

View file

@ -21,6 +21,7 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// ─── Seed builders ───────────────────────────────────────────────────────────

View file

@ -17,6 +17,7 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// Mirrors the exact graph structure from issue #480:

View file

@ -17,6 +17,7 @@ import {
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// ─── Block 2: callTool dispatch tests ────────────────────────────────

View file

@ -16,6 +16,7 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// ─── Seed data ────────────────────────────────────────────────────────────────

View file

@ -30,6 +30,7 @@ import { vi } from 'vitest';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
withTestLbugDB(

View file

@ -37,6 +37,15 @@ vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
// `core/git-staleness` is also imported by `local-backend.ts` (for
// `checkStaleness` and `checkCwdMatch`). Stub it out here so unit
// tests don't shell out to git.
vi.mock('../../src/core/git-staleness.js', () => ({
checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }),
checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }),
}));
// Also mock the search modules to avoid loading onnxruntime

View file

@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest';
import path from 'path';
import os from 'os';
import fs from 'fs';
import { execSync } from 'child_process';
// ─── hasGitDir ────────────────────────────────────────────────────────────
//
@ -111,3 +112,71 @@ describe('getGitRoot', () => {
}
});
});
// ─── getRemoteUrl ─────────────────────────────────────────────────────────
describe('getRemoteUrl', () => {
const setupRepoWithRemote = (remoteUrl: string): string => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-remote-'));
// Use real fs paths and shellouts — the helper itself shells out to
// `git config`, so we need a real git repo for the assertion to be
// meaningful.
execSync('git init -q', { cwd: tmpDir });
execSync(`git remote add origin ${remoteUrl}`, { cwd: tmpDir });
return tmpDir;
};
it('returns undefined for a non-git directory', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
expect(getRemoteUrl(tmpDir)).toBeUndefined();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns undefined for a git repo with no origin remote', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
try {
execSync('git init -q', { cwd: tmpDir });
expect(getRemoteUrl(tmpDir)).toBeUndefined();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('strips trailing .git and lowercases host for HTTPS remotes', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = setupRepoWithRemote('https://GitHub.COM/Foo/Bar.git');
try {
expect(getRemoteUrl(tmpDir)).toBe('https://github.com/Foo/Bar');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('lowercases host for SCP-style SSH remotes and strips .git', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const tmpDir = setupRepoWithRemote('git@GitHub.com:Foo/Bar.git');
try {
expect(getRemoteUrl(tmpDir)).toBe('git@github.com:Foo/Bar');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns the same fingerprint for two clones of the same repo', async () => {
const { getRemoteUrl } = await import('../../src/storage/git.js');
const a = setupRepoWithRemote('https://example.com/foo/bar.git');
const b = setupRepoWithRemote('https://example.com/foo/bar');
try {
expect(getRemoteUrl(a)).toBe(getRemoteUrl(b));
expect(getRemoteUrl(a)).toBeTruthy();
} finally {
fs.rmSync(a, { recursive: true, force: true });
fs.rmSync(b, { recursive: true, force: true });
}
});
});

View file

@ -25,6 +25,11 @@ vi.mock('../../src/storage/repo-manager.js', () => ({
vi.mock('../../src/storage/git.js', () => ({
getGitRoot: mockGetGitRoot,
isGitRepo: mockIsGitRepo,
// `index-repo.ts` calls `getRemoteUrl` to backfill `remoteUrl` on
// older `.gitnexus/meta.json` files. The unit tests don't care
// about the remote URL, so a static `undefined` keeps behaviour
// identical to the pre-feature path.
getRemoteUrl: vi.fn().mockReturnValue(undefined),
}));
describe('indexCommand', () => {

View file

@ -0,0 +1,271 @@
/**
* 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 fs from 'fs/promises';
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();
};
const advanceCommit = (dir: string): string => {
execSync('git commit --allow-empty -q -m advance', { 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 at the same commit (no drift hint)', 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 and
// an identical-message empty commit — that's enough for the
// remote-URL-based fingerprint to match. Commits differ; we use
// the same lastCommit for the registered side anyway because
// the warning logic only cares about the *registered* commit
// vs the cwd HEAD.
initRepoWithCommit(sibling.dbPath, remote);
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));
expect(m.cwdGitRoot).toBe(path.resolve(sibling.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.
const fakeIndexedCommit = '0000000000000000000000000000000000000000';
initRepoWithCommit(sibling.dbPath, remote);
await registerRepo(indexed.dbPath, {
repoPath: indexed.dbPath,
lastCommit: fakeIndexedCommit,
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(fakeIndexedCommit);
expect(m.hint).toMatch(/sibling clone/);
} finally {
await indexed.cleanup();
await sibling.cleanup();
}
});
});