feat(clean): remove only what no shared-store member references (#3352)

Deleting a shared checkout slot (clean, clean --all, remove, and the
server delete route) recounts references under the store's publish
lock and deletes commit graphs no member points at, then the store
itself once empty. A graph that cannot be deleted (open on Windows) is
reported and kept for the next pass. clean --gc also drops member slots
whose worktree is gone or no longer resolves to the store.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-09-24 07:52:40 +00:00
parent 22ad5e312d
commit 8c74a86fc3
9 changed files with 450 additions and 21 deletions

View file

@ -33,6 +33,13 @@ import {
listParkedLbugSidecars,
} from '../core/lbug/sidecar-recovery.js';
import { t } from './i18n/index.js';
import { getGlobalDir } from '../storage/global-dir.js';
import { STORES_DIR } from '../storage/shared-store.js';
import {
reclaimAfterSlotRemoval,
reclaimSharedStore,
type ReclaimResult,
} from '../storage/shared-store-lifecycle.js';
type OwnedCwdStorage = {
repo: NonNullable<Awaited<ReturnType<typeof findRepo>>>;
@ -147,13 +154,50 @@ const cleanStaleBranchSlots = async (force: boolean): Promise<void> => {
}
};
const reportReclaim = (result: ReclaimResult | null): void => {
if (!result) return;
if (result.removed.length > 0) {
console.log(t('clean.shared.reclaimed', { count: result.removed.length }));
}
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
};
/** `clean --gc`: collect every shared store under GITNEXUS_HOME (#3352). */
const collectSharedStores = async (): Promise<void> => {
const storesDir = path.join(getGlobalDir(), STORES_DIR);
const names = await fs.readdir(storesDir).catch(() => [] as string[]);
if (names.length === 0) {
console.log(t('clean.gc.none'));
return;
}
for (const name of names) {
const root = path.join(storesDir, name);
const result = await reclaimSharedStore(root, { gc: true });
console.log(
t('clean.gc.store', {
path: root,
members: result.droppedMembers.length,
graphs: result.removed.length,
}),
);
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
if (result.storeRemoved) console.log(t('clean.shared.storeRemoved', { path: root }));
}
};
export const cleanCommand = async (options?: {
force?: boolean;
all?: boolean;
lbugSidecars?: boolean;
stale?: boolean;
branch?: string;
gc?: boolean;
}) => {
if (options?.gc) {
await collectSharedStores();
return;
}
// --stale: reclaim leftover per-branch slots whose recorded branch is not
// a live local head (#3331). Exclusive arm before --branch.
if (options?.stale) {
@ -295,6 +339,7 @@ export const cleanCommand = async (options?: {
await fs.rm(storagePath, { recursive: true, force: true });
await unregisterRepo(entry.path);
console.log(t('clean.deletedRepo', { name: entry.name, storagePath }));
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
} catch (err) {
if (err instanceof StorageDeletionError) {
logger.error(`Refusing to clean ${entry.name}: ${err.message}`);
@ -341,6 +386,7 @@ export const cleanCommand = async (options?: {
await fs.rm(storagePath, { recursive: true, force: true });
await unregisterRepo(repo.repoPath);
console.log(t('common.deleted', { target: storagePath }));
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
} catch (err) {
logger.error({ err }, 'Failed to delete:');
}

View file

@ -59,6 +59,14 @@ export const en = {
'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
'clean.notFoundHere': 'No indexed repository found in this directory.',
'clean.shared.reclaimed':
'Shared store: removed {{count}} commit graph(s) no checkout references.',
'clean.shared.kept':
'Shared store: kept {{count}} unreferenced commit graph(s) that are still open; run `gitnexus clean --gc` later.',
'clean.shared.storeRemoved': 'Shared store: removed {{path}} (no checkouts remain).',
'clean.gc.none': 'No shared stores to collect.',
'clean.gc.store':
'Shared store {{path}}: dropped {{members}} checkout(s), removed {{graphs}} commit graph(s).',
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
'clean.branchNotIndexed':
'No indexed branch named "{{branch}}" for this repository. Use `gitnexus clean --stale` to reclaim leftover branch indexes, or `gitnexus list` to see recorded names.',

View file

@ -58,6 +58,12 @@ export const zhCN = {
'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
'clean.deletedRepo': '已删除:{{name}}({{storagePath}})',
'clean.notFoundHere': '当前目录未找到已索引仓库。',
'clean.shared.reclaimed': '共享存储:已删除 {{count}} 个不再被任何检出引用的提交图。',
'clean.shared.kept':
'共享存储:保留了 {{count}} 个仍处于打开状态的未引用提交图;请稍后运行 `gitnexus clean --gc`。',
'clean.shared.storeRemoved': '共享存储:已删除 {{path}}(没有剩余检出)。',
'clean.gc.none': '没有可回收的共享存储。',
'clean.gc.store': '共享存储 {{path}}:移除了 {{members}} 个检出,删除了 {{graphs}} 个提交图。',
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
'clean.branchNotIndexed':
'该仓库没有名为 “{{branch}}” 的已索引分支。使用 `gitnexus clean --stale` 回收残留分支索引,或使用 `gitnexus list` 查看已记录名称。',

View file

@ -350,6 +350,10 @@ program
.option('--all', 'Clean all indexed repos')
.option('--branch <name>', 'Delete only the named branch index (not the workspace index)')
.option('--stale', 'Reclaim leftover branch indexes that are not a live local head')
.option(
'--gc',
'Drop shared-store checkouts whose worktree is gone and delete commit graphs nothing references',
)
.option(
'--lbug-sidecars',
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',

View file

@ -29,6 +29,7 @@
* here there is no pipeline, so no conflation.)
*/
import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js';
import fs from 'fs/promises';
import { logger } from '../core/logger.js';
import { cliError } from './cli-message.js';
@ -100,6 +101,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean
try {
await fs.rm(storagePath, { recursive: true, force: true });
await unregisterRepo(entry.path);
await reclaimAfterSlotRemoval(storagePath);
console.log(t('remove.removed', { name: entry.name }));
console.log(` ${t('common.path')}: ${entry.path}`);
console.log(` ${t('common.storage')}: ${entry.storagePath}`);

View file

@ -19,7 +19,8 @@ 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 { acquireIndexLock } from '../storage/index-lock.js';
import { withStoreLock } from '../storage/shared-store-lifecycle.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';
@ -250,26 +251,6 @@ export const ensurePrivateSharedGraph = async (slot: string, log: Log): Promise<
return true;
};
/**
* Serialize one kind of store-wide write (`publish`, `cache`) across
* checkouts. Each checkout's own slot is already covered by its index lock.
*/
export const withStoreLock = async <T>(
layout: SharedStoreLayout,
name: 'publish' | 'cache',
fn: () => Promise<T>,
): Promise<T> => {
const lockDir = path.join(layout.root, 'locks', name);
await fs.mkdir(lockDir, { recursive: true });
const lock = await acquireIndexLock(lockDir);
try {
requireExclusiveIndexLock(lock, `Cannot acquire the shared-store ${name} lock at ${lockDir}.`);
return await fn();
} finally {
lock.release();
}
};
/**
* Every directory in the store whose metadata may record parse-cache keys:
* each checkout slot (its branch slots are read by the caller's per-root
@ -350,3 +331,5 @@ export const publishSharedGraph = async (
// must still end up registered at its slot.
await registerRepo(repoPath, meta, { storagePath: slot });
};
export { withStoreLock };

View file

@ -8,6 +8,7 @@
* CORS is restricted to localhost, private/LAN networks, and the deployed site.
*/
import { reclaimAfterSlotRemoval } from '../storage/shared-store-lifecycle.js';
import express from 'express';
import cors from 'cors';
import path from 'path';
@ -1318,6 +1319,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// 1. Delete the .gitnexus index/storage directory
await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {});
await reclaimAfterSlotRemoval(storagePath);
// 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/.
// getCloneDir now throws on names that are not filesystem-safe (e.g.

View file

@ -0,0 +1,157 @@
/**
* Store-wide locking and reference-counted cleanup for the shared sibling
* store (#3352).
*
* A commit graph is live while any member checkout slot's metadata records
* it as `graphPath`. `reclaimSharedStore` deletes every commit graph with no
* reference, and the whole store once no member and no commit graph remain.
* It runs under the store's publish lock, so a concurrent analyze that is
* publishing or pointing at a graph is never raced.
*/
import { existsSync } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { acquireIndexLock, requireExclusiveIndexLock } from './index-lock.js';
import { loadMeta } from './repo-meta.js';
import {
resolveSharedStore,
storeRootOfCheckoutSlot,
type SharedStoreLayout,
} from './shared-store.js';
import { LBUG_DIRECTORY } from './storage-constants.js';
type StoreRoot = Pick<SharedStoreLayout, 'root'>;
/**
* Serialize one kind of store-wide write (`publish`, `cache`) across
* checkouts. Each checkout's own slot is already covered by its index lock.
*/
export const withStoreLock = async <T>(
layout: StoreRoot,
name: 'publish' | 'cache',
fn: () => Promise<T>,
): Promise<T> => {
const lockDir = path.join(layout.root, 'locks', name);
await fs.mkdir(lockDir, { recursive: true });
const lock = await acquireIndexLock(lockDir);
try {
requireExclusiveIndexLock(lock, `Cannot acquire the shared-store ${name} lock at ${lockDir}.`);
return await fn();
} finally {
lock.release();
}
};
export interface ReclaimResult {
/** Commit graph directories deleted. */
removed: string[];
/** Unreferenced commit graphs that could not be deleted (for example, open on Windows). */
kept: string[];
/** Member slots dropped by garbage collection. */
droppedMembers: string[];
/** The store root was deleted because nothing remained. */
storeRemoved: boolean;
}
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).
*/
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;
};
/**
* 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.
*/
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;
};
/**
* After a storage slot was deleted: reclaim its store when it was a shared
* checkout slot. No-op for any other storage path. Never throws — the slot
* deletion already succeeded and reclaim is retried by the next clean.
*/
export const reclaimAfterSlotRemoval = async (
storagePath: string,
): Promise<ReclaimResult | null> => {
const storeRoot = storeRootOfCheckoutSlot(storagePath);
if (!storeRoot) return null;
try {
return await reclaimSharedStore(storeRoot);
} catch {
return null;
}
};
/** Graph directory a checkout slot reads, for reporting. */
export const describeSharedGraph = (
graphPath: string,
storagePath: string,
): 'shared' | 'private' =>
path.resolve(graphPath) === path.join(path.resolve(storagePath), LBUG_DIRECTORY)
? 'private'
: 'shared';

View file

@ -0,0 +1,221 @@
import { execFileSync } from 'child_process';
import { existsSync } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
import {
resolveSharedStore,
sharedStoreLayout,
type SharedStoreLayout,
} from '../../src/storage/shared-store.js';
import {
reclaimAfterSlotRemoval,
reclaimSharedStore,
} from '../../src/storage/shared-store-lifecycle.js';
import { createTempDir } from '../helpers/test-db.js';
/**
* #3352 U6 — clean removes only what no remaining member references.
*/
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 commitDirs = async (layout: SharedStoreLayout): Promise<string[]> =>
(await fs.readdir(layout.commitsDir).catch(() => [] as string[])).filter(
(n) => !n.startsWith('.'),
);
describe('shared store clean (#3352)', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
let savedCwd: string;
let main: string;
let wtA: string;
let wtB: string;
const analyze = async (checkout: string) => {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
return runFullAnalysis(checkout, {}, { onProgress: () => {} });
};
const cleanIn = async (checkout: string, options: Record<string, unknown>) => {
const { cleanCommand } = await import('../../src/cli/clean.js');
process.chdir(checkout);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await cleanCommand(options);
return log.mock.calls.map((c) => String(c[0]));
} finally {
log.mockRestore();
process.chdir(savedCwd);
}
};
beforeEach(async () => {
savedCwd = process.cwd();
tmpHome = await createTempDir('gitnexus-clean-home-');
tmpRepo = await createTempDir('gitnexus-clean-repo-');
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
const root = await fs.realpath(tmpRepo.dbPath);
main = path.join(root, 'main');
await fs.mkdir(main);
git(main, 'init', '-q', '-b', 'main');
await fs.writeFile(path.join(main, 'a.ts'), 'export function alpha() { return 1; }\n');
commitAll(main, 'init');
wtA = path.join(root, 'wt-a');
wtB = path.join(root, 'wt-b');
git(main, 'worktree', 'add', '-q', '-b', 'wt-a', wtA);
git(main, 'worktree', 'add', '-q', '-b', 'wt-b', wtB);
});
afterEach(async () => {
process.chdir(savedCwd);
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpRepo.cleanup();
await tmpHome.cleanup();
});
it('Covers AE5: cleaning one of two worktrees at X keeps X for the other', async () => {
await analyze(wtA);
await analyze(wtB);
const graph = getStoragePaths(wtB, undefined, layoutOf(wtB).checkoutSlot).lbugPath;
await cleanIn(wtA, { force: true });
expect(existsSync(layoutOf(wtA).checkoutSlot)).toBe(false);
expect(existsSync(graph)).toBe(true);
expect((await loadMeta(layoutOf(wtB).checkoutSlot))?.graphPath).toBe(graph);
}, 240_000);
it('deletes the commit graph and the store when the last member is cleaned', async () => {
await analyze(wtA);
await analyze(wtB);
const layout = layoutOf(wtA);
await cleanIn(wtA, { force: true });
const logs = await cleanIn(wtB, { force: true });
expect(await commitDirs(layout)).toEqual([]);
expect(existsSync(layout.root)).toBe(false);
expect(logs.join('\n')).toMatch(/removed 1 commit graph/);
}, 240_000);
it('previews without --force and deletes nothing', async () => {
await analyze(wtA);
const layout = layoutOf(wtA);
await cleanIn(wtA, {});
expect(existsSync(layout.checkoutSlot)).toBe(true);
expect(await commitDirs(layout)).toHaveLength(1);
}, 240_000);
it('clean --gc drops a deleted worktree and the graph only it referenced', async () => {
await analyze(wtA);
await fs.writeFile(path.join(wtB, 'b.ts'), 'export function beta() { return 2; }\n');
commitAll(wtB, 'b');
await analyze(wtB);
const layout = layoutOf(wtB);
const slotB = layout.checkoutSlot;
expect(await commitDirs(layout)).toHaveLength(2);
git(main, 'worktree', 'remove', '--force', wtB);
const logs = await cleanIn(main, { gc: true });
expect(existsSync(slotB)).toBe(false);
expect(await commitDirs(layout)).toHaveLength(1);
expect(existsSync(layoutOf(wtA).checkoutSlot)).toBe(true);
expect(logs.join('\n')).toMatch(/dropped 1 checkout\(s\), removed 1 commit graph/);
}, 240_000);
});
describe('reclaimSharedStore', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let savedHome: string | undefined;
beforeEach(async () => {
tmpHome = await createTempDir('gitnexus-reclaim-home-');
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 tmpHome.cleanup();
});
const layout = (): SharedStoreLayout => sharedStoreLayout('repo-0123456789ab', '/tmp/wt');
const commitGraph = async (name: string): Promise<string> => {
const dir = path.join(layout().commitsDir, name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, 'lbug'), 'graph');
return dir;
};
const member = async (slotName: string, meta: Record<string, unknown>): Promise<string> => {
const slot = path.join(layout().checkoutsDir, slotName);
await fs.mkdir(slot, { recursive: true });
await saveMeta(slot, { lastCommit: '', indexedAt: '', repoPath: '/tmp/wt', ...meta });
return slot;
};
it('keeps referenced graphs and removes unreferenced graphs and stale staging', async () => {
const kept = await commitGraph('aaaaaaa-1111111111111111');
const orphan = await commitGraph('bbbbbbb-2222222222222222');
const staging = await commitGraph('.publish-dead');
await member('wt-000000000000', { graphPath: path.join(kept, 'lbug') });
const result = await reclaimSharedStore(layout().root);
expect(existsSync(kept)).toBe(true);
expect(existsSync(orphan)).toBe(false);
expect(existsSync(staging)).toBe(false);
expect(result.removed).toEqual([orphan]);
expect(result.storeRemoved).toBe(false);
});
it('never collects a slot whose metadata it cannot attribute', async () => {
const slot = path.join(layout().checkoutsDir, 'unknown-000000000000');
await fs.mkdir(slot, { recursive: true });
const result = await reclaimSharedStore(layout().root, { gc: true });
expect(existsSync(slot)).toBe(true);
expect(result.droppedMembers).toEqual([]);
});
it('is a no-op for storage outside the stores directory', async () => {
const outside = path.join(tmpHome.dbPath, 'elsewhere', '.gitnexus');
await fs.mkdir(outside, { recursive: true });
expect(await reclaimAfterSlotRemoval(outside)).toBeNull();
expect(existsSync(outside)).toBe(true);
});
it('reports a graph it cannot delete instead of failing', async () => {
const orphan = await commitGraph('ccccccc-3333333333333333');
await member('wt-000000000000', {});
const rm = vi.spyOn(fs, 'rm').mockImplementation(async (target) => {
if (String(target) === orphan) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
});
try {
const result = await reclaimSharedStore(layout().root);
expect(result.kept).toEqual([orphan]);
expect(result.removed).toEqual([]);
} finally {
rm.mockRestore();
}
});
});