GitNexus/gitnexus/src/cli/clean.ts
Gergo Magyar 2cdee6d4a5 fix(storage): address fifth review round on the shared store (#3374)
- analyze --no-share leaves the shared store even when routed to a
  branch sub-index (gate on sharedStore, not placement.branch)
- clean --gc aborts a store whose member listing is unreadable instead
  of treating it as empty, and skips non-directory entries under stores/
- reusing an existing commit graph no longer fails a finished analyze
  when the redundant private graph cannot be wiped
- a store pointer holding JSON null, a number, a string, or an array is
  treated as invalid
- clean, remove, and DELETE /api/repo hold the checkout slot's index
  lock while deleting the slot
- the server analyze launcher waits on the checkout slot the worker
  actually writes
- sync the Factory hook copy; isolate hook tests from storage overrides;
  use a junction for the Windows worktree alias; the relative
  GITNEXUS_HOME test now sets a relative home

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:07:05 +00:00

442 lines
14 KiB
TypeScript

/**
* 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<Awaited<ReturnType<typeof findRepo>>>;
entry: RegistryEntry | undefined;
storagePath: string;
};
const resolveOwnedCwdStorage = async (refusePrefix: string): Promise<OwnedCwdStorage | null> => {
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<void> => {
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<void> => {
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 <checkout>/.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 <name>: 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:');
}
};