diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index 6c5b24de5..b39d5ebbe 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -200,6 +200,9 @@ const collectSharedStores = async (force: boolean): Promise => { graphs: result.removed.length, }), ); + if (result.keptMembers.length > 0) { + console.log(t('clean.gc.keptMembers', { count: result.keptMembers.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 })); } diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index e661a5eb3..d1801011d 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -74,6 +74,8 @@ export const en = { '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.keptMembers': + 'Shared store: kept {{count}} checkout(s) it could not delete; run `gitnexus clean --gc --force` later.', 'clean.gc.store': 'Shared store {{path}}: dropped {{members}} checkout(s), removed {{graphs}} commit graph(s).', 'clean.gc.preview': diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 5eb0baf22..05eec8322 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -70,6 +70,8 @@ export const zhCN = { '共享存储:保留了 {{count}} 个仍处于打开状态的未引用提交图;请稍后运行 `gitnexus clean --gc`。', 'clean.shared.storeRemoved': '共享存储:已删除 {{path}}(没有剩余检出)。', 'clean.gc.none': '没有可回收的共享存储。', + 'clean.gc.keptMembers': + '共享存储:保留了 {{count}} 个无法删除的检出;请稍后运行 `gitnexus clean --gc --force`。', 'clean.gc.store': '共享存储 {{path}}:移除了 {{members}} 个检出,删除了 {{graphs}} 个提交图。', 'clean.gc.preview': '共享存储 {{path}}:将移除 {{members}} 个检出并删除 {{graphs}} 个提交图。', 'clean.localIndex.none': '此检出中没有残留的本地索引。', diff --git a/gitnexus/src/storage/shared-store-lifecycle.ts b/gitnexus/src/storage/shared-store-lifecycle.ts index 36f60e488..605647bd7 100644 --- a/gitnexus/src/storage/shared-store-lifecycle.ts +++ b/gitnexus/src/storage/shared-store-lifecycle.ts @@ -19,13 +19,18 @@ import { readRegistryStrictIfPresent, registryPathEquals, } from './repo-manager.js'; -import { isMissingFilesystemError, loadMeta } from './repo-meta.js'; +import { isMissingFilesystemError, loadMeta, type RepoMeta } from './repo-meta.js'; import { SHARED_STORE_POINTER, storeRootOfCheckoutSlot, type SharedStoreLayout, } from './shared-store.js'; -import { GITNEXUS_DIR, LBUG_DIRECTORY } from './storage-constants.js'; +import { + GITNEXUS_DIR, + INDEX_METADATA_FILE, + LBUG_DIRECTORY, + LEGACY_METADATA_FILE, +} from './storage-constants.js'; type StoreRoot = Pick; @@ -56,6 +61,8 @@ export interface ReclaimResult { kept: string[]; /** Member slots dropped by garbage collection. */ droppedMembers: string[]; + /** Orphaned member slots garbage collection could not delete; still counted as members. */ + keptMembers: string[]; /** The store root was deleted because nothing remained. */ storeRemoved: boolean; } @@ -73,6 +80,31 @@ const listDirStrict = (dir: string): Promise => throw err; }); +/** + * Slot metadata for reclaim decisions, read like `loadMeta` (the legacy + * mirror only when `gitnexus.json` is absent). Only absent metadata means "no + * reference"; an unreadable or unparseable file aborts, because `loadMeta`'s + * null there would delete the commit graph the slot still points at. + */ +const loadMetaStrict = async (slot: string): Promise => { + for (const file of [INDEX_METADATA_FILE, LEGACY_METADATA_FILE]) { + const metaPath = path.join(slot, file); + let raw: string; + try { + raw = await fs.readFile(metaPath, 'utf-8'); + } catch (err) { + if (isMissingFilesystemError(err)) continue; + throw new Error(`Cannot read ${metaPath}: ${(err as Error).message}`, { cause: err }); + } + try { + return JSON.parse(raw) as RepoMeta; + } catch (err) { + throw new Error(`Cannot parse ${metaPath}: ${(err as Error).message}`, { cause: err }); + } + } + return null; +}; + /** * 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 @@ -116,7 +148,13 @@ export const reclaimSharedStoreLocked = async ( // Absolute, so commit dirs compare equal to the resolved graphPath parents // even when GITNEXUS_HOME is relative. const storeRoot = path.resolve(storeRootInput); - const result: ReclaimResult = { removed: [], kept: [], droppedMembers: [], storeRemoved: false }; + const result: ReclaimResult = { + removed: [], + kept: [], + droppedMembers: [], + keptMembers: [], + storeRemoved: false, + }; const checkoutsDir = path.join(storeRoot, 'checkouts'); const commitsDir = path.join(storeRoot, 'commits'); const referenced = new Set(); @@ -140,7 +178,18 @@ export const reclaimSharedStoreLocked = async ( orphans.delete(slot); continue; } - if (!opts.dryRun) await fs.rm(slot, { recursive: true, force: true }); + if (!opts.dryRun) { + try { + await fs.rm(slot, { recursive: true, force: true }); + } catch { + // Like an undeletable graph below: keep it for the next collection + // rather than abort every store after this one. It stays a member, + // so whatever graph it still names is kept too. + orphans.delete(slot); + result.keptMembers.push(slot); + continue; + } + } result.droppedMembers.push(slot); } finally { lock.release(); @@ -149,7 +198,7 @@ export const reclaimSharedStoreLocked = async ( slots = slots.filter((slot) => !orphans.has(slot)); } for (const slot of slots) { - const graphPath = (await loadMeta(slot))?.graphPath; + const graphPath = (await loadMetaStrict(slot))?.graphPath; if (graphPath) referenced.add(path.dirname(path.resolve(graphPath))); } @@ -201,7 +250,7 @@ export const reclaimSharedStore = async ( opts: { gc?: boolean; dryRun?: boolean } = {}, ): Promise => { if (!existsSync(storeRoot)) { - return { removed: [], kept: [], droppedMembers: [], storeRemoved: false }; + return { removed: [], kept: [], droppedMembers: [], keptMembers: [], storeRemoved: false }; } return withStoreLock({ root: storeRoot }, 'publish', () => reclaimSharedStoreLocked(storeRoot, opts), diff --git a/gitnexus/test/integration/shared-store-clean.test.ts b/gitnexus/test/integration/shared-store-clean.test.ts index 139021ad5..2872c069a 100644 --- a/gitnexus/test/integration/shared-store-clean.test.ts +++ b/gitnexus/test/integration/shared-store-clean.test.ts @@ -490,6 +490,80 @@ describe('reclaimSharedStore', () => { rm.mockRestore(); } }); + + // A torn or unreadable gitnexus.json must not read as "references nothing": + // the graph it names would be deleted while the checkout still uses it. + it.each([ + ['invalid JSON', (file: string) => fs.writeFile(file, '{"graphPath": "/trunc')], + ['an unreadable file', (file: string) => fs.mkdir(file)], + ])('aborts instead of deleting graphs when a member has %s as metadata', async (_, corrupt) => { + const graph = await commitGraph('fffffff-6666666666666666'); + const slot = path.join(layout().checkoutsDir, 'wt-000000000000'); + await fs.mkdir(slot, { recursive: true }); + await corrupt(path.join(slot, 'gitnexus.json')); + await member('wt-111111111111', {}); + + await expect(reclaimSharedStore(layout().root)).rejects.toThrow(/wt-000000000000/); + expect(existsSync(graph)).toBe(true); + }); + + it('counts a member with no metadata as referencing nothing', async () => { + const graph = await commitGraph('fffffff-6666666666666666'); + const slot = path.join(layout().checkoutsDir, 'wt-000000000000'); + await fs.mkdir(slot, { recursive: true }); + + const result = await reclaimSharedStore(layout().root); + + expect(result.removed).toEqual([graph]); + expect(existsSync(graph)).toBe(false); + }); + + it('clean --gc keeps a member it cannot delete and still collects every store', async () => { + const gone = '/nonexistent/checkout'; + const stuckGraph = await commitGraph('aaaaaaa-1111111111111111'); + const unreferenced = await commitGraph('bbbbbbb-2222222222222222'); + const stuck = await member('wt-000000000000', { + repoPath: gone, + graphPath: path.join(stuckGraph, 'lbug'), + }); + const dropped = await member('wt-111111111111', { repoPath: gone }); + const other = sharedStoreLayout('repo-fedcba987654', '/tmp/wt'); + const otherGraph = path.join(other.commitsDir, 'ccccccc-3333333333333333'); + await fs.mkdir(otherGraph, { recursive: true }); + const otherSlot = path.join(other.checkoutsDir, 'wt-000000000000'); + await fs.mkdir(otherSlot, { recursive: true }); + await saveMeta(otherSlot, { lastCommit: '', indexedAt: '', repoPath: gone }); + + const realRm = fs.rm; + const rm = vi.spyOn(fs, 'rm').mockImplementation((async ( + target: string, + ...rest: unknown[] + ) => { + if (String(target) === stuck) throw Object.assign(new Error('busy'), { code: 'EBUSY' }); + return (realRm as (...a: unknown[]) => Promise)(target, ...rest); + }) as typeof fs.rm); + const { cleanCommand } = await import('../../src/cli/clean.js'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + let lines: string[]; + try { + await cleanCommand({ gc: true, force: true }); + lines = log.mock.calls.map((c) => String(c[0])); + } finally { + log.mockRestore(); + rm.mockRestore(); + } + + // The stuck member stays a member, so the graph it names stays live. + expect(existsSync(stuck)).toBe(true); + expect(existsSync(stuckGraph)).toBe(true); + expect(existsSync(dropped)).toBe(false); + expect(existsSync(unreferenced)).toBe(false); + // The store after it was still collected, down to its root. + expect(existsSync(other.root)).toBe(false); + expect(lines).toEqual( + expect.arrayContaining([expect.stringMatching(/kept 1 checkout\(s\) it could not delete/)]), + ); + }); }); describe('private graph copies (#3352)', () => {