diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 69d9b70d1..89bf912f0 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -6,6 +6,8 @@ * CLAUDE.md is for Claude Code which only reads that file. */ +import { GITNEXUS_DIR } from '../storage/storage-constants.js'; +import { storeRootOfCheckoutSlot } from '../storage/shared-store.js'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -619,7 +621,13 @@ export async function generateAIContextFiles( // CLI and hooks already share; failure to copy is non-fatal (docs carry a // bootstrap fallback). `runnerPath` is project-relative with POSIX separators // so the emitted command is identical across platforms. - const runnerPath = path.relative(repoPath, path.join(storagePath, 'run.cjs')).replace(/\\/g, '/'); + // A shared-store slot (#3352) lives under the GitNexus home, so its path + // would be machine- and worktree-specific in committed docs; the runner goes + // next to the checkout's store pointer instead. + const runnerDir = storeRootOfCheckoutSlot(storagePath) + ? path.join(repoPath, GITNEXUS_DIR) + : storagePath; + const runnerPath = path.relative(repoPath, path.join(runnerDir, 'run.cjs')).replace(/\\/g, '/'); try { const runnerSrc = path.join( __dirname, @@ -629,8 +637,8 @@ export async function generateAIContextFiles( 'claude', 'resolve-analyze-cmd.cjs', ); - await fs.mkdir(storagePath, { recursive: true }); - await fs.copyFile(runnerSrc, path.join(storagePath, 'run.cjs')); + await fs.mkdir(runnerDir, { recursive: true }); + await fs.copyFile(runnerSrc, path.join(runnerDir, 'run.cjs')); } catch (err) { logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`); } diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 826d2f3be..a0a7dee60 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -179,6 +179,7 @@ import { listStoreMetaRoots, leaveSharedStore, optedInSlotToLeave, + registerLeftStore, publishSharedGraph, resolveOptedInStore, seedSharedSlot, @@ -1320,8 +1321,12 @@ export async function runFullAnalysis( ); if (flatShared) { await publishSharedGraph(flatShared, repoPath, writeTarget.currentCommit, log); - } else if (slotToLeave) { - await leaveSharedStore(repoPath, slotToLeave, log); + } else if (!writeTarget.placement.branch) { + // Leaving a store (`--no-share`, or sharing turned off): the up-to-date + // path does not re-register, so point the registry at the new storage + // before the old slot goes away. + await registerLeftStore(repoPath, writeTarget.storagePath); + if (slotToLeave) await leaveSharedStore(repoPath, slotToLeave, log); } return result; } finally { diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts index 4a76d3ad5..bc2302e25 100644 --- a/gitnexus/src/core/shared-store-analyze.ts +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -22,8 +22,11 @@ import path from 'path'; import { acquireIndexLock } from '../storage/index-lock.js'; import { commitDistanceToHead, getRemoteUrl, isWorkingTreeDirty } from '../storage/git.js'; import { + canonicalizePath, + findRegistryEntryByRepoPath, readRegistry, registerRepo, + registryPathEquals, resolveRegistryEntry, saveMeta, } from '../storage/repo-manager.js'; @@ -38,6 +41,7 @@ import { } from '../storage/shared-store.js'; import { reclaimAfterSlotRemoval, + reclaimSharedStoreLocked, removeSharedStorePointer, withStoreLock, writeSharedStorePointer, @@ -200,7 +204,10 @@ const seedFromLocalIndex = async ( } finally { lock.release(); } - const seeded: RepoMeta = { ...meta, repoPath, storagePath: slot }; + // A local index may hold uncommitted edits from when it was built. Clearing + // lastCommit forces the next run through the file-hash diff, which rewrites + // any file whose content differs, instead of trusting the up-to-date path. + const seeded: RepoMeta = { ...meta, repoPath, storagePath: slot, lastCommit: '' }; delete seeded.graphPath; await saveMeta(slot, seeded); log(`Shared store: seeded from the local index at ${source}.`); @@ -222,16 +229,25 @@ export const seedSharedSlot = async ( ): Promise => { if (await loadMeta(layout.checkoutSlot)) return; const seed = pickSeed(repoPath, await listCommitGraphs(layout)); - if (seed) { - 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); + // Record the pointer under the publish lock, where reclaim counts + // references, so the graph cannot be deleted between the pick and the save. + const pointed = + seed && + (await withStoreLock(layout, 'publish', async () => { + const graph = path.join(seed.dir, LBUG_DIRECTORY); + if (!(await exists(graph))) return false; + await fs.mkdir(layout.checkoutSlot, { recursive: true }); + const meta: RepoMeta = { + ...seed.meta, + repoPath, + storagePath: layout.checkoutSlot, + graphPath: graph, + }; + delete meta.incrementalInProgress; + await saveMeta(layout.checkoutSlot, meta); + return true; + })); + if (seed && pointed) { log(`Shared store: seeded from commit graph ${seed.commit.slice(0, 12)}.`); return; } @@ -304,53 +320,68 @@ export const publishSharedGraph = async ( if (!meta) return; const own = path.join(slot, LBUG_DIRECTORY); + // A graph built while files were dirty still holds those edits even after + // they are reverted (the up-to-date path does not re-diff a clean tree), so + // only a graph whose build saw no dirty covered file may become shared. + const builtClean = (meta.indexCoverage?.dirtyPaths ?? []).length === 0; const shareable = currentCommit !== '' && meta.lastCommit === currentCommit && !meta.incrementalInProgress && + builtClean && !isWorkingTreeDirty(repoPath); - if (shareable) { - const target = commitGraphDir(layout, currentCommit, featureKeyOf(meta)); - const targetGraph = path.join(target, LBUG_DIRECTORY); - const published = await withStoreLock(layout, 'publish', async () => { - if (await exists(targetGraph)) { + // Every pointer change and the reclaim that follows run under one publish + // lock, so a concurrent reclaim never sees a half-recorded reference. + await withStoreLock(layout, 'publish', async () => { + if (shareable) { + const target = commitGraphDir(layout, currentCommit, featureKeyOf(meta)); + const targetGraph = path.join(target, LBUG_DIRECTORY); + let published = await exists(targetGraph); + if (published) { await wipeLbugDbFiles(own); - return true; + } else if ((await exists(own)) && (await inspectLbugSidecars(own)).kind === 'clean') { + 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); + published = true; + log(`Shared store: published commit graph ${currentCommit.slice(0, 12)}.`); + } 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.`, + ); + } } - 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; + if (published) { + meta.graphPath = targetGraph; + await saveMeta(slot, meta); } - log(`Shared store: published commit graph ${currentCommit.slice(0, 12)}.`); - return true; - }); - if (published) { - meta.graphPath = targetGraph; + } else if (meta.graphPath !== undefined && (await exists(own))) { + delete meta.graphPath; await saveMeta(slot, meta); } - } else if (meta.graphPath !== undefined && (await exists(own))) { - delete meta.graphPath; - await saveMeta(slot, meta); - } + const reclaimed = await reclaimSharedStoreLocked(layout.root); + if (reclaimed.removed.length > 0) { + log(`Shared store: removed ${reclaimed.removed.length} commit graph(s) no checkout uses.`); + } + }); // 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 }); + // must still end up registered at its slot. Branch summaries recorded for a + // previous storage location point at sub-indexes the slot does not hold. + const previous = findRegistryEntryByRepoPath(await readRegistry(), repoPath); + const moved = + previous !== undefined && + !registryPathEquals(canonicalizePath(previous.storagePath), canonicalizePath(slot)); + await registerRepo(repoPath, meta, { storagePath: slot, dropBranches: moved }); await writeSharedStorePointer(repoPath, layout); }; @@ -389,7 +420,7 @@ export const resolveOptedInStore = async ( } return sharedStoreLayout(path.basename(root), repoPath); } - const own = entries.find((e) => path.resolve(e.path) === path.resolve(repoPath)); + const own = findRegistryEntryByRepoPath(entries, repoPath); const root = own ? storeRootOfCheckoutSlot(own.storagePath) : null; return root ? sharedStoreLayout(path.basename(root), repoPath) : undefined; }; @@ -424,3 +455,17 @@ export const leaveSharedStore = async ( await reclaimAfterSlotRemoval(previousSlot); log(`Shared store: left ${previousSlot}.`); }; + +/** + * After a run that indexed outside a store: if the registry still names a + * store slot for this checkout, re-register it at `storagePath`. No-op when + * the entry is already elsewhere or the new location has no finished index. + */ +export const registerLeftStore = async (repoPath: string, storagePath: string): Promise => { + const entry = findRegistryEntryByRepoPath(await readRegistry(), repoPath); + if (!entry || !storeRootOfCheckoutSlot(entry.storagePath)) return; + const meta = await loadMeta(storagePath); + if (!meta?.lastCommit) return; + await registerRepo(repoPath, meta, { storagePath }); + await removeSharedStorePointer(repoPath); +}; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 3280abc8e..37da889de 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -2111,7 +2111,7 @@ export class LocalBackend { this.maybeWarnSiblingDrift(result).catch(() => { /* best-effort; never throw from resolveRepo */ }); - return this.applyBranchScope(result, branch); + return this.applyBranchScope(this.followSharedGraph(result), branch); } // Still no match — throw with helpful message @@ -2166,6 +2166,21 @@ export class LocalBackend { * and restamped labels the cached handle predates resolve on the next * call. */ + /** + * A shared-store checkout (#3352) moves between immutable commit graphs and + * its private graph as it is re-analyzed, while the cached handle keeps the + * graph it resolved first. Re-resolve the flat graph (one stat when the slot + * metadata is unchanged) and update the cached handle when it moved, so the + * pool opens the graph the checkout reads now. + */ + private followSharedGraph(handle: RepoHandle): RepoHandle { + const current = resolveGraphPath(handle.storagePath); + if (current === handle.lbugPath) return handle; + const moved = { ...handle, lbugPath: current }; + if (this.repos.get(handle.id) === handle) this.repos.set(handle.id, moved); + return moved; + } + private async applyBranchScope(handle: RepoHandle, branch?: string): Promise { if (!branch) return handle; // At most one cache refresh per resolution: enough for the NEXT call to @@ -2180,7 +2195,10 @@ export class LocalBackend { // One small JSON read per scoped call; mid-run meta writes preserve the // old label until the end-of-run atomic stamp (run-analyze dirty stamps // spread the existing meta), so this read never runs ahead of the DB. - const flatMeta = await loadMeta(path.dirname(handle.lbugPath)); + // The flat slot's own metadata, not the graph's directory: a shared-store + // checkout's graph sits in a commit directory whose metadata carries no + // branch label (#3352). + const flatMeta = await loadMeta(handle.storagePath); if (flatMeta?.branch && flatMeta.branch === branch) { // The disk meta decides routing, so it also supplies the metadata — // the cached handle's label/commit/stats can predate the restamp. diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index e30b34e70..258c13413 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -890,6 +890,12 @@ export interface RegisterRepoOptions { * analysis or index operation has begun. */ storagePath?: string; + /** + * Drop recorded `branches[]` summaries on a primary run. Set when the entry + * moves to a different storage location (a shared-store slot, #3352): the + * summaries name `branches/` sub-indexes the new location does not hold. + */ + dropBranches?: boolean; } /** @@ -1166,7 +1172,7 @@ const registerRepoUnlocked = async ( // Primary run: apply our refreshed top-level, but defer to the FRESH // branches[] (a concurrent branch upsert or `clean --branch` wins). merged = { ...entry }; - if (freshExisting?.branches) merged.branches = freshExisting.branches; + if (freshExisting?.branches && !opts?.dropBranches) merged.branches = freshExisting.branches; else delete merged.branches; } if (freshIdx >= 0) { diff --git a/gitnexus/src/storage/shared-store-lifecycle.ts b/gitnexus/src/storage/shared-store-lifecycle.ts index 994505d44..b02bd5da0 100644 --- a/gitnexus/src/storage/shared-store-lifecycle.ts +++ b/gitnexus/src/storage/shared-store-lifecycle.ts @@ -13,9 +13,14 @@ import { existsSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; import { acquireIndexLock, requireExclusiveIndexLock } from './index-lock.js'; +import { + canonicalizePath, + findRegistryEntryByRepoPath, + readRegistry, + registryPathEquals, +} from './repo-manager.js'; import { loadMeta } from './repo-meta.js'; import { - resolveSharedStore, SHARED_STORE_POINTER, storeRootOfCheckoutSlot, type SharedStoreLayout, @@ -58,77 +63,100 @@ export interface ReclaimResult { const listDir = (dir: string): Promise => fs.readdir(dir).catch(() => [] as string[]); /** - * A member slot whose checkout is gone or no longer resolves to this store - * (the worktree was deleted, or sharing was turned off for it). + * Member slots that no registry entry uses any more: the checkout directory is + * gone, or its entry moved elsewhere (`--no-share`, sharing turned off). The + * registry is the membership record for opted-in clones and for a main + * checkout whose last worktree was removed, so identity alone cannot decide. + * A slot with no attributable `repoPath` is never collected. */ -const isOrphanMember = async (slot: string, storeRoot: string): Promise => { - const meta = await loadMeta(slot); - if (!meta?.repoPath) return false; // unknown slot: never collect what we cannot attribute - if (!existsSync(meta.repoPath)) return true; - const layout = resolveSharedStore(meta.repoPath); - return !layout || layout.root !== storeRoot || layout.checkoutSlot !== slot; +const orphanMembers = async (slots: string[]): Promise> => { + const entries = await readRegistry(); + const orphans = new Set(); + for (const slot of slots) { + const meta = await loadMeta(slot); + if (!meta?.repoPath) continue; + const entry = existsSync(meta.repoPath) + ? findRegistryEntryByRepoPath(entries, meta.repoPath) + : undefined; + if ( + !entry || + !registryPathEquals(canonicalizePath(entry.storagePath), canonicalizePath(slot)) + ) { + orphans.add(slot); + } + } + return orphans; +}; + +/** + * Reclaim with the store's publish lock already held. Analyze calls this right + * after publishing so graphs a checkout stopped using are deleted at once + * (KTD7), and slot pointers written under the same lock are always counted. + */ +export const reclaimSharedStoreLocked = async ( + storeRoot: string, + opts: { gc?: boolean } = {}, +): Promise => { + const result: ReclaimResult = { removed: [], kept: [], droppedMembers: [], storeRemoved: false }; + const checkoutsDir = path.join(storeRoot, 'checkouts'); + const commitsDir = path.join(storeRoot, 'commits'); + const referenced = new Set(); + let slots = (await listDir(checkoutsDir)).map((name) => path.join(checkoutsDir, name)); + if (opts.gc) { + const orphans = await orphanMembers(slots); + for (const slot of orphans) { + await fs.rm(slot, { recursive: true, force: true }); + result.droppedMembers.push(slot); + } + slots = slots.filter((slot) => !orphans.has(slot)); + } + for (const slot of slots) { + const graphPath = (await loadMeta(slot))?.graphPath; + if (graphPath) referenced.add(path.dirname(path.resolve(graphPath))); + } + + for (const name of await listDir(commitsDir)) { + const dir = path.join(commitsDir, name); + if (referenced.has(dir)) continue; + try { + await fs.rm(dir, { recursive: true, force: true }); + if (!name.startsWith('.')) result.removed.push(dir); + } catch { + // Windows refuses to delete a file another process has open (an MCP + // reader). Keep it for the next reclaim instead of failing the caller. + if (!name.startsWith('.')) result.kept.push(dir); + } + } + + const remaining = (await listDir(checkoutsDir)).length + (await listDir(commitsDir)).length; + if (remaining === 0) { + // The lock directory lives inside the store; removing it while held is + // safe on POSIX and is retried on the next reclaim elsewhere. + await fs + .rm(storeRoot, { recursive: true, force: true }) + .then(() => { + result.storeRemoved = true; + }) + .catch(() => {}); + } + return result; }; /** * Delete unreferenced commit graphs, stale publish staging, and — with `gc` — - * member slots whose checkout no longer belongs to the store. Removes the - * store itself when nothing remains. + * member slots no registry entry uses. Removes the store itself when nothing + * remains. */ export const reclaimSharedStore = async ( storeRoot: string, opts: { gc?: boolean } = {}, ): Promise => { - const result: ReclaimResult = { removed: [], kept: [], droppedMembers: [], storeRemoved: false }; - if (!existsSync(storeRoot)) return result; - const checkoutsDir = path.join(storeRoot, 'checkouts'); - const commitsDir = path.join(storeRoot, 'commits'); - - await withStoreLock({ root: storeRoot }, 'publish', async () => { - const referenced = new Set(); - let slots = (await listDir(checkoutsDir)).map((name) => path.join(checkoutsDir, name)); - if (opts.gc) { - const live: string[] = []; - for (const slot of slots) { - if (await isOrphanMember(slot, storeRoot)) { - await fs.rm(slot, { recursive: true, force: true }); - result.droppedMembers.push(slot); - } else { - live.push(slot); - } - } - slots = live; - } - for (const slot of slots) { - const graphPath = (await loadMeta(slot))?.graphPath; - if (graphPath) referenced.add(path.dirname(path.resolve(graphPath))); - } - - for (const name of await listDir(commitsDir)) { - const dir = path.join(commitsDir, name); - if (referenced.has(dir)) continue; - try { - await fs.rm(dir, { recursive: true, force: true }); - if (!name.startsWith('.')) result.removed.push(dir); - } catch { - // Windows refuses to delete a file another process has open (an MCP - // reader). Keep it for the next reclaim instead of failing the clean. - if (!name.startsWith('.')) result.kept.push(dir); - } - } - - const remaining = (await listDir(checkoutsDir)).length + (await listDir(commitsDir)).length; - if (remaining === 0) { - // The lock directory lives inside the store; removing it while held is - // safe on POSIX and is retried on the next reclaim elsewhere. - await fs - .rm(storeRoot, { recursive: true, force: true }) - .then(() => { - result.storeRemoved = true; - }) - .catch(() => {}); - } - }); - return result; + if (!existsSync(storeRoot)) { + return { removed: [], kept: [], droppedMembers: [], storeRemoved: false }; + } + return withStoreLock({ root: storeRoot }, 'publish', () => + reclaimSharedStoreLocked(storeRoot, opts), + ); }; /** @@ -158,7 +186,7 @@ export const describeSharedGraph = ( : 'shared'; /** Files a shared checkout keeps in `/.gitnexus`; everything else there is legacy. */ -const POINTER_DIR_KEEP = new Set([SHARED_STORE_POINTER, '.gitignore']); +const POINTER_DIR_KEEP = new Set([SHARED_STORE_POINTER, '.gitignore', 'run.cjs']); /** * Point `/.gitnexus` at the checkout's store slot (#3352 R16). The @@ -182,8 +210,9 @@ export const removeSharedStorePointer = async (checkoutPath: string): Promise POINTER_DIR_KEEP.has(name))) { await fs.rm(dir, { recursive: true, force: true }); + } }; const sizeOf = async (target: string): Promise => { diff --git a/gitnexus/src/storage/shared-store.ts b/gitnexus/src/storage/shared-store.ts index 6f6a1f7eb..ad4644c7f 100644 --- a/gitnexus/src/storage/shared-store.ts +++ b/gitnexus/src/storage/shared-store.ts @@ -211,17 +211,31 @@ export const storeRootOfCheckoutSlot = (storagePath: string): string | null => { * private slot, unreadable metadata) falls back to the slot's own graph so a * hand-edited file cannot redirect reads. */ +// Store-slot metadata can be megabytes (file hashes, cache keys) and this runs +// on hot paths (MCP repo refresh, every getStoragePaths). Re-parse only when +// the file's identity changes; a stat is the per-call cost. +// ponytail: unbounded map keyed by slot path — one entry per shared checkout, +// small; add eviction if a process ever tracks thousands of slots. +const recordedGraphCache = new Map(); + +const readRecordedGraphPath = (metaPath: string): unknown => { + const stat = fs.statSync(metaPath); + const key = `${stat.ino}:${stat.size}:${stat.mtimeMs}`; + const cached = recordedGraphCache.get(metaPath); + if (cached?.key === key) return cached.recorded; + const recorded = (JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as { graphPath?: unknown }) + .graphPath; + recordedGraphCache.set(metaPath, { key, recorded }); + return recorded; +}; + export const resolveGraphPath = (storagePath: string): string => { const own = path.join(storagePath, LBUG_DIRECTORY); const root = storeRootOfCheckoutSlot(storagePath); if (!root) return own; let recorded: unknown; try { - recorded = ( - JSON.parse(fs.readFileSync(path.join(storagePath, INDEX_METADATA_FILE), 'utf-8')) as { - graphPath?: unknown; - } - ).graphPath; + recorded = readRecordedGraphPath(path.join(storagePath, INDEX_METADATA_FILE)); } catch { return own; } diff --git a/gitnexus/src/storage/storage-resolver.ts b/gitnexus/src/storage/storage-resolver.ts index 5d796fbed..3df2df7b7 100644 --- a/gitnexus/src/storage/storage-resolver.ts +++ b/gitnexus/src/storage/storage-resolver.ts @@ -27,6 +27,7 @@ const INDEX_LOCK_ARTIFACTS = new Set([ // are not index data either. SHARED_STORE_POINTER, '.gitignore', + 'run.cjs', ]); export type StorageState = diff --git a/gitnexus/test/integration/shared-store-adoption.test.ts b/gitnexus/test/integration/shared-store-adoption.test.ts index ae7bd1f0f..a4ba0d41e 100644 --- a/gitnexus/test/integration/shared-store-adoption.test.ts +++ b/gitnexus/test/integration/shared-store-adoption.test.ts @@ -104,7 +104,9 @@ describe('shared store adoption and reporting (#3352)', () => { const result = await analyze(wt); - expect(result.alreadyUpToDate).toBe(true); // seeded from its own index + // Seeded from its own index, then verified by a file-hash diff rather than + // trusted: a local index may hold edits that were later reverted. + expect(result.alreadyUpToDate).not.toBe(true); const layout = layoutOf(wt); expect((await fs.readdir(layout.commitsDir)).filter((n) => !n.startsWith('.'))).toHaveLength(1); expect(await fs.readFile(path.join(wt, '.gitnexus', 'lbug'))).toEqual(legacyGraph); @@ -143,6 +145,7 @@ describe('shared store adoption and reporting (#3352)', () => { await runIn(wt, () => cleanCommand({ localIndex: true, force: true })); expect((await fs.readdir(path.join(wt, '.gitnexus'))).sort()).toEqual([ '.gitignore', + 'run.cjs', 'store.json', ]); expect((await statusJson(wt)).legacyLocalIndex).toBeNull(); @@ -178,6 +181,46 @@ describe('shared store adoption and reporting (#3352)', () => { expect(await fs.readFile(commitGraph)).toEqual(before); }, 240_000); + it('keeps committed agent docs pointing at a runner inside the checkout', async () => { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(wt, { registryName: 'wt' }, { onProgress: () => {} }); + expect(existsSync(path.join(wt, '.gitnexus', 'run.cjs'))).toBe(true); + const agents = await fs.readFile(path.join(wt, 'AGENTS.md'), 'utf-8'); + expect(agents).toContain('.gitnexus/run.cjs'); + expect(agents).not.toContain('stores/'); + }, 240_000); + + it('drops pinned branch summaries when a checkout moves into the store', async () => { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + process.env[SHARED_STORE_ENV] = 'off'; + try { + await runFullAnalysis(wt, { branch: 'wt' }, { onProgress: () => {} }); + await analyze(wt); + } finally { + delete process.env[SHARED_STORE_ENV]; + } + await analyze(wt); + const entry = (await listRegisteredRepos()).find((e) => e.path === wt); + expect(entry?.storagePath).toBe(layoutOf(wt).checkoutSlot); + expect(entry?.branches).toBeUndefined(); + }, 240_000); + + it('re-registers at .gitnexus when sharing is turned off on an up-to-date index', async () => { + await legacyIndex(wt); + await analyze(wt); + process.env[SHARED_STORE_ENV] = 'off'; + try { + const result = await analyze(wt); + expect(result.alreadyUpToDate).toBe(true); + } finally { + delete process.env[SHARED_STORE_ENV]; + } + expect((await listRegisteredRepos()).find((e) => e.path === wt)?.storagePath).toBe( + path.join(wt, '.gitnexus'), + ); + expect(readSharedStorePointer(wt)).toBeNull(); + }, 240_000); + it('rejects a pointer that names another checkout slot', async () => { await analyze(wt); const pointer = path.join(wt, '.gitnexus', 'store.json'); diff --git a/gitnexus/test/integration/shared-store-analyze.test.ts b/gitnexus/test/integration/shared-store-analyze.test.ts index d959f6131..99294f269 100644 --- a/gitnexus/test/integration/shared-store-analyze.test.ts +++ b/gitnexus/test/integration/shared-store-analyze.test.ts @@ -183,7 +183,22 @@ describe('shared sibling store analyze (#3352)', () => { expect(meta?.graphPath).toBe( path.join(commitGraphDir(layout, head, featureKeyOf(meta as RepoMeta)), 'lbug'), ); - expect(await listCommitDirs(layout)).toHaveLength(2); + // The previous commit's graph is no longer referenced and is reclaimed at once. + expect(await listCommitDirs(layout)).toEqual([ + path.basename(path.dirname(meta?.graphPath as string)), + ]); + }, 180_000); + + it('never publishes a graph that was built from uncommitted edits', async () => { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await fs.writeFile(path.join(wtA, 'a.ts'), 'export function uncommitted() { return 9; }\n'); + await runFullAnalysis(wtA, {}, { onProgress: () => {} }); + git(wtA, 'checkout', '--', 'a.ts'); + await runFullAnalysis(wtA, {}, { onProgress: () => {} }); + + const layout = layoutOf(wtA); + expect(await listCommitDirs(layout)).toEqual([]); + expect(existsSync(path.join(layout.checkoutSlot, 'lbug'))).toBe(true); }, 180_000); }); diff --git a/gitnexus/test/integration/shared-store-clone-optin.test.ts b/gitnexus/test/integration/shared-store-clone-optin.test.ts index ce1d40d76..dc5a9190d 100644 --- a/gitnexus/test/integration/shared-store-clone-optin.test.ts +++ b/gitnexus/test/integration/shared-store-clone-optin.test.ts @@ -135,6 +135,29 @@ describe('shared store clone opt-in (#3352)', () => { ); }, 240_000); + it('--no-share on an up-to-date local index still re-registers there', async () => { + const clone = cloneWithRemote('clone', REMOTE); + await analyze(clone); + await analyze(clone, { shareWith: wt }); + const slot = (await registeredStorage(clone)) as string; + + const result = await analyze(clone, { noShare: true }); + + expect(result.alreadyUpToDate).toBe(true); + expect(await registeredStorage(clone)).toBe(path.join(clone, '.gitnexus')); + expect(existsSync(slot)).toBe(false); + }, 240_000); + + it('clean --gc keeps an opted-in clone that is still registered at its slot', async () => { + const clone = cloneWithRemote('clone', REMOTE); + await analyze(clone, { shareWith: wt }); + const slot = (await registeredStorage(clone)) as string; + const { reclaimSharedStore } = await import('../../src/storage/shared-store-lifecycle.js'); + const result = await reclaimSharedStore(storeLayout.root, { gc: true }); + expect(result.droppedMembers).toEqual([]); + expect(existsSync(slot)).toBe(true); + }, 240_000); + it('rejects --no-share in a linked worktree', async () => { await expect(analyze(wt, { noShare: true })).rejects.toThrow(/GITNEXUS_SHARED_STORE=off/); }, 240_000);