diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index f0c20e737..902872b73 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -162,8 +162,16 @@ import { import { ANALYZE_FORCE_STORAGE_REQUIREMENTS, ANALYZE_STORAGE_REQUIREMENTS, + requireRegisteredStoragePath, requireStoragePath, } from '../storage/storage-resolver.js'; +import { resolveSharedStore, type SharedStoreLayout } from '../storage/shared-store.js'; +import { LBUG_DIRECTORY } from '../storage/storage-constants.js'; +import { + ensurePrivateSharedGraph, + publishSharedGraph, + seedSharedSlot, +} from './shared-store-analyze.js'; import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js'; import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, @@ -1085,6 +1093,8 @@ interface WriteTarget { lbugPath: string; metaPath: string; metaDir: string; + /** Set when this checkout writes into a shared sibling store (#3352). */ + sharedStore?: SharedStoreLayout; } /** @@ -1101,10 +1111,19 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr // a cached path string must not skip ownership (STORAGE_PATH can move to a // foreign slot while the lock is waited out). `--force` may adopt a // repository-local foreign slot; the non-force set stays ANALYZE_STORAGE. - const storagePath = await requireStoragePath( - repoPath, - options.force ? ANALYZE_FORCE_STORAGE_REQUIREMENTS : ANALYZE_STORAGE_REQUIREMENTS, - ); + // A linked-worktree checkout writes its own slot in the shared store + // (#3352); that slot replaces any repository-local `.gitnexus`, which is left + // untouched. + const storageRequirements = options.force + ? ANALYZE_FORCE_STORAGE_REQUIREMENTS + : ANALYZE_STORAGE_REQUIREMENTS; + const sharedStore = resolveSharedStore(repoPath) ?? undefined; + const storagePath = sharedStore + ? await requireRegisteredStoragePath( + { path: repoPath, storagePath: sharedStore.checkoutSlot }, + storageRequirements, + ) + : await requireStoragePath(repoPath, storageRequirements); const repoHasGit = hasGitDir(repoPath); const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : ''; // Normalize the auto-detected branch the same way an explicit `--branch` is @@ -1131,7 +1150,11 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr const placement = options.branch ? await resolveBranchPlacement(repoPath, branchLabel, storagePath) : {}; - const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch, storagePath); + const paths = getStoragePaths(repoPath, placement.branch, storagePath); + const { metaPath } = paths; + // Analyze always writes a shared slot's own graph; a recorded `graphPath` + // only redirects readers. + const lbugPath = sharedStore ? path.join(path.dirname(metaPath), LBUG_DIRECTORY) : paths.lbugPath; return { storagePath, repoHasGit, @@ -1143,6 +1166,7 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr lbugPath, metaPath, metaDir: path.dirname(metaPath), + sharedStore, }; } @@ -1253,7 +1277,9 @@ export async function runFullAnalysis( `Warning: checkout "${formatRejectedBranchForLog(writeTarget.rejectedDetectedBranch)}" is not a usable index label; continuing.`, ); } - return await runFullAnalysisInner( + const flatShared = writeTarget.placement.branch ? undefined : writeTarget.sharedStore; + if (flatShared) await seedSharedSlot(flatShared, repoPath, log); + const result = await runFullAnalysisInner( repoPath, options, callbacks, @@ -1261,6 +1287,10 @@ export async function runFullAnalysis( contentRetention, runnerIdentityAtBootstrap, ); + if (flatShared) { + await publishSharedGraph(flatShared, repoPath, writeTarget.currentCommit, log); + } + return result; } finally { discardScopedEmbeddingSpills(); lock.release(); @@ -1326,7 +1356,16 @@ async function runFullAnalysisInner( log(`Metadata reconciliation failed (non-critical${code ? `, ${code}` : ''}); continuing.`); } + // Shared-store pointer slots (#3352) get a private graph just before the + // first graph open: here for the paths that open it before the up-to-date + // check, and below once that check falls through. + const ensurePrivateGraph = async (): Promise => { + if (writeTarget.sharedStore && !placement.branch) { + await ensurePrivateSharedGraph(metaDir, log); + } + }; const loadedMeta = await loadMeta(metaDir); + if (loadedMeta?.incrementalInProgress || options.repairFts) await ensurePrivateGraph(); if (options.preserveExistingPdg && options.pdg === undefined) { if (loadedMeta) { options = { ...options, pdg: loadedMeta.pdg !== undefined }; @@ -2280,6 +2319,7 @@ async function runFullAnalysisInner( } await ensureWritableStorage(); + await ensurePrivateGraph(); // ── Cache embeddings from existing index before rebuild ──────────── // Four modes: diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts new file mode 100644 index 000000000..0d39868b2 --- /dev/null +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -0,0 +1,259 @@ +/** + * Analyze-side operations for the shared sibling store (#3352). + * + * A shared checkout slot is in one of two states: + * - pointer: metadata records `graphPath` (a commit graph in the store); + * the slot has no graph of its own. + * - private: the slot owns `/lbug`; no `graphPath`. + * + * Analyze always writes the private path. `ensurePrivateSharedGraph` turns a + * pointer into a private copy just before the first graph open/write, so a + * clean checkout that hits the up-to-date fast path copies nothing. + * `publishSharedGraph` runs after a successful analyze: a clean checkout at + * HEAD moves its private graph into `commits/` (or drops it when that commit + * graph already exists) and becomes a pointer again. Commit graphs are never + * written after publish. + */ + +import { createHash, randomUUID } from 'crypto'; +import { constants as fsConstants } from 'fs'; +import fs from 'fs/promises'; +import path from 'path'; +import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; +import { commitDistanceToHead, isWorkingTreeDirty } from '../storage/git.js'; +import { registerRepo, saveMeta } from '../storage/repo-manager.js'; +import { loadMeta, type RepoMeta } from '../storage/repo-meta.js'; +import { + commitGraphDir, + resolveGraphPath, + type SharedStoreLayout, +} from '../storage/shared-store.js'; +import { INDEX_METADATA_FILE, LBUG_DIRECTORY } from '../storage/storage-constants.js'; +import { wipeLbugDbFiles } from './lbug/lbug-adapter.js'; +import { inspectLbugSidecars } from './lbug/sidecar-recovery.js'; + +type Log = (msg: string) => void; + +/** + * Fields that differ between checkouts or runs of the same content and + * settings. Everything else in the metadata — schema fingerprint, analysis + * features, capabilities, retention, runner identity, PDG and process + * settings — must match for two checkouts to share a graph. A denylist fails + * safe: an unexpected per-run field only prevents sharing, never mixes graphs. + */ +const FEATURE_KEY_EXCLUDED = new Set([ + 'repoPath', + 'storagePath', + 'graphPath', + 'lastCommit', + 'indexedAt', + 'branch', + 'remoteUrl', + 'fileHashes', + 'cacheKeys', + 'incrementalInProgress', + 'embeddingCheckpoint', + 'stats', +]); + +/** Fields that describe one checkout; stripped from a published commit graph's metadata. */ +const CHECKOUT_FIELDS = ['repoPath', 'storagePath', 'graphPath', 'branch', 'incrementalInProgress']; + +const stableStringify = (value: unknown): string => { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`; + } + return JSON.stringify(value); +}; + +/** Hash of every graph-affecting metadata field (see FEATURE_KEY_EXCLUDED). */ +export const featureKeyOf = (meta: RepoMeta): string => { + const kept: Record = {}; + for (const [k, v] of Object.entries(meta)) { + if (!FEATURE_KEY_EXCLUDED.has(k)) kept[k] = v; + } + // Embedding presence changes graph content but lives only in `stats`. + kept.hasEmbeddings = (meta.stats?.embeddings ?? 0) > 0; + return createHash('sha256').update(stableStringify(kept)).digest('hex').slice(0, 16); +}; + +const exists = (p: string): Promise => + fs.access(p).then( + () => true, + () => false, + ); + +interface CommitGraph { + dir: string; + commit: string; + meta: RepoMeta; +} + +const listCommitGraphs = async (layout: SharedStoreLayout): Promise => { + let names: string[]; + try { + names = await fs.readdir(layout.commitsDir); + } catch { + return []; + } + const graphs: CommitGraph[] = []; + for (const name of names) { + const match = /^([0-9a-f]{7,64})-([0-9a-f]{8,64})$/.exec(name); + if (!match) continue; + const dir = path.join(layout.commitsDir, name); + const meta = await loadMeta(dir); + if (!meta || !(await exists(path.join(dir, LBUG_DIRECTORY)))) continue; + graphs.push({ dir, commit: match[1], meta }); + } + return graphs; +}; + +/** + * Pick the commit graph to seed a new checkout slot from: the one at HEAD, + * else the ancestor with the fewest commits to HEAD. Ties go to the most + * recently indexed graph. + * ponytail: one `git` call pair per commit graph; fine for tens of graphs, + * batch through `git rev-list` if stores grow to hundreds. + */ +const pickSeed = (repoPath: string, graphs: CommitGraph[]): CommitGraph | null => { + let best: { graph: CommitGraph; distance: number } | null = null; + for (const graph of graphs) { + const distance = commitDistanceToHead(repoPath, graph.commit); + if (distance === null) continue; + const better = + !best || + distance < best.distance || + (distance === best.distance && graph.meta.indexedAt > best.graph.meta.indexedAt); + if (better) best = { graph, distance }; + } + return best?.graph ?? null; +}; + +/** + * Give a slot with no metadata a pointer to the best commit graph, so the + * run that follows is up to date (same commit, clean) or incremental from + * that graph's file hashes. Caller holds the slot's index lock. + */ +export const seedSharedSlot = async ( + layout: SharedStoreLayout, + repoPath: string, + log: Log, +): Promise => { + if (await loadMeta(layout.checkoutSlot)) return; + const seed = pickSeed(repoPath, await listCommitGraphs(layout)); + if (!seed) return; + await fs.mkdir(layout.checkoutSlot, { recursive: true }); + const meta: RepoMeta = { + ...seed.meta, + repoPath, + storagePath: layout.checkoutSlot, + graphPath: path.join(seed.dir, LBUG_DIRECTORY), + }; + delete meta.incrementalInProgress; + await saveMeta(layout.checkoutSlot, meta); + log(`Shared store: seeded from commit graph ${seed.commit.slice(0, 12)}.`); +}; + +/** + * Turn a pointer slot into a private one before analyze opens or writes the + * graph. No-op for a slot that already owns its graph or has no graph at all. + * Caller holds the slot's index lock. + */ +export const ensurePrivateSharedGraph = async (slot: string, log: Log): Promise => { + const own = path.join(slot, LBUG_DIRECTORY); + const pointed = resolveGraphPath(slot); + if (pointed === own) return; + const meta = await loadMeta(slot); + if (!meta) return; + if (!(await exists(own))) { + // `lbug.new.` is swept by the slot lock if this process dies mid-copy. + const tmp = `${own}.new.${randomUUID()}`; + const started = Date.now(); + await fs.copyFile(pointed, tmp, fsConstants.COPYFILE_FICLONE); + await fs.rename(tmp, own); + log(`Shared store: copied the shared graph for local changes in ${Date.now() - started}ms.`); + } + delete meta.graphPath; + await saveMeta(slot, meta); +}; + +const withPublishLock = async (layout: SharedStoreLayout, fn: () => Promise): Promise => { + const lockDir = path.join(layout.root, 'locks', 'publish'); + await fs.mkdir(lockDir, { recursive: true }); + const lock = await acquireIndexLock(lockDir); + try { + requireExclusiveIndexLock(lock, `Cannot acquire the shared-store publish lock at ${lockDir}.`); + return await fn(); + } finally { + lock.release(); + } +}; + +/** + * After a successful analyze of the flat slot: publish or reuse the commit + * graph when the checkout is clean at HEAD, and make sure the registry points + * at the slot. Caller holds the slot's index lock. + */ +export const publishSharedGraph = async ( + layout: SharedStoreLayout, + repoPath: string, + currentCommit: string, + log: Log, +): Promise => { + const slot = layout.checkoutSlot; + const meta = await loadMeta(slot); + if (!meta) return; + const own = path.join(slot, LBUG_DIRECTORY); + + const shareable = + currentCommit !== '' && + meta.lastCommit === currentCommit && + !meta.incrementalInProgress && + !isWorkingTreeDirty(repoPath); + if (shareable) { + const target = commitGraphDir(layout, currentCommit, featureKeyOf(meta)); + const targetGraph = path.join(target, LBUG_DIRECTORY); + const published = await withPublishLock(layout, async () => { + if (await exists(targetGraph)) { + await wipeLbugDbFiles(own); + return true; + } + if (!(await exists(own)) || (await inspectLbugSidecars(own)).kind !== 'clean') return false; + await fs.mkdir(layout.commitsDir, { recursive: true }); + const staging = path.join(layout.commitsDir, `.publish-${randomUUID()}`); + await fs.mkdir(staging); + const commitMeta: Record = { ...meta }; + for (const field of CHECKOUT_FIELDS) delete commitMeta[field]; + try { + await fs.rename(own, path.join(staging, LBUG_DIRECTORY)); + await fs.writeFile(path.join(staging, INDEX_METADATA_FILE), JSON.stringify(commitMeta)); + await fs.rename(staging, target); + } catch (err) { + // Put the graph back so the slot stays usable as a private index. + await fs.rename(path.join(staging, LBUG_DIRECTORY), own).catch(() => {}); + await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); + log( + `Shared store: could not publish (${(err as Error).message}); keeping a private graph.`, + ); + return false; + } + log(`Shared store: published commit graph ${currentCommit.slice(0, 12)}.`); + return true; + }); + if (published) { + meta.graphPath = targetGraph; + await saveMeta(slot, meta); + } + } else if (meta.graphPath !== undefined && (await exists(own))) { + delete meta.graphPath; + await saveMeta(slot, meta); + } + + // The up-to-date fast path skips registration; a seeded or adopted checkout + // must still end up registered at its slot. + await registerRepo(repoPath, meta, { storagePath: slot }); +}; diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 80c3fc883..baecaddfb 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -251,6 +251,31 @@ export const isGitRepo = (repoPath: string): boolean => { } }; +/** + * Number of commits from `ancestor` to HEAD, or null when `ancestor` is not + * an ancestor of HEAD (or git fails). 0 means `ancestor` is HEAD. + */ +export const commitDistanceToHead = (repoPath: string, ancestor: string): number | null => { + try { + execFileSync('git', ['merge-base', '--is-ancestor', ancestor, 'HEAD'], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + const count = Number( + execFileSync('git', ['rev-list', '--count', `${ancestor}..HEAD`], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + encoding: 'utf8', + }).trim(), + ); + return Number.isInteger(count) ? count : null; + } catch { + return null; + } +}; + export const getCurrentCommit = (repoPath: string): string => { try { return execSync('git rev-parse HEAD', { diff --git a/gitnexus/test/integration/shared-store-analyze.test.ts b/gitnexus/test/integration/shared-store-analyze.test.ts new file mode 100644 index 000000000..862e667c7 --- /dev/null +++ b/gitnexus/test/integration/shared-store-analyze.test.ts @@ -0,0 +1,266 @@ +import { execFileSync } from 'child_process'; +import { existsSync } from 'fs'; +import fs from 'fs/promises'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { featureKeyOf, publishSharedGraph } from '../../src/core/shared-store-analyze.js'; +import { + getStoragePaths, + listRegisteredRepos, + loadMeta, + saveMeta, +} from '../../src/storage/repo-manager.js'; +import type { RepoMeta } from '../../src/storage/repo-meta.js'; +import { + commitGraphDir, + resolveSharedStore, + type SharedStoreLayout, +} from '../../src/storage/shared-store.js'; +import { createTempDir } from '../helpers/test-db.js'; + +/** + * #3352 — linked worktrees at one commit share one immutable commit graph in + * the store under GITNEXUS_HOME, and a second worktree's analyze reuses it + * without writing a graph. + */ +const git = (cwd: string, ...args: string[]): string => + execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim(); + +const commitAll = (cwd: string, message: string): void => { + git(cwd, 'add', '-A'); + git(cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', message); +}; + +const layoutOf = (checkout: string): SharedStoreLayout => { + const layout = resolveSharedStore(checkout); + expect(layout).not.toBeNull(); + return layout as SharedStoreLayout; +}; + +const listCommitDirs = async (layout: SharedStoreLayout): Promise => + (await fs.readdir(layout.commitsDir).catch(() => [] as string[])).filter( + (name) => !name.startsWith('.'), + ); + +describe('shared sibling store analyze (#3352)', () => { + let tmpHome: Awaited>; + let tmpRepo: Awaited>; + let savedHome: string | undefined; + let main: string; + let wtA: string; + let wtB: string; + + beforeEach(async () => { + tmpHome = await createTempDir('gitnexus-shared-home-'); + tmpRepo = await createTempDir('gitnexus-shared-repo-'); + savedHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = tmpHome.dbPath; + + const root = await fs.realpath(tmpRepo.dbPath); + main = path.join(root, 'main'); + wtA = path.join(root, 'wt-a'); + wtB = path.join(root, 'wt-b'); + await fs.mkdir(main); + git(main, 'init', '-q', '-b', 'main'); + await fs.writeFile( + path.join(main, 'a.ts'), + 'export function a() { return b(); }\nexport function b() { return 1; }\n', + ); + commitAll(main, 'init'); + git(main, 'worktree', 'add', '-q', '-b', 'wt-a', wtA); + git(main, 'worktree', 'add', '-q', '-b', 'wt-b', wtB); + }); + + afterEach(async () => { + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + await tmpRepo.cleanup(); + await tmpHome.cleanup(); + }); + + it('Covers AE1: three clean worktrees at one commit share one commit graph', async () => { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const results = []; + for (const checkout of [main, wtA, wtB]) { + results.push(await runFullAnalysis(checkout, {}, { onProgress: () => {} })); + } + + const layout = layoutOf(main); + const commitDirs = await listCommitDirs(layout); + expect(commitDirs).toHaveLength(1); + const graph = path.join(layout.commitsDir, commitDirs[0], 'lbug'); + + for (const checkout of [main, wtA, wtB]) { + const slot = layoutOf(checkout).checkoutSlot; + expect(getStoragePaths(checkout, undefined, slot).lbugPath).toBe(graph); + expect(existsSync(path.join(slot, 'lbug'))).toBe(false); + // The pre-existing repository-local index location is never written. + expect(existsSync(path.join(checkout, '.gitnexus', 'lbug'))).toBe(false); + } + // Siblings after the first reuse the published graph without a pipeline run. + expect(results.map((r) => r.alreadyUpToDate === true)).toEqual([false, true, true]); + + const registered = await listRegisteredRepos(); + for (const checkout of [main, wtA, wtB]) { + const entry = registered.find((e) => e.path === checkout); + expect(entry?.storagePath).toBe(layoutOf(checkout).checkoutSlot); + } + }, 180_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: () => {} }); + await runFullAnalysis(wtB, {}, { onProgress: () => {} }); + + const lbug = (await import('@ladybugdb/core')).default; + const graph = getStoragePaths(wtB, undefined, layoutOf(wtB).checkoutSlot).lbugPath; + const db = new lbug.Database(graph, 0, true, true); + const conn = new lbug.Connection(db); + const rows = (await ( + await conn.query('MATCH (f:File) RETURN f.filePath AS p ORDER BY p') + ).getAll()) as { p: string }[]; + await conn.close(); + await db.close(); + expect(rows.map((r) => r.p)).toEqual(['a.ts']); + }, 180_000); + + it('publishes a new commit graph when a clean worktree moves to a new commit', async () => { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(wtA, {}, { onProgress: () => {} }); + await fs.writeFile(path.join(wtA, 'c.ts'), 'export const c = 3;\n'); + commitAll(wtA, 'c'); + await runFullAnalysis(wtA, {}, { onProgress: () => {} }); + + const layout = layoutOf(wtA); + const head = git(wtA, 'rev-parse', 'HEAD'); + const meta = await loadMeta(layout.checkoutSlot); + expect(meta?.lastCommit).toBe(head); + expect(meta?.graphPath).toBe( + path.join(commitGraphDir(layout, head, featureKeyOf(meta as RepoMeta)), 'lbug'), + ); + expect(await listCommitDirs(layout)).toHaveLength(2); + }, 180_000); +}); + +describe('featureKeyOf', () => { + const base: RepoMeta = { + repoPath: '/a', + storagePath: '/a/.gitnexus', + lastCommit: 'abc1234', + indexedAt: '2026-01-01T00:00:00.000Z', + schemaFingerprint: 'fp1', + analysisFeatures: { x: 1 }, + }; + + it('ignores per-checkout and per-run fields', () => { + expect( + featureKeyOf({ + ...base, + repoPath: '/b', + storagePath: '/b/.gitnexus', + indexedAt: '2027-01-01T00:00:00.000Z', + lastCommit: 'def5678', + branch: 'feature', + fileHashes: { 'a.ts': 'h' }, + stats: { nodes: 9 }, + }), + ).toBe(featureKeyOf(base)); + }); + + it('is independent of key order', () => { + const reordered = Object.fromEntries(Object.entries(base).reverse()) as RepoMeta; + expect(featureKeyOf(reordered)).toBe(featureKeyOf(base)); + }); + + it.each([ + ['schema fingerprint', { schemaFingerprint: 'fp2' }], + ['analysis features', { analysisFeatures: { x: 2 } }], + ['PDG layer', { pdg: {} as RepoMeta['pdg'] }], + ['content retention', { contentRetention: 'none' as const }], + ['embeddings present', { stats: { embeddings: 3 } }], + ])('changes with %s', (_label, delta) => { + expect(featureKeyOf({ ...base, ...delta })).not.toBe(featureKeyOf(base)); + }); +}); + +describe('publishSharedGraph race (#3352)', () => { + let tmpHome: Awaited>; + let tmpRepo: Awaited>; + let savedHome: string | undefined; + + beforeEach(async () => { + tmpHome = await createTempDir('gitnexus-shared-race-home-'); + tmpRepo = await createTempDir('gitnexus-shared-race-repo-'); + savedHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = tmpHome.dbPath; + }); + + afterEach(async () => { + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + await tmpRepo.cleanup(); + await tmpHome.cleanup(); + }); + + const setup = async (): Promise<{ checkouts: string[]; head: string }> => { + const root = await fs.realpath(tmpRepo.dbPath); + const main = path.join(root, 'main'); + await fs.mkdir(main); + git(main, 'init', '-q', '-b', 'main'); + await fs.writeFile(path.join(main, 'a.ts'), 'export const a = 1;\n'); + commitAll(main, 'init'); + const wt = path.join(root, 'wt'); + git(main, 'worktree', 'add', '-q', '-b', 'wt', wt); + const head = git(main, 'rev-parse', 'HEAD'); + for (const checkout of [main, wt]) { + const slot = layoutOf(checkout).checkoutSlot; + await fs.mkdir(slot, { recursive: true }); + await fs.writeFile(path.join(slot, 'lbug'), `graph from ${checkout}`); + await saveMeta(slot, { + repoPath: checkout, + storagePath: slot, + lastCommit: head, + indexedAt: new Date().toISOString(), + }); + } + return { checkouts: [main, wt], head }; + }; + + it('Covers AE6: two checkouts publishing one new commit produce exactly one graph', async () => { + const { checkouts } = await setup(); + await Promise.all( + checkouts.map((c) => + publishSharedGraph(layoutOf(c), c, git(c, 'rev-parse', 'HEAD'), () => {}), + ), + ); + const layout = layoutOf(checkouts[0]); + expect(await listCommitDirs(layout)).toHaveLength(1); + const pointers = await Promise.all( + checkouts.map(async (c) => (await loadMeta(layoutOf(c).checkoutSlot))?.graphPath), + ); + expect(new Set(pointers).size).toBe(1); + for (const c of checkouts) { + expect(existsSync(path.join(layoutOf(c).checkoutSlot, 'lbug'))).toBe(false); + } + }); + + it('keeps a private graph whose sidecars are not consolidated', async () => { + const { checkouts, head } = await setup(); + const [main] = checkouts; + const slot = layoutOf(main).checkoutSlot; + await fs.writeFile(path.join(slot, 'lbug.wal'), 'pending'); + await publishSharedGraph(layoutOf(main), main, head, () => {}); + expect(await listCommitDirs(layoutOf(main))).toEqual([]); + expect(existsSync(path.join(slot, 'lbug'))).toBe(true); + expect((await loadMeta(slot))?.graphPath).toBeUndefined(); + }); + + it('keeps a dirty checkout private', async () => { + const { checkouts, head } = await setup(); + const [main] = checkouts; + await fs.writeFile(path.join(main, 'a.ts'), 'export const a = 2;\n'); + await publishSharedGraph(layoutOf(main), main, head, () => {}); + expect(await listCommitDirs(layoutOf(main))).toEqual([]); + expect(existsSync(path.join(layoutOf(main).checkoutSlot, 'lbug'))).toBe(true); + }); +});