fix(storage): address review findings in the shared store (#3352)

- Never publish a graph whose build saw dirty files, and never trust a
  local-index seed on the up-to-date path; it may hold reverted edits.
- Record slot pointers and reclaim under the publish lock, and reclaim
  right after each publish, so a commit graph is never deleted between
  publish and pointer save and superseded graphs don't pile up.
- clean --gc decides membership from the registry, so opted-in clones
  and a main checkout without worktrees are not dropped.
- Leaving a store re-registers first, so an up-to-date run cannot leave
  the registry pointing at a deleted slot.
- Drop pinned branch summaries when an entry moves into a store slot.
- Keep run.cjs and the AGENTS.md runner path inside the checkout.
- MCP handles follow the slot's current graph, and branch scoping reads
  the slot's own metadata.
- Cache resolveGraphPath by metadata file identity; look up opted-in
  entries with canonical registry paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-24 11:13:54 +00:00
parent f9e988ebce
commit 28a770b68d
11 changed files with 333 additions and 126 deletions

View file

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

View file

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

View file

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

View file

@ -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<RepoHandle> {
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.

View file

@ -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/<slug>` 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) {

View file

@ -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<string[]> => 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<boolean> => {
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<Set<string>> => {
const entries = await readRegistry();
const orphans = new Set<string>();
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<ReclaimResult> => {
const result: ReclaimResult = { removed: [], kept: [], droppedMembers: [], storeRemoved: false };
const checkoutsDir = path.join(storeRoot, 'checkouts');
const commitsDir = path.join(storeRoot, 'commits');
const referenced = new Set<string>();
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<ReclaimResult> => {
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<string>();
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 `<checkout>/.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 `<checkout>/.gitnexus` at the checkout's store slot (#3352 R16). The
@ -182,8 +210,9 @@ export const removeSharedStorePointer = async (checkoutPath: string): Promise<vo
const dir = path.join(checkoutPath, GITNEXUS_DIR);
await fs.rm(path.join(dir, SHARED_STORE_POINTER), { force: true });
const rest = await listDir(dir);
if (rest.length === 1 && rest[0] === '.gitignore')
if (rest.every((name) => POINTER_DIR_KEEP.has(name))) {
await fs.rm(dir, { recursive: true, force: true });
}
};
const sizeOf = async (target: string): Promise<number> => {

View file

@ -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<string, { key: string; recorded: unknown }>();
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;
}

View file

@ -27,6 +27,7 @@ const INDEX_LOCK_ARTIFACTS = new Set([
// are not index data either.
SHARED_STORE_POINTER,
'.gitignore',
'run.cjs',
]);
export type StorageState =

View file

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

View file

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

View file

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