/** * Clean Command * * Removes the .gitnexus index from the current repository. * Also unregisters it from the global registry. */ import fs from 'fs/promises'; import path from 'path'; import { logger } from '../core/logger.js'; import { findRegistryEntryByRepoPath, findRepo, unregisterRepo, listRegisteredRepos, getStoragePaths, type RegistryEntry, } from '../storage/repo-manager.js'; import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js'; import { formatSlotSize, formatStaleSlotLine } from './stale-branch-format.js'; import { listLocalHeads } from '../storage/git.js'; import { isContainedBranchDir, isDeleteCandidate, listStaleBranchSlots, removeBranchSlot, staleListingBlock, type StaleBranchSlot, } from '../storage/stale-branch-slots.js'; import { cleanParkedLbugSidecars, inspectLbugSidecars, 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 { findLegacyLocalIndex, reclaimAfterSlotRemoval, reclaimSharedStore, removeLegacyLocalIndex, removeSharedStorePointer, withCheckoutSlotLock, type ReclaimResult, } from '../storage/shared-store-lifecycle.js'; type OwnedCwdStorage = { repo: NonNullable>>; entry: RegistryEntry | undefined; storagePath: string; }; const resolveOwnedCwdStorage = async (refusePrefix: string): Promise => { const repo = await findRepo(process.cwd()); if (!repo) { console.log(t('clean.notFoundHere')); return null; } try { const [entries, storagePath] = await Promise.all([ listRegisteredRepos(), requireDeletableStoragePath({ path: repo.repoPath, storagePath: repo.storagePath, }), ]); return { repo, entry: findRegistryEntryByRepoPath(entries, repo.repoPath), storagePath, }; } catch (err) { if (err instanceof StorageDeletionError) { logger.error(`${refusePrefix}${err.message}`); return null; } throw err; } }; const printStaleSlotLines = (message: string, slots: readonly StaleBranchSlot[]): void => { console.log(message); for (const slot of slots) { console.log(` - ${formatStaleSlotLine(slot)}`); } }; const cleanStaleBranchSlots = async (force: boolean): Promise => { const owned = await resolveOwnedCwdStorage('Refusing to clean leftover branch indexes: '); if (!owned) return; const { repo, entry, storagePath } = owned; const slots = await listStaleBranchSlots({ repoPath: repo.repoPath, storagePath, branches: entry?.branches, includeSize: !force, }); const listingBlock = staleListingBlock(slots); if (listingBlock === 'heads-unavailable') { printStaleSlotLines( t('clean.stale.headsUnavailable'), slots.filter((row) => row.reason === 'heads-unavailable'), ); return; } if (listingBlock === 'listing-failed') { console.log(t('clean.stale.listingFailed')); return; } const candidates = slots.filter(isDeleteCandidate); const probeFailed = slots.filter((slot) => slot.reason === 'probe-failed'); if (candidates.length === 0) { if (probeFailed.length > 0) { printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed); return; } console.log(t('clean.stale.none')); return; } if (!force) { printStaleSlotLines(t('clean.stale.preview', { count: candidates.length }), candidates); if (probeFailed.length > 0) { printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed); } console.log(`\n${t('common.runForceConfirm')}`); return; } let deletedAny = false; for (const slot of candidates) { const heads = listLocalHeads(repo.repoPath); if (heads === null) { console.log( deletedAny ? t('clean.stale.remainingSkipped') : t('clean.stale.headsUnavailable'), ); return; } if (heads.includes(slot.branch)) { console.log(t('clean.stale.skippedLive', { branch: slot.branch })); continue; } const result = await removeBranchSlot({ repoPath: repo.repoPath, storagePath, branch: slot.branch, dir: slot.dir, }); if (!result.ok) { console.log(t('clean.stale.failed', { branch: slot.branch })); logger.error({ err: result.error }, 'Failed to delete leftover branch index:'); continue; } deletedAny = true; console.log(t('clean.stale.deleted', { branch: slot.branch })); } if (probeFailed.length > 0) { printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed); } }; 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 (force: boolean): Promise => { const storesDir = path.join(getGlobalDir(), STORES_DIR); // Only a missing stores root means "nothing to collect"; an unreadable one // must fail loudly rather than report success. const names = await fs.readdir(storesDir).catch((err: NodeJS.ErrnoException) => { if (err.code === 'ENOENT') return [] as string[]; throw err; }); if (names.length === 0) { console.log(t('clean.gc.none')); return; } for (const name of names) { const root = path.join(storesDir, name); // Stray files (`.DS_Store`) are not stores. if (!(await fs.stat(root)).isDirectory()) continue; // Without --force this is a preview: same selection, nothing deleted. const result = await reclaimSharedStore(root, { gc: true, dryRun: !force }); console.log( t(force ? 'clean.gc.store' : 'clean.gc.preview', { 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 })); } if (!force) console.log(`\n${t('common.runForceConfirm')}`); }; export const cleanCommand = async (options?: { force?: boolean; all?: boolean; lbugSidecars?: boolean; stale?: boolean; branch?: string; gc?: boolean; localIndex?: boolean; }) => { if (options?.gc) { await collectSharedStores(options.force === true); return; } // --local-index: delete a pre-adoption index left in /.gitnexus // after the checkout moved into a shared store (#3352). Keeps the pointer. if (options?.localIndex) { const repo = await findRepo(process.cwd()); if (!repo) { console.log(t('clean.notFoundHere')); return; } const legacy = await findLegacyLocalIndex(repo.repoPath, repo.storagePath); if (!legacy) { console.log(t('clean.localIndex.none')); return; } if (!options.force) { console.log( t('clean.localIndex.preview', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }), ); console.log(`\n${t('common.runForceConfirm')}`); return; } await removeLegacyLocalIndex(repo.repoPath, repo.storagePath); console.log( t('clean.localIndex.deleted', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }), ); return; } // --stale: reclaim leftover per-branch slots whose recorded branch is not // a live local head (#3331). Exclusive arm before --branch. if (options?.stale) { await cleanStaleBranchSlots(options.force === true); return; } // --branch : remove a single non-primary branch's index (#2106 R7). // Resolve against the RECORDED branches[] summary (never by slugging the // user's raw input, which can disagree with the index-time-sanitized label). if (options?.branch) { const owned = await resolveOwnedCwdStorage('Refusing to clean branch index: '); if (!owned) return; const { repo, entry, storagePath } = owned; const summary = entry?.branches?.find((b) => b.branch === options.branch); if (!summary) { console.log(t('clean.branchNotIndexed', { branch: options.branch })); return; } const { lbugPath } = getStoragePaths(repo.repoPath, summary.branch, storagePath); const branchDir = path.dirname(lbugPath); if (!isContainedBranchDir(storagePath, branchDir)) { logger.error( `Refusing to clean branch index outside the validated storage slot: ${branchDir}`, ); return; } if (!options.force) { console.log(t('clean.deleteBranch', { branch: summary.branch, path: branchDir })); console.log(`\n${t('common.runForceConfirm')}`); return; } const result = await removeBranchSlot({ repoPath: repo.repoPath, storagePath, branch: summary.branch, dir: branchDir, }); if (!result.ok) { logger.error({ err: result.error }, 'Failed to delete branch index:'); return; } console.log(t('clean.deletedBranch', { branch: summary.branch })); return; } if (options?.lbugSidecars) { const cwd = process.cwd(); const repo = await findRepo(cwd); if (!repo) { console.log(t('clean.notFoundHere')); return; } let storagePath: string; try { storagePath = await requireDeletableStoragePath({ path: repo.repoPath, storagePath: repo.storagePath, }); } catch (err) { if (err instanceof StorageDeletionError) { logger.error(`Refusing to clean sidecars: ${err.message}`); return; } throw err; } const { lbugPath } = getStoragePaths(repo.repoPath, undefined, storagePath); const state = await inspectLbugSidecars(lbugPath); // Single roster authority (this shipping review, FIX 5): the aggregate // covers both parked-sidecar families — the timestamped missing-shadow // WAL quarantines AND the fixed-name `.dirty-recovery` parks (`.next` // residues included) left by a dirty-flag recovery rebuild (#2409). The // previous inline concatenations here were how the `.next` residue // stayed invisible to this surface. const quarantined = await listParkedLbugSidecars(lbugPath); console.log(t('clean.lbugSidecars.state', { state: state.kind })); if (quarantined.length === 0) { console.log(t('clean.lbugSidecars.none')); return; } if (!options.force) { console.log(t('clean.lbugSidecars.preview', { count: quarantined.length })); for (const file of quarantined) { console.log(` - ${file}`); } console.log(`\n${t('common.runForceConfirm')}`); return; } const { deleted, failed } = await cleanParkedLbugSidecars(lbugPath); console.log(t('clean.lbugSidecars.deleted', { count: deleted.length })); // A locked parked file no longer crashes the clean mid-command (FIX 5) // — the rest were deleted above; report what remains and why. if (failed.length > 0) { console.log(t('clean.lbugSidecars.failed', { count: failed.length })); for (const file of failed) { console.log(` - ${file}`); } } return; } // --all flag: clean all indexed repos if (options?.all) { const entries = await listRegisteredRepos(); if (!options?.force) { const deletableEntries = []; for (const entry of entries) { try { await requireDeletableStoragePath(entry); deletableEntries.push(entry); } catch (err) { if (err instanceof StorageDeletionError) { logger.error(`Refusing to preview ${entry.name}: ${err.message}`); continue; } throw err; } } if (deletableEntries.length === 0) { console.log(t('common.notIndexed')); return; } console.log(t('clean.deleteAll', { count: deletableEntries.length })); for (const entry of deletableEntries) { console.log(` - ${entry.name} (${entry.path})`); } console.log(`\n${t('common.runForceConfirm')}`); return; } for (const entry of entries) { try { const storagePath = await requireDeletableStoragePath(entry); await withCheckoutSlotLock(storagePath, async () => { await fs.rm(storagePath, { recursive: true, force: true }); await unregisterRepo(entry.path); }); console.log(t('clean.deletedRepo', { name: entry.name, storagePath })); const reclaim = await reclaimAfterSlotRemoval(storagePath); if (reclaim) await removeSharedStorePointer(entry.path); reportReclaim(reclaim); } catch (err) { if (err instanceof StorageDeletionError) { logger.error(`Refusing to clean ${entry.name}: ${err.message}`); continue; } logger.error({ err }, `Failed to delete ${entry.name}:`); } } return; } // Default: clean current repo const cwd = process.cwd(); const repo = await findRepo(cwd); if (!repo) { console.log(t('clean.notFoundHere')); return; } const repoName = repo.repoPath.split(/[/\\]/).pop() || repo.repoPath; let storagePath: string; try { storagePath = await requireDeletableStoragePath({ path: repo.repoPath, storagePath: repo.storagePath, }); } catch (err) { if (err instanceof StorageDeletionError) { logger.error(`Refusing to clean ${repoName}: ${err.message}`); return; } throw err; } if (!options?.force) { console.log(t('clean.deleteCurrent', { repoName })); console.log(` ${t('common.path')}: ${storagePath}`); console.log(`\n${t('common.runForceConfirm')}`); return; } try { await withCheckoutSlotLock(storagePath, async () => { await fs.rm(storagePath, { recursive: true, force: true }); await unregisterRepo(repo.repoPath); }); console.log(t('common.deleted', { target: storagePath })); const reclaim = await reclaimAfterSlotRemoval(storagePath); if (reclaim) await removeSharedStorePointer(repo.repoPath); reportReclaim(reclaim); } catch (err) { logger.error({ err }, 'Failed to delete:'); } };