mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
fix(mcp): a projectPath in a nested repo the ancestor index leaves out isn't answered from it (#2110) (#2287)
* 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>
This commit is contained in:
@@ -163,6 +163,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- Newer MCP clients such as Antigravity 2.5 connect again: the server now answers their `server/discover` probe right away with "method not found", so they go straight on to the regular handshake instead of waiting on a reply that could take seconds or never come. Thanks @danusha2345, and @samem26 for the report. (#2084)
|
||||
- A CodeGraph session that queried another project through `projectPath` no longer keeps that project locked for as long as it runs: after 10 minutes without a query it lets the project go, so the project's own session and `codegraph index` can take over again (tune with `CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS`, `0` keeps it open). Thanks @danusha2345, and @bompus for the report. (#2087)
|
||||
- Claude Code now shows the model the full text of `codegraph_status` and of explore's stale-index refusal. Both results also carried `structuredContent`, and Claude Code shows a result's `structuredContent` in place of its text, so the model saw only a freshness JSON object: no refusal message, no retry guidance and no status report. Both results are now text only. Thanks @bompus. (#2088)
|
||||
- When an agent asks CodeGraph about a separate git repository nested inside an indexed project, one the project's index leaves out (for example because the parent's `.gitignore` excludes it), it now gets the usual "isn't indexed" guidance instead of answers from the parent project's code. Nested repositories the parent does index, such as submodules, work as before. Thanks @wstczyw for the report. (#2110)
|
||||
|
||||
## [1.6.1] - 2026-09-29
|
||||
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* A `projectPath` inside an independent nested git repository that the
|
||||
* ancestor index excludes (#2110).
|
||||
*
|
||||
* `findNearestCodeGraphRoot` walks up to the nearest `.codegraph/` without
|
||||
* looking at git boundaries. For a submodule or embedded clone the ancestor
|
||||
* indexes, that is what we want — one index serves the whole workspace. For a
|
||||
* nested repository the ancestor does NOT index (typically gitignored), the
|
||||
* call silently answered from the ancestor's code instead. It must now get the
|
||||
* same success-shaped "isn't indexed" guidance a project with no index at all
|
||||
* gets, while every layout the ancestor index does cover keeps resolving to it.
|
||||
*
|
||||
* Real `git`, real temp repositories, real indexes — no mocking.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execFileSync, spawn, ChildProcessWithoutNullStreams } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import CodeGraph from '../src/index';
|
||||
import { ToolHandler, __setLoadCodeGraphForTests } from '../src/mcp/tools';
|
||||
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
|
||||
|
||||
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
||||
|
||||
function git(cwd: string, ...args: string[]): void {
|
||||
execFileSync('git', args, { cwd, stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true });
|
||||
}
|
||||
|
||||
function initRepo(dir: string): void {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
git(dir, 'init', '-q');
|
||||
git(dir, 'config', 'user.email', 'test@example.com');
|
||||
git(dir, 'config', 'user.name', 'Test');
|
||||
git(dir, 'config', 'commit.gpgsign', 'false');
|
||||
}
|
||||
|
||||
/**
|
||||
* The issue's layout: `parent/` is a repository that gitignores `nested/`,
|
||||
* and `nested/` is an independent repository of its own. Only the parent is
|
||||
* indexed. `parent/src/` is an ordinary subdirectory of the parent.
|
||||
*/
|
||||
function makeParentWithIgnoredNestedRepo(root: string, ignoreNested = true): { parent: string; nested: string } {
|
||||
const parent = path.join(root, 'parent');
|
||||
const nested = path.join(parent, 'nested');
|
||||
initRepo(parent);
|
||||
if (ignoreNested) fs.writeFileSync(path.join(parent, '.gitignore'), 'nested/\nwt/\n');
|
||||
fs.writeFileSync(path.join(parent, 'parent-only.ts'), "export function parentOnly() { return 'parent'; }\n");
|
||||
fs.mkdirSync(path.join(parent, 'src'));
|
||||
fs.writeFileSync(path.join(parent, 'src', 'sub.ts'), "export function parentSub() { return 'sub'; }\n");
|
||||
git(parent, 'add', '.');
|
||||
git(parent, 'commit', '-q', '-m', 'repro-parent');
|
||||
|
||||
initRepo(nested);
|
||||
fs.writeFileSync(path.join(nested, 'nested-only.ts'), "export function nestedOnly() { return 'nested'; }\n");
|
||||
fs.mkdirSync(path.join(nested, 'lib'));
|
||||
fs.writeFileSync(path.join(nested, 'lib', 'deep.ts'), "export function nestedDeep() { return 'deep'; }\n");
|
||||
git(nested, 'add', '.');
|
||||
git(nested, 'commit', '-q', '-m', 'repro-nested');
|
||||
return { parent, nested };
|
||||
}
|
||||
|
||||
function text(res: { content: Array<{ type: string; text?: string }> }): string {
|
||||
const first = res.content[0];
|
||||
return first && first.type === 'text' ? first.text ?? '' : '';
|
||||
}
|
||||
|
||||
describe('projectPath inside a nested git repository the ancestor index excludes (#2110)', () => {
|
||||
let root: string;
|
||||
let parent: string;
|
||||
let nested: string;
|
||||
let cg: CodeGraph;
|
||||
let handler: ToolHandler | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nested-repo-'));
|
||||
({ parent, nested } = makeParentWithIgnoredNestedRepo(root));
|
||||
cg = CodeGraph.initSync(parent);
|
||||
await cg.indexAll();
|
||||
handler = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
__setLoadCodeGraphForTests(null);
|
||||
if (handler) await handler.closeAll();
|
||||
try { cg.destroy(); } catch { /* best effort */ }
|
||||
try { git(parent, 'worktree', 'remove', '--force', path.join(parent, 'wt')); } catch { /* none added */ }
|
||||
fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
it('the fixture matches the issue: the parent index holds none of the nested repository', () => {
|
||||
expect(cg.getFile('parent-only.ts')).not.toBeNull();
|
||||
expect(cg.getFile('nested/nested-only.ts')).toBeNull();
|
||||
});
|
||||
|
||||
it('answers with success-shaped "isn\'t indexed" guidance instead of the parent\'s code', async () => {
|
||||
handler = new ToolHandler(cg); // the server runs in the parent, as in the issue
|
||||
const res = await handler.execute('codegraph_explore', {
|
||||
query: 'parentOnly nestedOnly',
|
||||
projectPath: nested,
|
||||
maxFiles: 10,
|
||||
});
|
||||
const body = text(res);
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(body).toMatch(/isn't indexed/);
|
||||
expect(body).toMatch(/codegraph init/);
|
||||
expect(body).toMatch(/built-in tools/);
|
||||
expect(body).not.toContain('parent-only.ts');
|
||||
expect(body).not.toContain('parentOnly()');
|
||||
});
|
||||
|
||||
it('covers a subdirectory of the nested repository, existing or not', async () => {
|
||||
handler = new ToolHandler(cg);
|
||||
for (const projectPath of [path.join(nested, 'lib'), path.join(nested, 'lib', 'not-created-yet')]) {
|
||||
const res = await handler.execute('codegraph_search', { query: 'parentOnly', projectPath });
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(text(res)).toMatch(/isn't indexed/);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses the same way when the parent is not the session\'s default project', async () => {
|
||||
__setLoadCodeGraphForTests(CodeGraph);
|
||||
handler = new ToolHandler(null);
|
||||
const res = await handler.execute('codegraph_search', { query: 'parentOnly', projectPath: nested });
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(text(res)).toMatch(/isn't indexed/);
|
||||
|
||||
// …while the parent itself is still served by projectPath.
|
||||
const ok = await handler.execute('codegraph_search', { query: 'parentOnly', projectPath: parent });
|
||||
expect(ok.isError).toBeUndefined();
|
||||
expect(text(ok)).toContain('parent-only.ts');
|
||||
});
|
||||
|
||||
it('keeps resolving an ordinary subdirectory of the parent to the parent index', async () => {
|
||||
handler = new ToolHandler(cg);
|
||||
const res = await handler.execute('codegraph_search', {
|
||||
query: 'parentSub',
|
||||
projectPath: path.join(parent, 'src'),
|
||||
});
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(text(res)).not.toMatch(/isn't indexed/);
|
||||
expect(text(res)).toContain('src/sub.ts');
|
||||
});
|
||||
|
||||
it('keeps serving a linked worktree of the parent from the parent index, with the #155 notice', async () => {
|
||||
// A worktree nested inside (and ignored by) the main checkout is the SAME
|
||||
// repository — the borrowed-worktree case, which is warned about, not refused.
|
||||
const wt = path.join(parent, 'wt');
|
||||
git(parent, 'worktree', 'add', '-q', '-b', 'feature', wt);
|
||||
handler = new ToolHandler(cg);
|
||||
const res = await handler.execute('codegraph_search', { query: 'parentOnly', projectPath: wt });
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(text(res)).not.toMatch(/isn't indexed/);
|
||||
expect(text(res)).toContain('different git worktree');
|
||||
expect(text(res)).toContain('parent-only.ts');
|
||||
});
|
||||
|
||||
it('notices the nested repository joining the index without a restart', async () => {
|
||||
handler = new ToolHandler(cg);
|
||||
const before = await handler.execute('codegraph_search', { query: 'nestedOnly', projectPath: nested });
|
||||
expect(text(before)).toMatch(/isn't indexed/);
|
||||
|
||||
// The user stops ignoring it and the parent re-syncs (an embedded clone the
|
||||
// parent does not ignore is indexed with it).
|
||||
fs.writeFileSync(path.join(parent, '.gitignore'), 'wt/\n');
|
||||
await cg.sync();
|
||||
expect(cg.getFile('nested/nested-only.ts')).not.toBeNull();
|
||||
|
||||
const after = await handler.execute('codegraph_search', { query: 'nestedOnly', projectPath: nested });
|
||||
expect(after.isError).toBeUndefined();
|
||||
expect(text(after)).not.toMatch(/isn't indexed/);
|
||||
expect(text(after)).toContain('nested/nested-only.ts');
|
||||
});
|
||||
|
||||
it('serves a nested repository that has its own index from that index', async () => {
|
||||
const nestedCg = CodeGraph.initSync(nested);
|
||||
await nestedCg.indexAll();
|
||||
nestedCg.close();
|
||||
__setLoadCodeGraphForTests(CodeGraph);
|
||||
handler = new ToolHandler(cg);
|
||||
const res = await handler.execute('codegraph_search', { query: 'nestedOnly', projectPath: nested });
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(text(res)).not.toMatch(/isn't indexed/);
|
||||
expect(text(res)).toContain('nested-only.ts');
|
||||
});
|
||||
|
||||
it('fails open to the ancestor index when git cannot be run', async () => {
|
||||
handler = new ToolHandler(cg);
|
||||
const savedPath = process.env.PATH;
|
||||
process.env.PATH = '';
|
||||
try {
|
||||
const res = await handler.execute('codegraph_search', { query: 'parentOnly', projectPath: nested });
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(text(res)).not.toMatch(/isn't indexed/);
|
||||
} finally {
|
||||
process.env.PATH = savedPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectPath inside a nested git repository the ancestor index DOES cover (#2110)', () => {
|
||||
let root: string;
|
||||
let parent: string;
|
||||
let nested: string;
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nested-covered-'));
|
||||
// Same two repositories, but the parent does not ignore the nested one — an
|
||||
// embedded clone the parent's index takes in.
|
||||
({ parent, nested } = makeParentWithIgnoredNestedRepo(root, false));
|
||||
cg = CodeGraph.initSync(parent);
|
||||
await cg.indexAll();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { cg.destroy(); } catch { /* best effort */ }
|
||||
fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
it('keeps resolving to the parent index, which answers for the nested repository', async () => {
|
||||
expect(cg.getFile('nested/nested-only.ts')).not.toBeNull();
|
||||
const res = await new ToolHandler(cg).execute('codegraph_search', {
|
||||
query: 'nestedOnly',
|
||||
projectPath: nested,
|
||||
});
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(text(res)).not.toMatch(/isn't indexed/);
|
||||
expect(text(res)).toContain('nested/nested-only.ts');
|
||||
});
|
||||
|
||||
it('keeps resolving a submodule the parent indexes to the parent index', async () => {
|
||||
const source = path.join(root, 'sub-source');
|
||||
initRepo(source);
|
||||
fs.writeFileSync(path.join(source, 'lib.ts'), 'export function fromSubmodule() { return 1; }\n');
|
||||
git(source, 'add', '.');
|
||||
git(source, 'commit', '-q', '-m', 'sub');
|
||||
git(parent, '-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', source, 'service-a');
|
||||
git(parent, 'commit', '-q', '-m', 'add submodule');
|
||||
await cg.sync();
|
||||
expect(cg.getFile('service-a/lib.ts')).not.toBeNull();
|
||||
|
||||
const res = await new ToolHandler(cg).execute('codegraph_search', {
|
||||
query: 'fromSubmodule',
|
||||
projectPath: path.join(parent, 'service-a'),
|
||||
});
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(text(res)).not.toMatch(/isn't indexed/);
|
||||
expect(text(res)).toContain('service-a/lib.ts');
|
||||
});
|
||||
});
|
||||
|
||||
/** Send a JSON-RPC request and resolve with the response matching its id. */
|
||||
function request(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
msg: { id: number; method: string; params?: unknown },
|
||||
timeoutMs = 20000,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.stdout.off('data', onData);
|
||||
reject(new Error(`timeout waiting for response id=${msg.id}`));
|
||||
}, timeoutMs);
|
||||
const onData = (chunk: Buffer) => {
|
||||
buf += chunk.toString();
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf('\n')) !== -1) {
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
if (parsed.id === msg.id) {
|
||||
clearTimeout(timer);
|
||||
child.stdout.off('data', onData);
|
||||
resolve(parsed);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// non-JSON noise on stdout — ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
child.stdout.on('data', onData);
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
describe('stdio MCP: projectPath inside an excluded nested git repository (#2110)', { timeout: 60_000 }, () => {
|
||||
let root: string;
|
||||
let parent: string;
|
||||
let nested: string;
|
||||
let child: ChildProcessWithoutNullStreams | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nested-stdio-'));
|
||||
({ parent, nested } = makeParentWithIgnoredNestedRepo(root));
|
||||
const cg = CodeGraph.initSync(parent);
|
||||
await cg.indexAll();
|
||||
cg.close();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (child) {
|
||||
const exited = new Promise<void>((resolve) => child!.once('exit', () => resolve()));
|
||||
child.kill('SIGKILL');
|
||||
await Promise.race([exited, new Promise((r) => setTimeout(r, 3000))]);
|
||||
child = null;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
it('returns the not-indexed guidance for the nested repository and still serves the parent', async () => {
|
||||
child = spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp'], {
|
||||
cwd: parent,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
// In-process server (no detached daemon to leak); see mcp-unindexed.test.ts.
|
||||
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
|
||||
}) as ChildProcessWithoutNullStreams;
|
||||
|
||||
await request(child, {
|
||||
id: 0,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '0.0.0' },
|
||||
rootUri: `file://${parent}`,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(child, {
|
||||
id: 3,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'codegraph_explore',
|
||||
arguments: { query: 'parentOnly nestedOnly', projectPath: nested, maxFiles: 10 },
|
||||
},
|
||||
});
|
||||
const result = res.result as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.content[0]!.text).toMatch(/isn't indexed/);
|
||||
expect(result.content[0]!.text).not.toContain('parent-only.ts');
|
||||
|
||||
const ok = await request(child, {
|
||||
id: 4,
|
||||
method: 'tools/call',
|
||||
params: { name: 'codegraph_explore', arguments: { query: 'parentOnly', projectPath: parent } },
|
||||
});
|
||||
const okResult = ok.result as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(okResult.isError).toBeUndefined();
|
||||
expect(okResult.content[0]!.text).toContain('parent-only.ts');
|
||||
});
|
||||
});
|
||||
@@ -280,6 +280,7 @@ export class QueryBuilder {
|
||||
deleteFile?: SqliteStatement;
|
||||
getFileByPath?: SqliteStatement;
|
||||
getAllFiles?: SqliteStatement;
|
||||
hasFilesUnder?: SqliteStatement;
|
||||
insertUnresolved?: SqliteStatement;
|
||||
deleteUnresolvedByNode?: SqliteStatement;
|
||||
getUnresolvedByName?: SqliteStatement;
|
||||
@@ -3184,6 +3185,18 @@ export class QueryBuilder {
|
||||
return row ? rowToFileRecord(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any tracked file lives under the project-relative POSIX directory
|
||||
* `dir`. A range scan on the `path` primary key — `dir/` up to `dir0` ('0'
|
||||
* is the byte after '/') — so it costs one index probe at any repo size.
|
||||
*/
|
||||
hasFilesUnder(dir: string): boolean {
|
||||
if (!this.stmts.hasFilesUnder) {
|
||||
this.stmts.hasFilesUnder = this.db.prepare('SELECT 1 FROM files WHERE path >= ? AND path < ? LIMIT 1');
|
||||
}
|
||||
return this.stmts.hasFilesUnder.get(`${dir}/`, `${dir}0`) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracked files
|
||||
*/
|
||||
|
||||
@@ -2019,6 +2019,14 @@ export class CodeGraph {
|
||||
return this.queries.getFileByPath(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the index holds any file under `dir`, a project-relative POSIX
|
||||
* directory (e.g. `packages/api`).
|
||||
*/
|
||||
hasFilesUnder(dir: string): boolean {
|
||||
return this.queries.hasFilesUnder(dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracked files
|
||||
*/
|
||||
|
||||
@@ -26,8 +26,10 @@ export function __setLoadCodeGraphForTests(cls: typeof import('../index').defaul
|
||||
}
|
||||
import {
|
||||
detectWorktreeIndexMismatch,
|
||||
nestedRepositoryBelow,
|
||||
worktreeMismatchWarning,
|
||||
worktreeMismatchNotice,
|
||||
type NestedRepository,
|
||||
type WorktreeIndexMismatch,
|
||||
} from '../sync/worktree';
|
||||
import type { PendingFile } from '../sync';
|
||||
@@ -1941,6 +1943,10 @@ export class ToolHandler {
|
||||
// once and every later tool call reuses the result — never shelling out to
|
||||
// git on the hot path. `undefined` = not computed yet; `null` = no mismatch.
|
||||
private worktreeMismatchCache: Map<string, WorktreeIndexMismatch | null> = new Map();
|
||||
// Per-(projectPath, index root) cache of the different git repository the
|
||||
// path sits in below that root, if any (#2110) — the git half of
|
||||
// `uncoveredNestedRepo`, memoized like the mismatch above.
|
||||
private nestedRepoCache: Map<string, NestedRepository | null> = new Map();
|
||||
// Gate that the MCP engine pokes after `cg.open()` so the first tool call
|
||||
// blocks on the post-open filesystem reconcile (catch-up sync). Without
|
||||
// this, a tool call that races past `catchUpSync()` serves rows for files
|
||||
@@ -2260,6 +2266,30 @@ export class ToolHandler {
|
||||
);
|
||||
}
|
||||
|
||||
const cg = this.openProjectRoot(resolvedRoot, canonicalRoot);
|
||||
// The walk above crosses git boundaries. A nested repository the ancestor
|
||||
// index leaves out (typically gitignored) would otherwise be answered from
|
||||
// the ancestor's code, looking like an answer about the requested project
|
||||
// (#2110) — so it gets the same guidance as a project with no index at all.
|
||||
const nested = this.uncoveredNestedRepo(projectPath, canonicalRoot, cg);
|
||||
if (nested) {
|
||||
throw new NotIndexedError(
|
||||
`The project at ${projectPath} isn't indexed with codegraph: it is its own git repository ` +
|
||||
`(${nested.root}), and the nearest index, at ${canonicalRoot}, holds none of its files ` +
|
||||
'(that repository is excluded from it, e.g. by a .gitignore), so codegraph cannot query it. ' +
|
||||
"Use your built-in tools (Read/Grep/Glob) for that codebase instead, and don't call codegraph " +
|
||||
"for it again this session. Indexing is the user's decision — they can run 'codegraph init' " +
|
||||
`in ${nested.root} to enable it.`
|
||||
);
|
||||
}
|
||||
return cg;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open CodeGraph for an index root the up-walk resolved: the default
|
||||
* instance, a cached one, or a newly opened (and cached) one.
|
||||
*/
|
||||
private openProjectRoot(resolvedRoot: string, canonicalRoot: string): CodeGraph {
|
||||
// If the path resolves to the default project, reuse the already-open
|
||||
// default instance rather than opening a SECOND connection to the same DB.
|
||||
// A duplicate connection serializes reads against the watcher's auto-sync
|
||||
@@ -2303,6 +2333,34 @@ export class ToolHandler {
|
||||
return cg;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nested git repository `projectPath` lives in when the index the
|
||||
* up-walk reached (`indexRoot`'s, open as `cg`) holds none of its files; null
|
||||
* when that index covers it — an ordinary subdirectory, a submodule or
|
||||
* embedded clone the ancestor indexes, a linked worktree (#155) — or when git
|
||||
* can't tell (#2110).
|
||||
*
|
||||
* The git half is memoized per (projectPath, index root), keyed on both for
|
||||
* the reason `worktreeMismatchCache` is (#926). The index half is one
|
||||
* primary-key probe per call, so a sync that brings the repository into the
|
||||
* index is honored without a restart.
|
||||
*/
|
||||
private uncoveredNestedRepo(projectPath: string, indexRoot: string, cg: CodeGraph): NestedRepository | null {
|
||||
const cacheKey = `${projectPath}\u0000${indexRoot}`;
|
||||
let nested = this.nestedRepoCache.get(cacheKey);
|
||||
if (nested === undefined) {
|
||||
nested = nestedRepositoryBelow(projectPath, indexRoot);
|
||||
this.nestedRepoCache.set(cacheKey, nested);
|
||||
}
|
||||
if (!nested) return null;
|
||||
try {
|
||||
return cg.hasFilesUnder(nested.relPath) ? null : nested;
|
||||
} catch {
|
||||
// An index we can't read is no evidence the repository is excluded.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async awaitProjectGate(projectPath: string): Promise<void> {
|
||||
const cg = this.getCodeGraph(projectPath);
|
||||
if (!this.projectLifecycle || cg === this.cg) return;
|
||||
@@ -2400,6 +2458,7 @@ export class ToolHandler {
|
||||
this.worktreeMismatchCache.clear();
|
||||
if (this.idleReleaseTimer) clearTimeout(this.idleReleaseTimer);
|
||||
this.idleReleaseTimer = null;
|
||||
this.nestedRepoCache.clear();
|
||||
this.trimProjects();
|
||||
if (this.projectCache.size === 0 && this.activeCalls === 0 && this.pendingCloses === 0) return Promise.resolve();
|
||||
return new Promise((resolve) => this.closeWaiters.push(resolve));
|
||||
|
||||
@@ -122,6 +122,66 @@ export function detectWorktreeIndexMismatch(
|
||||
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 (
|
||||
|
||||
Reference in New Issue
Block a user