extraction/index.ts bounds every git call it makes; worktree.ts, git-hooks.ts, and the installer's npm install -g did not, so a stuck subprocess blocked the caller indefinitely. Worst case was the daemon: gitWorktreeRoot/gitCommonDir run (memoized) on the main event loop while serving MCP clients, where an unbounded git hang would trip the 60s liveness watchdog and SIGKILL a healthy daemon. git calls get 5s, the interactive npm install 120s. Regression tests assert the option through a mocked child_process plus a per-file call-site sweep. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
159 lines
6.7 KiB
TypeScript
159 lines
6.7 KiB
TypeScript
/**
|
|
* Git Worktree Awareness
|
|
*
|
|
* A CodeGraph index lives in a `.codegraph/` directory and is resolved by
|
|
* walking up parent directories to the nearest one (see
|
|
* `findNearestCodeGraphRoot`). That walk is unaware of git worktrees: when a
|
|
* worktree is created *inside* the main checkout (e.g. some tools place them
|
|
* under `.gitignore`d paths like `.claude/worktrees/<name>/`), a command run
|
|
* from the worktree walks up and silently resolves the MAIN checkout's index.
|
|
*
|
|
* Every query then returns results from the main tree's code — usually a
|
|
* different branch — rather than the worktree the user is actually editing.
|
|
* Symbols added or changed only in the worktree are invisible. This module
|
|
* detects that "borrowed index" situation so callers can warn about it.
|
|
*
|
|
* Detection is best-effort: when git is unavailable or the path isn't a repo,
|
|
* it reports "no mismatch" and callers carry on unchanged.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { execFileSync } from 'child_process';
|
|
|
|
/**
|
|
* Absolute, symlink-resolved toplevel of the git working tree that `dir`
|
|
* belongs to, or null when `dir` isn't inside a git repo (or git is missing).
|
|
*
|
|
* `git rev-parse --show-toplevel` returns the per-worktree root: the main
|
|
* checkout and each linked worktree report their own distinct directory, which
|
|
* is exactly the distinction this module relies on.
|
|
*/
|
|
export function gitWorktreeRoot(dir: string): string | null {
|
|
try {
|
|
const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
cwd: dir,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
windowsHide: true,
|
|
// Bounded like every git call in extraction/: this runs (memoized) on
|
|
// the daemon's main event loop, where an unbounded hang would trip the
|
|
// 60s liveness watchdog and SIGKILL a healthy daemon (#1139).
|
|
timeout: 5000,
|
|
}).trim();
|
|
return out ? realpath(out) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Absolute, symlink-resolved git **common** directory for `dir` — the shared
|
|
* `.git` that all worktrees of one repository point at. Linked worktrees of the
|
|
* same repo report the SAME common dir; a submodule or an embedded clone is a
|
|
* DIFFERENT repository and reports its own (`…/.git/modules/<name>` or its own
|
|
* `.git`). That distinction is what separates a genuine "borrowed worktree"
|
|
* from a nested repo the parent index already covers. Null when not a repo.
|
|
*/
|
|
export function gitCommonDir(dir: string): string | null {
|
|
try {
|
|
const out = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
cwd: dir,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
windowsHide: true,
|
|
timeout: 5000, // same rationale as gitWorktreeRoot
|
|
}).trim();
|
|
if (!out) return null;
|
|
// `--git-common-dir` is relative to cwd unless already absolute.
|
|
return realpath(path.isAbsolute(out) ? out : path.resolve(dir, out));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface WorktreeIndexMismatch {
|
|
/** The git working tree the command was run from. */
|
|
worktreeRoot: string;
|
|
/** The (different) working tree whose `.codegraph` index is being used. */
|
|
indexRoot: string;
|
|
}
|
|
|
|
/**
|
|
* Detect when `startPath` lives in one git working tree but the resolved
|
|
* CodeGraph index (`indexRoot`) belongs to a *different* working tree.
|
|
*
|
|
* Returns null — meaning "nothing to warn about" — when:
|
|
* - `startPath` isn't in a git repo (or git is unavailable),
|
|
* - the index already lives in `startPath`'s own working tree, or
|
|
* - `indexRoot` isn't itself a working-tree root (an unrelated parent dir
|
|
* that merely happens to contain a `.codegraph/`), which keeps non-git
|
|
* and monorepo-subdir layouts from producing false warnings.
|
|
*/
|
|
export function detectWorktreeIndexMismatch(
|
|
startPath: string,
|
|
indexRoot: string,
|
|
): WorktreeIndexMismatch | null {
|
|
const worktreeRoot = gitWorktreeRoot(startPath);
|
|
if (!worktreeRoot) return null;
|
|
|
|
const resolvedIndexRoot = realpath(indexRoot);
|
|
if (worktreeRoot === resolvedIndexRoot) return null;
|
|
|
|
// Only flag it when the index root is itself a real working-tree root. This
|
|
// distinguishes "borrowed another worktree's index" from "index sits in a
|
|
// plain ancestor directory", and avoids warning outside git entirely.
|
|
if (gitWorktreeRoot(resolvedIndexRoot) !== resolvedIndexRoot) return null;
|
|
|
|
// Don't flag a nested repo (submodule / embedded clone) that `indexRoot`'s
|
|
// index ALREADY covers: indexing a super-repo descends into its submodules
|
|
// and gitlinked clones, so a query run from inside one resolves up to the
|
|
// parent index — whose graph *does* contain that nested repo's files. The
|
|
// warning's premise ("results are a different branch; symbols changed only
|
|
// here are missing") is false there, and its "run codegraph init -i" advice
|
|
// would needlessly fragment the unified workspace index. A genuine borrowed
|
|
// worktree and the index root are the SAME repository (they share a git
|
|
// common dir); a submodule/embedded clone is a DIFFERENT repository and does
|
|
// not — so suppress only when the two clearly differ. (#1031, #1033)
|
|
const worktreeCommon = gitCommonDir(worktreeRoot);
|
|
const indexCommon = gitCommonDir(resolvedIndexRoot);
|
|
if (worktreeCommon && indexCommon && worktreeCommon !== indexCommon) return null;
|
|
|
|
return { worktreeRoot, indexRoot: resolvedIndexRoot };
|
|
}
|
|
|
|
/** One-line-per-fact warning describing a detected mismatch. */
|
|
export function worktreeMismatchWarning(m: WorktreeIndexMismatch): string {
|
|
return (
|
|
`This CodeGraph index belongs to a different git working tree.\n` +
|
|
` Running in: ${m.worktreeRoot}\n` +
|
|
` Index from: ${m.indexRoot}\n` +
|
|
`Results reflect that tree's code (often a different branch), not this worktree — ` +
|
|
`symbols changed only here are missing. Run "codegraph init -i" in this worktree ` +
|
|
`for a worktree-local index.`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Compact, single-line variant for prefixing a tool's result. Read tools
|
|
* return their answer inline, so the heads-up has to ride on the same payload
|
|
* the agent is already reading — a multi-line block would bury the result.
|
|
*/
|
|
export function worktreeMismatchNotice(m: WorktreeIndexMismatch): string {
|
|
return (
|
|
`⚠ CodeGraph results below come from a different git worktree (${m.indexRoot}), ` +
|
|
`not where you're working (${m.worktreeRoot}) — they may reflect another branch, ` +
|
|
`and symbols changed only here are missing. Run "codegraph init -i" here for a ` +
|
|
`worktree-local index.`
|
|
);
|
|
}
|
|
|
|
/** Resolve symlinks where possible so tmp/realpath quirks don't break equality. */
|
|
function realpath(p: string): string {
|
|
try {
|
|
return fs.realpathSync(path.resolve(p));
|
|
} catch {
|
|
return path.resolve(p);
|
|
}
|
|
}
|