diff --git a/CHANGELOG.md b/CHANGELOG.md index 6afc50b31..6c15e96fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__tests__/mcp-nested-repo-projectpath.test.ts b/__tests__/mcp-nested-repo-projectpath.test.ts new file mode 100644 index 000000000..6dc27cc07 --- /dev/null +++ b/__tests__/mcp-nested-repo-projectpath.test.ts @@ -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> { + 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; + 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((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'); + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 655f98a98..ae53515dd 100644 --- a/src/db/queries.ts +++ b/src/db/queries.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 */ diff --git a/src/index.ts b/src/index.ts index 99303b6fd..47330503a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 */ diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 3aa5fab87..c8fec8d3e 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -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 = 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 = 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 { 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)); diff --git a/src/sync/worktree.ts b/src/sync/worktree.ts index 1f9bfba7e..a648b9333 100644 --- a/src/sync/worktree.ts +++ b/src/sync/worktree.ts @@ -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 (