mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
f9e988ebce
commit
28a770b68d
11 changed files with 333 additions and 126 deletions
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const INDEX_LOCK_ARTIFACTS = new Set([
|
|||
// are not index data either.
|
||||
SHARED_STORE_POINTER,
|
||||
'.gitignore',
|
||||
'run.cjs',
|
||||
]);
|
||||
|
||||
export type StorageState =
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue