feat(mcp): read the graph a shared checkout points at (#3352)

MCP, the HTTP API, group sync, augmentation, and the Claude hook (all
three byte-identical copies) resolve a flat slot's graph through
resolveGraphPath instead of joining 'lbug' onto the storage path, so
checkouts at one commit share one open database. The embeddings writers
(embeddings sync and the server embed job) take a private copy first
and never write an immutable commit graph. The post-analyze settle
probe accepts fresh metadata that points at an existing commit graph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-24 10:28:28 +00:00
parent c86cca40b6
commit 1130ca9537
11 changed files with 280 additions and 20 deletions

View file

@ -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 <slot>/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,
};
}

View file

@ -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 <slot>/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,
};
}

View file

@ -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 <slot>/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,
};
}

View file

@ -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<void> =
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.`);

View file

@ -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;

View file

@ -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

View file

@ -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,

View file

@ -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);

View file

@ -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;

View file

@ -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: () => {} });

View file

@ -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<string, unknown>) => {
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'),
);
});
});