diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index b7c810a7c..51b57db23 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -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>>; @@ -147,13 +154,50 @@ const cleanStaleBranchSlots = async (force: boolean): Promise => { } }; +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 => { + 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:'); } diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 932d36b25..ea08053ce 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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.', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 5fde76516..c18f106e7 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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` 查看已记录名称。', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 01e7be835..897c03764 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -350,6 +350,10 @@ program .option('--all', 'Clean all indexed repos') .option('--branch ', '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)', diff --git a/gitnexus/src/cli/remove.ts b/gitnexus/src/cli/remove.ts index 72bb92364..697200a62 100644 --- a/gitnexus/src/cli/remove.ts +++ b/gitnexus/src/cli/remove.ts @@ -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}`); diff --git a/gitnexus/src/core/shared-store-analyze.ts b/gitnexus/src/core/shared-store-analyze.ts index e6167caaa..c969323ad 100644 --- a/gitnexus/src/core/shared-store-analyze.ts +++ b/gitnexus/src/core/shared-store-analyze.ts @@ -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 ( - layout: SharedStoreLayout, - name: 'publish' | 'cache', - fn: () => Promise, -): Promise => { - 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 }; diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index d10a4fd47..70c23fa80 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -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. diff --git a/gitnexus/src/storage/shared-store-lifecycle.ts b/gitnexus/src/storage/shared-store-lifecycle.ts new file mode 100644 index 000000000..c0d8c6131 --- /dev/null +++ b/gitnexus/src/storage/shared-store-lifecycle.ts @@ -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; + +/** + * 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 ( + layout: StoreRoot, + name: 'publish' | 'cache', + fn: () => Promise, +): Promise => { + 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 => 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 => { + 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 => { + 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; +}; + +/** + * 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 => { + 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'; diff --git a/gitnexus/test/integration/shared-store-clean.test.ts b/gitnexus/test/integration/shared-store-clean.test.ts new file mode 100644 index 000000000..cae8f59ac --- /dev/null +++ b/gitnexus/test/integration/shared-store-clean.test.ts @@ -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 => + (await fs.readdir(layout.commitsDir).catch(() => [] as string[])).filter( + (n) => !n.startsWith('.'), + ); + +describe('shared store clean (#3352)', () => { + let tmpHome: Awaited>; + let tmpRepo: Awaited>; + 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) => { + 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>; + 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 => { + 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): Promise => { + 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(); + } + }); +});