diff --git a/gitnexus-claude-plugin/hooks/registry-query.cjs b/gitnexus-claude-plugin/hooks/registry-query.cjs index 649b363fe..d3ac72fd7 100644 --- a/gitnexus-claude-plugin/hooks/registry-query.cjs +++ b/gitnexus-claude-plugin/hooks/registry-query.cjs @@ -298,6 +298,34 @@ function resolveEntryStoragePath(entry) { return path.resolve(path.join(entry.path, GITNEXUS_DIR)); } +function isDirectChild(parent, child) { + const rel = path.relative(parent, child); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep); +} + +// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a +// shared-store checkout slot may read a commit graph in the same store +// instead of owning /lbug. Any other recorded value is ignored. +function resolveGraphPath(storagePath, metadata) { + const own = path.join(storagePath, LBUG_DIRECTORY); + const storesRoot = path.resolve( + process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'), + 'stores', + ); + const slot = path.resolve(storagePath); + const checkoutsDir = path.dirname(slot); + const root = path.dirname(checkoutsDir); + if (path.basename(checkoutsDir) !== 'checkouts') return own; + if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own; + const recorded = metadata && metadata.graphPath; + if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; + const graph = path.resolve(recorded); + const valid = + path.basename(graph) === LBUG_DIRECTORY && + isDirectChild(path.join(root, 'commits'), path.dirname(graph)); + return valid ? graph : own; +} + function hasLocalIndexSignal(storagePath) { try { return ( @@ -387,7 +415,9 @@ function findRegisteredRepo(cwd) { best = { path: entry.path, storagePath, - lbugPath: path.join(indexDir, LBUG_DIRECTORY), + lbugPath: branchIsIndexed + ? path.join(indexDir, LBUG_DIRECTORY) + : resolveGraphPath(storagePath, ownershipMetadata), metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata, }; } diff --git a/gitnexus-cursor-integration/hooks/registry-query.cjs b/gitnexus-cursor-integration/hooks/registry-query.cjs index 649b363fe..d3ac72fd7 100644 --- a/gitnexus-cursor-integration/hooks/registry-query.cjs +++ b/gitnexus-cursor-integration/hooks/registry-query.cjs @@ -298,6 +298,34 @@ function resolveEntryStoragePath(entry) { return path.resolve(path.join(entry.path, GITNEXUS_DIR)); } +function isDirectChild(parent, child) { + const rel = path.relative(parent, child); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep); +} + +// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a +// shared-store checkout slot may read a commit graph in the same store +// instead of owning /lbug. Any other recorded value is ignored. +function resolveGraphPath(storagePath, metadata) { + const own = path.join(storagePath, LBUG_DIRECTORY); + const storesRoot = path.resolve( + process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'), + 'stores', + ); + const slot = path.resolve(storagePath); + const checkoutsDir = path.dirname(slot); + const root = path.dirname(checkoutsDir); + if (path.basename(checkoutsDir) !== 'checkouts') return own; + if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own; + const recorded = metadata && metadata.graphPath; + if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; + const graph = path.resolve(recorded); + const valid = + path.basename(graph) === LBUG_DIRECTORY && + isDirectChild(path.join(root, 'commits'), path.dirname(graph)); + return valid ? graph : own; +} + function hasLocalIndexSignal(storagePath) { try { return ( @@ -387,7 +415,9 @@ function findRegisteredRepo(cwd) { best = { path: entry.path, storagePath, - lbugPath: path.join(indexDir, LBUG_DIRECTORY), + lbugPath: branchIsIndexed + ? path.join(indexDir, LBUG_DIRECTORY) + : resolveGraphPath(storagePath, ownershipMetadata), metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata, }; } diff --git a/gitnexus/hooks/claude/registry-query.cjs b/gitnexus/hooks/claude/registry-query.cjs index 649b363fe..d3ac72fd7 100644 --- a/gitnexus/hooks/claude/registry-query.cjs +++ b/gitnexus/hooks/claude/registry-query.cjs @@ -298,6 +298,34 @@ function resolveEntryStoragePath(entry) { return path.resolve(path.join(entry.path, GITNEXUS_DIR)); } +function isDirectChild(parent, child) { + const rel = path.relative(parent, child); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(path.sep); +} + +// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a +// shared-store checkout slot may read a commit graph in the same store +// instead of owning /lbug. Any other recorded value is ignored. +function resolveGraphPath(storagePath, metadata) { + const own = path.join(storagePath, LBUG_DIRECTORY); + const storesRoot = path.resolve( + process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'), + 'stores', + ); + const slot = path.resolve(storagePath); + const checkoutsDir = path.dirname(slot); + const root = path.dirname(checkoutsDir); + if (path.basename(checkoutsDir) !== 'checkouts') return own; + if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own; + const recorded = metadata && metadata.graphPath; + if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; + const graph = path.resolve(recorded); + const valid = + path.basename(graph) === LBUG_DIRECTORY && + isDirectChild(path.join(root, 'commits'), path.dirname(graph)); + return valid ? graph : own; +} + function hasLocalIndexSignal(storagePath) { try { return ( @@ -387,7 +415,9 @@ function findRegisteredRepo(cwd) { best = { path: entry.path, storagePath, - lbugPath: path.join(indexDir, LBUG_DIRECTORY), + lbugPath: branchIsIndexed + ? path.join(indexDir, LBUG_DIRECTORY) + : resolveGraphPath(storagePath, ownershipMetadata), metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata, }; } diff --git a/gitnexus/src/cli/embeddings-sync.ts b/gitnexus/src/cli/embeddings-sync.ts index ff00eeb57..9848ef7c4 100644 --- a/gitnexus/src/cli/embeddings-sync.ts +++ b/gitnexus/src/cli/embeddings-sync.ts @@ -1,4 +1,6 @@ import { lstat } from 'node:fs/promises'; +import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js'; +import { LBUG_DIRECTORY } from '../storage/storage-constants.js'; import path from 'node:path'; import { cliInfo } from './cli-message.js'; import { getGitRoot } from '../storage/git.js'; @@ -43,14 +45,20 @@ export const embeddingsSyncCommand = async (inputPath?: string): Promise = const repoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd()); if (!repoPath) throw new Error('Not inside a git repository. Pass a repository path.'); - const { lbugPath, metaPath } = getStoragePaths(repoPath); + const { metaPath } = getStoragePaths(repoPath); const metaDir = path.dirname(metaPath); + // Writes go to the slot's own graph. A shared-store checkout that reads an + // immutable commit graph (#3352) takes a private copy first. + const lbugPath = path.join(metaDir, LBUG_DIRECTORY); const lock = await acquireIndexLock(metaDir); try { requireExclusiveIndexLock( lock, `Cannot acquire the index lock at ${metaDir}; refusing an unlocked embeddings sync.`, ); + if (!(await ensurePrivateSharedGraph(metaDir, (m) => console.log(` ${m}`)))) { + throw new Error('The shared graph this checkout reads is gone. Run gitnexus analyze first.'); + } const meta = await loadMeta(metaDir); if (!meta) throw new Error(`No GitNexus index found for ${repoPath}. Run gitnexus analyze first.`); diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index 23e563819..f72223b5c 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -14,6 +14,7 @@ * - Graceful failure: any error → return empty string */ +import { resolveGraphPath } from '../../storage/shared-store.js'; import path from 'path'; import { listRegisteredRepos } from '../../storage/repo-manager.js'; import { @@ -84,7 +85,10 @@ async function findRepoForCwd(cwd: string): Promise<{ return { name: bestMatch.name, storagePath, - lbugPath: path.join(indexDir, LBUG_DIRECTORY), + lbugPath: + indexDir === storagePath + ? resolveGraphPath(storagePath) + : path.join(indexDir, LBUG_DIRECTORY), }; } catch { return null; diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index ad3708168..73b3732c5 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises'; +import { resolveGraphPath } from '../../storage/shared-store.js'; import path from 'node:path'; import { Buffer } from 'node:buffer'; import { @@ -21,7 +22,6 @@ import { STATUS_STORAGE_REQUIREMENTS, } from '../../storage/storage-resolver.js'; import { loadMeta } from '../../storage/repo-meta.js'; -import { LBUG_DIRECTORY } from '../../storage/storage-constants.js'; import type { GroupConfig, RepoHandle, @@ -381,7 +381,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } const poolId = handle.id; - lbugPath = path.join(handle.storagePath, LBUG_DIRECTORY); + lbugPath = resolveGraphPath(handle.storagePath); await initLbug(poolId, lbugPath); // No pin here: contract extraction below uses `executor` while this // repo is freshly initialized and live, and completes before the next diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 31720337f..3280abc8e 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -6,6 +6,7 @@ * LadybugDB connections are opened lazily per repo on first query. */ +import { resolveGraphPath } from '../../storage/shared-store.js'; import fs from 'fs/promises'; import path from 'path'; import { createHash } from 'crypto'; @@ -1902,7 +1903,7 @@ export class LocalBackend { const id = this.assignRepoId(entry.name, entry.path, resolved, assigned); const storagePath = entry.storagePath; - const lbugPath = path.join(storagePath, 'lbug'); + const lbugPath = resolveGraphPath(storagePath); const handle: RepoHandle = { id, diff --git a/gitnexus/src/server/analyze-launch.ts b/gitnexus/src/server/analyze-launch.ts index 90761baaa..ffa46be8d 100644 --- a/gitnexus/src/server/analyze-launch.ts +++ b/gitnexus/src/server/analyze-launch.ts @@ -10,6 +10,7 @@ * path is resolved relative to `import.meta.url`. */ +import { resolveGraphPath, storeRootOfCheckoutSlot } from '../storage/shared-store.js'; import path from 'path'; import { existsSync, statSync } from 'node:fs'; import { fork } from 'child_process'; @@ -141,23 +142,33 @@ const waitForSettledIndex = async ( return false; } - const lbugPath = path.resolve(probeRoot, LBUG_DIRECTORY); - const lbugRel = path.relative(storageRoot, lbugPath); - if (lbugRel.startsWith('..') || path.isAbsolute(lbugRel)) { - return false; - } - const lbugStat = statSync(lbugPath); - const metaPath = path.resolve(probeRoot, INDEX_METADATA_FILE); const metaRel = path.relative(storageRoot, metaPath); if (metaRel.startsWith('..') || path.isAbsolute(metaRel)) { return false; } const metaStat = statSync(metaPath); + if (metaStat.mtimeMs < jobStartMs) return false; - if (lbugStat.mtimeMs < jobStartMs || metaStat.mtimeMs < jobStartMs) { + // A shared-store checkout slot (#3352) may point at an immutable commit + // graph, published consolidated and never rewritten, instead of owning a + // graph file. Fresh metadata naming an existing commit graph is settled. + const storeRoot = probeRoot === storageRoot ? storeRootOfCheckoutSlot(storageRoot) : null; + if (storeRoot) { + const graph = path.resolve(resolveGraphPath(storageRoot)); + const graphRel = path.relative(path.join(storeRoot, 'commits'), graph); + if (!graphRel.startsWith('..') && !path.isAbsolute(graphRel) && graphRel !== '') { + return existsSync(graph); + } + } + + const lbugPath = path.resolve(probeRoot, LBUG_DIRECTORY); + const lbugRel = path.relative(storageRoot, lbugPath); + if (lbugRel.startsWith('..') || path.isAbsolute(lbugRel)) { return false; } + const lbugStat = statSync(lbugPath); + if (lbugStat.mtimeMs < jobStartMs) return false; return ['lbug.wal', 'lbug.shadow', 'lbug.wal.checkpoint'].every((name) => { const sidePath = path.resolve(probeRoot, name); diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 70c23fa80..79b3077a0 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -8,6 +8,8 @@ * CORS is restricted to localhost, private/LAN networks, and the deployed site. */ +import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js'; +import { resolveGraphPath } from '../storage/shared-store.js'; import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js'; import express from 'express'; import cors from 'cors'; @@ -918,7 +920,7 @@ export const handleQueryRequest = async ( return; } if (respondIfAnalysisPending(entry, res)) return; - const lbugPath = path.join(entry.storagePath, 'lbug'); + const lbugPath = resolveGraphPath(entry.storagePath); const { skipFts } = await loadFtsSession(entry.storagePath); const result = await withLbugDb( lbugPath, @@ -1382,7 +1384,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => return; } if (respondIfAnalysisPending(entry, res)) return; - const lbugPath = path.join(entry.storagePath, 'lbug'); + const lbugPath = resolveGraphPath(entry.storagePath); const includeContent = req.query.includeContent === 'true'; const stream = req.query.stream === 'true'; const { skipFts } = await loadFtsSession(entry.storagePath); @@ -1475,7 +1477,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => return; } if (respondIfAnalysisPending(entry, res)) return; - const lbugPath = path.join(entry.storagePath, 'lbug'); + const lbugPath = resolveGraphPath(entry.storagePath); const parsedLimit = Number(req.body.limit ?? 10); const { ftsDisabledReason, skipFts } = await loadFtsSession(entry.storagePath); const limit = Number.isFinite(parsedLimit) @@ -1682,7 +1684,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => const repoRoot = path.resolve(entry.path); const { skipFts } = await loadFtsSession(entry.storagePath); - const lbugPath = path.join(entry.storagePath, 'lbug'); + const lbugPath = resolveGraphPath(entry.storagePath); const fileRows = await withLbugDb( lbugPath, () => @@ -2128,6 +2130,11 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => let partialRunError: string | undefined; let partialRunDetail: AnalyzeJobPartialOutcome | undefined; try { + // Writes go to the slot's own graph; a shared-store checkout + // reading an immutable commit graph (#3352) takes a private copy. + if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) { + throw new Error('The shared graph this repository reads is gone. Re-run analyze.'); + } const lbugPath = path.join(storagePath, LBUG_DIRECTORY); const ftsSession = await loadFtsSession(storagePath); let embeddingMeta = ftsSession.meta; diff --git a/gitnexus/test/integration/shared-store-analyze.test.ts b/gitnexus/test/integration/shared-store-analyze.test.ts index 844505bf4..d959f6131 100644 --- a/gitnexus/test/integration/shared-store-analyze.test.ts +++ b/gitnexus/test/integration/shared-store-analyze.test.ts @@ -121,6 +121,37 @@ describe('shared sibling store analyze (#3352)', () => { expect(validated.map((e) => e.path)).toEqual(expect.arrayContaining([wtA, wtB])); }, 180_000); + it('Covers AE1: MCP opens one database for three checkouts on one commit graph', async () => { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + for (const checkout of [main, wtA, wtB]) { + await runFullAnalysis(checkout, {}, { onProgress: () => {} }); + } + const graphs = [main, wtA, wtB].map( + (c) => getStoragePaths(c, undefined, layoutOf(c).checkoutSlot).lbugPath, + ); + expect(new Set(graphs).size).toBe(1); + + const { initLbug, closeLbug } = await import('../../src/core/lbug/pool-adapter.js'); + const savedTrace = process.env.GITNEXUS_POOL_RSS_TRACE; + process.env.GITNEXUS_POOL_RSS_TRACE = '1'; + const traces: string[] = []; + const write = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => { + if (String(chunk).startsWith('[pool-rss]')) traces.push(String(chunk)); + return (write as (...a: unknown[]) => boolean)(chunk, ...rest); + }) as typeof process.stderr.write; + try { + for (const [i, graph] of graphs.entries()) await initLbug(`shared-${i}`, graph); + } finally { + process.stderr.write = write; + if (savedTrace === undefined) delete process.env.GITNEXUS_POOL_RSS_TRACE; + else process.env.GITNEXUS_POOL_RSS_TRACE = savedTrace; + await closeLbug(); + } + const last = traces.filter((t) => t.includes(' init ')).pop(); + expect(last).toMatch(/pool=3 dbCache=1 /); + }, 240_000); + it('serves a sibling the same relative file paths from the shared graph', async () => { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(wtA, {}, { onProgress: () => {} }); diff --git a/gitnexus/test/unit/hooks-shared-store.test.ts b/gitnexus/test/unit/hooks-shared-store.test.ts new file mode 100644 index 000000000..609c19efe --- /dev/null +++ b/gitnexus/test/unit/hooks-shared-store.test.ts @@ -0,0 +1,108 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createRequire } from 'module'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +/** + * #3352 — the Claude hook resolves a shared-store checkout to the commit + * graph it reads, mirroring `resolveGraphPath` in src/storage/shared-store.ts. + */ +const HOOK_COPIES = [ + path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'registry-query.cjs'), + path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-claude-plugin', + 'hooks', + 'registry-query.cjs', + ), + path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'registry-query.cjs', + ), +]; + +type HookRepo = { storagePath: string; lbugPath: string } | null; +const load = (file: string) => + createRequire(import.meta.url)(file) as { findRegisteredRepo: (cwd: string) => HookRepo }; + +describe('registry-query shared store graph (#3352)', () => { + let tmp: string; + let home: string; + let checkout: string; + let slot: string; + let commitGraph: string; + const savedHome = process.env.GITNEXUS_HOME; + + const writeSlot = (meta: Record) => { + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync( + path.join(slot, 'gitnexus.json'), + JSON.stringify({ repoPath: checkout, storagePath: slot, lastCommit: 'abc', ...meta }), + ); + }; + + beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gn-hook-shared-'))); + home = path.join(tmp, 'home'); + checkout = path.join(tmp, 'wt'); + fs.mkdirSync(checkout, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: checkout, stdio: 'ignore' }); + const store = path.join(home, 'stores', 'repo-0123456789ab'); + slot = path.join(store, 'checkouts', 'wt-0123456789ab'); + commitGraph = path.join(store, 'commits', 'abc1234-deadbeefdeadbeef', 'lbug'); + fs.mkdirSync(path.dirname(commitGraph), { recursive: true }); + fs.writeFileSync(commitGraph, 'graph'); + fs.mkdirSync(home, { recursive: true }); + fs.writeFileSync( + path.join(home, 'registry.json'), + JSON.stringify([ + { name: 'wt', path: checkout, storagePath: slot, indexedAt: '', lastCommit: '' }, + ]), + ); + process.env.GITNEXUS_HOME = home; + }); + + afterEach(() => { + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('keeps the three hook copies byte-identical', () => { + const [primary, ...copies] = HOOK_COPIES.map((f) => fs.readFileSync(f, 'utf-8')); + for (const copy of copies) expect(copy).toBe(primary); + }); + + it.each(HOOK_COPIES)('returns the commit graph a shared slot records (%s)', (file) => { + writeSlot({ graphPath: commitGraph }); + expect(load(file).findRegisteredRepo(checkout)?.lbugPath).toBe(commitGraph); + }); + + it('returns the slot graph when none is recorded', () => { + writeSlot({}); + expect(load(HOOK_COPIES[0]).findRegisteredRepo(checkout)?.lbugPath).toBe( + path.join(slot, 'lbug'), + ); + }); + + it.each([ + ['outside the store', () => '/etc/lbug'], + ['a sibling slot', () => path.join(path.dirname(slot), 'other-000000000000', 'lbug')], + ['a relative path', () => 'commits/abc1234-deadbeefdeadbeef/lbug'], + ])('ignores a recorded graphPath %s', (_label, graphPath) => { + writeSlot({ graphPath: graphPath() }); + expect(load(HOOK_COPIES[0]).findRegisteredRepo(checkout)?.lbugPath).toBe( + path.join(slot, 'lbug'), + ); + }); +});