mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
36c54edc46
commit
e7bf2de3a4
16 changed files with 731 additions and 12 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
271
gitnexus/test/unit/sibling-clone-drift.test.ts
Normal file
271
gitnexus/test/unit/sibling-clone-drift.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue