mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 20:12:11 +08:00
* fix(mcp): a projectPath in a nested repo the ancestor index leaves out isn't answered from it (#2110) An explicit projectPath resolves to the nearest .codegraph/ walking up, and that walk crosses git boundaries. Inside an independent repository nested under an indexed parent that excludes it (typically gitignored), every call silently answered from the parent's code. After resolving the index root, ToolHandler now asks whether the path sits in a different git repository strictly below that root (git toplevel + common dir, memoized per path and root) and, if so, whether the index holds any file under it (one primary-key range probe per call). None means the success-shaped "isn't indexed" guidance. Nested repos the parent does index (submodules, gitlinks, embedded clones), ordinary subdirectories, linked worktrees of the same repository (the when git can't run, behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): nested repositories the index leaves out (#2110) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.0 KiB
TypeScript
219 lines
9.0 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 };
|
|
}
|
|
|
|
/** A git repository of its own nested below an index root. */
|
|
export interface NestedRepository {
|
|
/** The nested repository's working-tree root. */
|
|
root: string;
|
|
/** `root` relative to the index root, POSIX — the prefix its files carry in that index. */
|
|
relPath: string;
|
|
}
|
|
|
|
/**
|
|
* The repository `startPath` belongs to when it is a DIFFERENT git repository
|
|
* nested strictly below `indexRoot` — an embedded clone, a submodule, a
|
|
* gitignored checkout. The up-walk to the nearest `.codegraph/` crosses its
|
|
* boundary without noticing, and the ancestor's index may or may not hold its
|
|
* files, so the caller has to ask that index before answering for it (#2110).
|
|
*
|
|
* Returns null — nothing to check — when:
|
|
* - `startPath` isn't in a git repo (or git is unavailable),
|
|
* - its repository root is the index root or above it (an ordinary
|
|
* subdirectory; a monorepo sub-project with its own index), or
|
|
* - it is a linked worktree of the index root's OWN repository (same git
|
|
* common dir): the #155 borrowed-worktree case, warned about, not refused.
|
|
*/
|
|
export function nestedRepositoryBelow(startPath: string, indexRoot: string): NestedRepository | null {
|
|
// git needs an existing directory to run in: a file, or a sub-path that does
|
|
// not exist yet, belongs to the repository of its nearest existing directory.
|
|
let dir = path.resolve(startPath);
|
|
while (!isDirectory(dir)) {
|
|
const up = path.dirname(dir);
|
|
if (up === dir) return null;
|
|
dir = up;
|
|
}
|
|
const repoRoot = gitWorktreeRoot(dir);
|
|
if (!repoRoot) return null;
|
|
|
|
// The on-disk spelling of both ends (`realpathSync.native` normalizes case on
|
|
// macOS and Windows), so `relPath` matches the paths the index stored.
|
|
const rel = path.relative(nativeRealpath(indexRoot), nativeRealpath(repoRoot));
|
|
if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
|
|
|
|
const repoCommon = gitCommonDir(repoRoot);
|
|
if (!repoCommon || repoCommon === gitCommonDir(realpath(indexRoot))) return null;
|
|
return { root: repoRoot, relPath: rel.split(path.sep).join('/') };
|
|
}
|
|
|
|
function isDirectory(p: string): boolean {
|
|
try {
|
|
return fs.statSync(p).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function nativeRealpath(p: string): string {
|
|
try {
|
|
return fs.realpathSync.native(path.resolve(p));
|
|
} catch {
|
|
return realpath(p);
|
|
}
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|