diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index c3c4ca0de3..7d8fbfda42 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -39,8 +39,12 @@ function isGlobalRegistryDir(candidate) { ); } -function findGitNexusDir(startDir) { - let dir = startDir || process.cwd(); +/** + * Walk up from `startDir` looking for a non-registry `.gitnexus/` folder. + * Returns the path to `.gitnexus/` or null if not found within 5 levels. + */ +function walkForGitNexusDir(startDir) { + let dir = startDir; for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); if (fs.existsSync(candidate)) { @@ -53,6 +57,51 @@ function findGitNexusDir(startDir) { return null; } +/** + * Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside + * any git working tree — including a *linked* worktree created via + * `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the + * upward walk from cwd alone misses the index. Returns null when `cwd` is + * not inside a git repo or `git` is not available. + * + * Implementation: `git rev-parse --git-common-dir` resolves to the canonical + * `.git/` directory (or `.git/worktrees/...` parent) that is shared across + * all linked worktrees. The canonical repo root is its parent directory. + */ +function findCanonicalRepoRoot(cwd) { + try { + const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) return null; + const commonDir = (result.stdout || '').trim(); + if (!commonDir || !path.isAbsolute(commonDir)) return null; + return path.dirname(commonDir); + } catch { + return null; + } +} + +function findGitNexusDir(startDir) { + const cwd = startDir || process.cwd(); + + // Fast path: the cwd is inside the canonical repo (most common case). + const fromCwd = walkForGitNexusDir(cwd); + if (fromCwd) return fromCwd; + + // Fallback: cwd may be inside a linked git worktree whose `.gitnexus/` + // only lives in the canonical repo root. Resolve the shared git dir + // and retry from there. + const canonicalRoot = findCanonicalRepoRoot(cwd); + if (canonicalRoot && canonicalRoot !== cwd) { + return walkForGitNexusDir(canonicalRoot); + } + return null; +} + /** * Extract search pattern from tool input. */ diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 34283b48e2..7bfa150cda 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -39,8 +39,12 @@ function isGlobalRegistryDir(candidate) { ); } -function findGitNexusDir(startDir) { - let dir = startDir || process.cwd(); +/** + * Walk up from `startDir` looking for a non-registry `.gitnexus/` folder. + * Returns the path to `.gitnexus/` or null if not found within 5 levels. + */ +function walkForGitNexusDir(startDir) { + let dir = startDir; for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); if (fs.existsSync(candidate)) { @@ -53,6 +57,51 @@ function findGitNexusDir(startDir) { return null; } +/** + * Resolve the canonical (main) worktree root for `cwd`, when `cwd` is inside + * any git working tree — including a *linked* worktree created via + * `git worktree add`. Linked worktrees never contain `.gitnexus/`, so the + * upward walk from cwd alone misses the index. Returns null when `cwd` is + * not inside a git repo or `git` is not available. + * + * Implementation: `git rev-parse --git-common-dir` resolves to the canonical + * `.git/` directory (or `.git/worktrees/...` parent) that is shared across + * all linked worktrees. The canonical repo root is its parent directory. + */ +function findCanonicalRepoRoot(cwd) { + try { + const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) return null; + const commonDir = (result.stdout || '').trim(); + if (!commonDir || !path.isAbsolute(commonDir)) return null; + return path.dirname(commonDir); + } catch { + return null; + } +} + +function findGitNexusDir(startDir) { + const cwd = startDir || process.cwd(); + + // Fast path: the cwd is inside the canonical repo (most common case). + const fromCwd = walkForGitNexusDir(cwd); + if (fromCwd) return fromCwd; + + // Fallback: cwd may be inside a linked git worktree whose `.gitnexus/` + // only lives in the canonical repo root. Resolve the shared git dir + // and retry from there. + const canonicalRoot = findCanonicalRepoRoot(cwd); + if (canonicalRoot && canonicalRoot !== cwd) { + return walkForGitNexusDir(canonicalRoot); + } + return null; +} + /** * Extract search pattern from tool input. */ diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index b1ec81c5f4..f581186483 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -193,6 +193,19 @@ export const isDbBusyError = (err: unknown): boolean => { ); }; +/** + * Return true when the error message indicates a write was attempted against + * a read-only LadybugDB connection. The MCP query pool opens DBs read-only, + * so any path that calls a `CREATE_*` procedure there will surface this + * (e.g. defensive `ensureFTSIndex` calls). Owners of the writable analyze + * path should ignore this error — index creation is owned by `gitnexus + * analyze` and either already happened or will happen on the next run. + */ +export const isReadOnlyDbError = (err: unknown): boolean => { + const msg = err instanceof Error ? err.message : String(err); + return /read-only database/i.test(msg); +}; + const runWithSessionLock = async (operation: () => Promise): Promise => { const previous = sessionLock; let release: (() => void) | null = null; @@ -1239,6 +1252,13 @@ export const createFTSIndex = async ( * * Safe to call repeatedly — the in-process Set guarantees only the first * call hits LadybugDB. `closeLbug` clears the cache so re-init starts fresh. + * + * Defense in depth: if the active connection is read-only (e.g. the MCP + * pool adapter), `CREATE_FTS_INDEX` will fail with "Cannot execute write + * operations in a read-only database". Treat that as a no-op and cache + * the key so callers don't loop on a path that can never succeed here — + * the index is owned by `gitnexus analyze` (writable) and either already + * exists or will be created on the next analyze. */ export const ensureFTSIndex = async ( tableName: string, @@ -1248,8 +1268,19 @@ export const ensureFTSIndex = async ( ): Promise => { const key = `${tableName}:${indexName}`; if (ensuredFTSIndexes.has(key)) return; - await createFTSIndex(tableName, indexName, properties, stemmer); - ensuredFTSIndexes.add(key); + try { + await createFTSIndex(tableName, indexName, properties, stemmer); + ensuredFTSIndexes.add(key); + } catch (e) { + // Read-only DB: writable analyze owns index creation; silently skip + // and cache so callers don't loop on a path that can never succeed + // here (the MCP query pool opens DBs read-only by design). + if (isReadOnlyDbError(e)) { + ensuredFTSIndexes.add(key); + return; + } + throw e; + } }; /** diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 5349897925..68df9feaac 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -1110,13 +1110,12 @@ export class LocalBackend { } } else if (!this.warnedVectorUnsupported) { // Rare diagnostic: surface why we fell back to the exact scan path so - // operators can see at a glance that the VECTOR extension is missing on - // this runtime (e.g. Windows builds without the optional native - // dependency). Emitted once per `LocalBackend` instance lifetime to - // avoid noisy stderr on hot semantic-search paths (DoD §2.8). + // operators can see at a glance that VECTOR is disabled by platform + // policy. Emitted once per `LocalBackend` instance lifetime to avoid + // noisy stderr on hot semantic-search paths (DoD §2.8). this.warnedVectorUnsupported = true; console.error( - 'GitNexus [query:vector]: VECTOR index unavailable for this runtime; using exact scan fallback', + 'GitNexus [query:vector]: VECTOR extension not supported on this platform; using exact scan fallback', ); } diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index 825ec627cb..4af66205e7 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -135,6 +135,20 @@ withTestLbugDB( ).resolves.toBeUndefined(); }); + it('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async () => { + const { ensureFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js'); + + // First call creates the index. Second call must short-circuit on the + // in-process cache — guarantees the read-only guard added in #1224 + // still respects the success path. + await expect( + ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']), + ).resolves.toBeUndefined(); + await expect( + ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']), + ).resolves.toBeUndefined(); + }); + it('getLbugStats returns valid counts', async () => { const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js'); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 2299f90a3e..aa422327be 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -218,13 +218,29 @@ describe('LocalBackend.callTool', () => { ), ).toBe(true); expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining('GitNexus [query:vector]: VECTOR index unavailable'), + expect.stringContaining( + 'GitNexus [query:vector]: VECTOR extension not supported on this platform', + ), ); } finally { consoleError.mockRestore(); } }); + it('issues vector index query when VECTOR is supported by the platform', async () => { + platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true); + (executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => { + if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }]; + return []; + }); + (executeParameterized as any).mockResolvedValue([]); + + await backend.callTool('query', { query: 'auth' }); + + const queries = (executeQuery as any).mock.calls.map(([, cypher]: [string, string]) => cypher); + expect(queries.some((cypher: string) => cypher.includes('QUERY_VECTOR_INDEX'))).toBe(true); + }); + it('query tool returns error for empty query', async () => { const result = await backend.callTool('query', { query: '' }); expect(result.error).toContain('query parameter is required'); diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index 7d61e2e8af..da19da002c 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -602,6 +602,82 @@ describe('Global registry lookup', () => { } }); +// ─── Integration: linked-worktree resolution (#1224) ─────────────── + +describe('Linked git worktree resolution', () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: PostToolUse emits stale from a linked worktree pointing at an indexed canonical repo`, () => { + // Layout mirrors `git worktree add ../-worktrees/feature-x`: + // /main-repo/.git (canonical) + // /main-repo/.gitnexus/ (only here) + // /main-repo-worktrees/feat/ (linked worktree, no .gitnexus) + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-')); + const mainRepo = path.join(root, 'main-repo'); + const worktreePath = path.join(root, 'main-repo-worktrees', 'feat'); + try { + fs.mkdirSync(mainRepo, { recursive: true }); + initGitRepo(mainRepo); + fs.mkdirSync(path.join(mainRepo, '.gitnexus'), { recursive: true }); + fs.writeFileSync( + path.join(mainRepo, '.gitnexus', 'meta.json'), + JSON.stringify({ lastCommit: 'oldcommit', stats: {} }), + ); + + // Create the linked worktree on a new branch. + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]); + + // Sanity: walking up from the worktree never reaches `.gitnexus`. + expect(fs.existsSync(path.join(worktreePath, '.gitnexus'))).toBe(false); + expect(fs.existsSync(path.join(path.dirname(worktreePath), '.gitnexus'))).toBe(false); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: worktreePath, + }); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('stale'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it(`${label}: PostToolUse silent from a linked worktree when canonical repo has no .gitnexus`, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-')); + const mainRepo = path.join(root, 'main-repo'); + const worktreePath = path.join(root, 'main-repo-worktrees', 'feat'); + try { + fs.mkdirSync(mainRepo, { recursive: true }); + initGitRepo(mainRepo); + // Note: NO .gitnexus/ in the canonical repo. + + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]); + + const result = runHook(hookPath, { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: worktreePath, + }); + + expect(result.stdout.trim()).toBe(''); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + } +}); + // ─── Integration: dispatch map routes correctly ───────────────────── describe('Dispatch map routing (integration)', () => { diff --git a/gitnexus/test/unit/lbug-readonly-error.test.ts b/gitnexus/test/unit/lbug-readonly-error.test.ts new file mode 100644 index 0000000000..4e412c323d --- /dev/null +++ b/gitnexus/test/unit/lbug-readonly-error.test.ts @@ -0,0 +1,55 @@ +/** + * Regression Tests: read-only DB error discriminator (#1224) + * + * The MCP query pool opens LadybugDB read-only. Defensive callers of + * `ensureFTSIndex` from that pool used to spam stderr with five + * "Cannot execute write operations in a read-only database" warnings + * per query because the cache was invalidated each time. The fix: + * `ensureFTSIndex` now treats the read-only error as a no-op and + * caches the key — but to do that it relies on a precise discriminator + * that does NOT swallow lock / busy / "already exists" errors. + * + * This file unit-tests the discriminator directly so future refactors + * keep the contract. + */ +import { describe, it, expect } from 'vitest'; +import { isReadOnlyDbError } from '../../src/core/lbug/lbug-adapter.js'; + +describe('isReadOnlyDbError', () => { + it('matches the canonical LadybugDB read-only message verbatim', () => { + const err = new Error( + 'Connection exception: Cannot execute write operations in a read-only database!', + ); + expect(isReadOnlyDbError(err)).toBe(true); + }); + + it('matches when the error is wrapped in additional prefix text', () => { + const err = new Error( + 'Runtime exception: Cannot execute write operations in a read-only database', + ); + expect(isReadOnlyDbError(err)).toBe(true); + }); + + it('is case-insensitive on the "read-only" substring', () => { + expect(isReadOnlyDbError(new Error('Read-Only Database access denied'))).toBe(true); + }); + + it('accepts non-Error values (string, unknown) without throwing', () => { + expect(isReadOnlyDbError('write rejected: read-only database')).toBe(true); + expect(isReadOnlyDbError({ toString: () => 'read-only database' })).toBe(true); + expect(isReadOnlyDbError(null)).toBe(false); + expect(isReadOnlyDbError(undefined)).toBe(false); + }); + + it('does NOT match unrelated errors that the ensure path must still surface', () => { + // Lock contention — handled separately by isDbBusyError; must not be + // silenced by the read-only filter. + expect(isReadOnlyDbError(new Error('Could not set lock on file'))).toBe(false); + // "already exists" — the happy idempotent path inside createFTSIndex. + expect(isReadOnlyDbError(new Error('Index file_fts already exists'))).toBe(false); + // Schema-level problem. + expect(isReadOnlyDbError(new Error('Table File does not exist'))).toBe(false); + // Generic transient error. + expect(isReadOnlyDbError(new Error('Connection refused'))).toBe(false); + }); +});