From 620fd18a5c134c588186a55d80e93ceb4a57621e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 20 Sep 2026 16:03:18 +0100 Subject: [PATCH] feat(cli): reclaim leftover per-branch indexes after branch delete (#3338) * feat(storage): classify leftover per-branch index slots Operators need a shared enumerator for deleted-branch leftovers before clean --stale or doctor can reclaim or report them. Co-authored-by: Cursor * feat(storage): reclaim a per-branch slot and empty branches/ Named clean --branch now shares one rm-then-registry helper so the last leftover slot can drop the empty branches directory, and a failed rm still keeps the retryable summary. Co-authored-by: Cursor * feat(cli): add clean --stale to reclaim leftover branch indexes Operators can drop per-branch slots whose recorded branch is gone without remembering each name, while a git-list failure stays a no-op. Co-authored-by: Cursor * feat(cli): report leftover branch indexes in doctor Operators can see cwd orphaned per-branch slots and their size, then reclaim them with clean --stale, without doctor deleting anything. Co-authored-by: Cursor * fix(review): keep unreadable branch slots out of stale reclaim A stat error other than ENOENT/ENOTDIR must not look like a missing directory, or clean --stale --force drops the registry row and leaves the slot on disk. Co-authored-by: Cursor * refactor(cli): keep leftover-slot display helpers in the CLI layer Preview and doctor share one size formatter and an i18n path for registry-only rows, so storage no longer owns display copy. Co-authored-by: Cursor * fix(review): keep leftover reclaim moving after a registry drop failure Catch removeBranchIndex rejections so --stale continues, match doctor registry rows through canonicalizePath, and size leftover slots sequentially. Co-authored-by: Cursor * fix(review): match leftover-slot registry rows with canonicalizePath Use the repo-manager path contract so clean --stale and --branch still see registry-only leftover rows when cwd and the stored path differ. Co-authored-by: Cursor * fix(review): contain leftover-slot deletes and re-check live heads Refuse symlink and junction escapes under branches/, unlink slot links instead of removing through them, and skip --stale --force when a name is a local head again. Co-authored-by: Cursor * Address PR review feedback (#3338) - Bound listLocalHeads spawnSync with GIT_PATH_LIST_MAX_BUFFER. - Clarify that doctor leftover reporting is cwd-only, not registry-wide. - Drop the MCP/serve assumption from clean --stale delete failures. Co-authored-by: Cursor * Address PR review feedback (#3338) - Describe disk-only leftover slots in --stale help, not only recorded branches. Co-authored-by: Cursor * fix(cli): stop doctor reclaim copy when heads cannot be listed Doctor was naming clean --stale for leftover rows even when git cannot list local heads, which is a no-op. Print the retry-git message instead (#3337). Co-authored-by: Cursor * revert: drop Unreleased changelog notes from this branch Co-authored-by: Cursor * fix(cli): keep live branch pins when a tag shares the name %(refname:short) disambiguates against tags, so clean --stale treated still-local heads as leftover. Fail closed on obstructed slots and unlistable branches/ directories. Co-authored-by: Cursor * Address PR review feedback (#3338) Bound leftover-slot listing, revalidate paths immediately before delete, and keep registry rows when a stray disk-only directory claims a recorded branch. Co-authored-by: Cursor --------- Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- gitnexus/src/cli/clean.ts | 133 +++- gitnexus/src/cli/doctor.ts | 59 ++ gitnexus/src/cli/help-i18n.ts | 1 + gitnexus/src/cli/i18n/en.ts | 27 +- gitnexus/src/cli/i18n/zh-CN.ts | 25 +- gitnexus/src/cli/index.ts | 1 + gitnexus/src/cli/stale-branch-format.ts | 37 ++ gitnexus/src/storage/git.ts | 30 +- gitnexus/src/storage/repo-manager.ts | 9 + gitnexus/src/storage/stale-branch-slots.ts | 384 ++++++++++++ .../clean-stale-branch-slots.test.ts | 144 +++++ .../test/unit/clean-command-ownership.test.ts | 57 ++ gitnexus/test/unit/clean-stale.test.ts | 353 +++++++++++ gitnexus/test/unit/doctor-format.test.ts | 83 ++- gitnexus/test/unit/git.test.ts | 67 +- .../test/unit/repo-manager-rm-failure.test.ts | 92 +++ gitnexus/test/unit/stale-branch-slots.test.ts | 590 ++++++++++++++++++ 17 files changed, 2075 insertions(+), 17 deletions(-) create mode 100644 gitnexus/src/cli/stale-branch-format.ts create mode 100644 gitnexus/src/storage/stale-branch-slots.ts create mode 100644 gitnexus/test/integration/clean-stale-branch-slots.test.ts create mode 100644 gitnexus/test/unit/clean-stale.test.ts create mode 100644 gitnexus/test/unit/stale-branch-slots.test.ts diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index f545f8211..cac9f69d4 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -9,13 +9,22 @@ import fs from 'fs/promises'; import path from 'path'; import { logger } from '../core/logger.js'; import { + findRegistryEntryByRepoPath, findRepo, unregisterRepo, listRegisteredRepos, getStoragePaths, - removeBranchIndex, } from '../storage/repo-manager.js'; import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js'; +import { formatStaleSlotLine } from './stale-branch-format.js'; +import { listLocalHeads } from '../storage/git.js'; +import { + isContainedBranchDir, + isDeleteCandidate, + listStaleBranchSlots, + removeBranchSlot, + staleListingBlock, +} from '../storage/stale-branch-slots.js'; import { cleanParkedLbugSidecars, inspectLbugSidecars, @@ -27,8 +36,107 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean; lbugSidecars?: boolean; + stale?: boolean; branch?: string; }) => { + // --stale: reclaim leftover per-branch slots whose recorded branch is not + // a live local head (#3331). Exclusive arm before --branch. + if (options?.stale) { + const cwd = process.cwd(); + const repo = await findRepo(cwd); + if (!repo) { + console.log(t('clean.notFoundHere')); + return; + } + const entries = await listRegisteredRepos(); + const entry = findRegistryEntryByRepoPath(entries, 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 leftover branch indexes: ${err.message}`); + return; + } + throw err; + } + const slots = await listStaleBranchSlots({ + repoPath: repo.repoPath, + storagePath, + branches: entry?.branches, + includeSize: !options.force, + }); + const listingBlock = staleListingBlock(slots); + if (listingBlock === 'heads-unavailable') { + console.log(t('clean.stale.headsUnavailable')); + for (const slot of slots.filter((row) => row.reason === 'heads-unavailable')) { + console.log(` - ${formatStaleSlotLine(slot)}`); + } + 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'); + const printProbeFailed = (): void => { + console.log(t('clean.stale.probeFailed')); + for (const slot of probeFailed) { + console.log(` - ${formatStaleSlotLine(slot)}`); + } + }; + if (candidates.length === 0) { + if (probeFailed.length > 0) { + printProbeFailed(); + return; + } + console.log(t('clean.stale.none')); + return; + } + if (!options.force) { + console.log(t('clean.stale.preview', { count: candidates.length })); + for (const slot of candidates) { + console.log(` - ${formatStaleSlotLine(slot)}`); + } + if (probeFailed.length > 0) { + printProbeFailed(); + } + console.log(`\n${t('common.runForceConfirm')}`); + return; + } + for (const slot of candidates) { + const heads = listLocalHeads(repo.repoPath); + if (heads === null) { + console.log(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; + } + console.log(t('clean.stale.deleted', { branch: slot.branch })); + } + if (probeFailed.length > 0) { + printProbeFailed(); + } + 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). @@ -40,7 +148,7 @@ export const cleanCommand = async (options?: { return; } const entries = await listRegisteredRepos(); - const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo.repoPath)); + const entry = findRegistryEntryByRepoPath(entries, repo.repoPath); const summary = entry?.branches?.find((b) => b.branch === options.branch); if (!summary) { console.log(t('clean.branchNotIndexed', { branch: options.branch })); @@ -61,10 +169,7 @@ export const cleanCommand = async (options?: { } const { lbugPath } = getStoragePaths(repo.repoPath, summary.branch, storagePath); const branchDir = path.dirname(lbugPath); - // Safety guard: the target MUST live under the validated - // storage slot's `branches/` directory before any destructive fs.rm. - const branchesRoot = path.join(storagePath, 'branches') + path.sep; - if (!branchDir.startsWith(branchesRoot)) { + if (!isContainedBranchDir(storagePath, branchDir)) { logger.error( `Refusing to clean branch index outside the validated storage slot: ${branchDir}`, ); @@ -75,13 +180,17 @@ export const cleanCommand = async (options?: { console.log(`\n${t('common.runForceConfirm')}`); return; } - try { - await fs.rm(branchDir, { recursive: true, force: true }); - await removeBranchIndex(repo.repoPath, summary.branch); - console.log(t('clean.deletedBranch', { branch: summary.branch })); - } catch (err) { - logger.error({ err }, 'Failed to delete branch index:'); + 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; } diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index 909bd945d..2f1d909c2 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -34,6 +34,18 @@ import { updateEligibleInstallSync } from '../core/install-context.js'; import { readValidatedUpdateCacheSync, type ValidatedUpdateCache } from '../core/update-cache.js'; import { t } from './i18n/index.js'; import { cachedUpdateNoticeLine } from './update-notice.js'; +import { formatSlotSize, staleReasonLabel } from './stale-branch-format.js'; +import { + findRegistryEntryByRepoPath, + findRepo, + listRegisteredRepos, +} from '../storage/repo-manager.js'; +import { + isDeleteCandidate, + listStaleBranchSlots, + staleListingBlock, + type StaleBranchSlot, +} from '../storage/stale-branch-slots.js'; function isCombiningMark(codePoint: number): boolean { return ( @@ -191,6 +203,35 @@ export function nativeStatusLine(check: NativeCheckResult): string { return ` ${padDisplayEnd('native', 10)}${nativeStatusText(check)}`; } +/** + * Cwd leftover-slot lines (#3331). Pure: no deletes and no registry scan. + * When heads cannot be listed, do not title rows as orphaned or name + * `clean --stale` (#3337): that command refuses to delete in the same state. + */ +export function orphanedBranchSlotDoctorLines(slots: StaleBranchSlot[]): string[] { + if (slots.length === 0) return []; + const listingBlock = staleListingBlock(slots); + if (listingBlock === 'heads-unavailable') { + return [t('clean.stale.headsUnavailable')]; + } + if (listingBlock === 'listing-failed') { + return [t('clean.stale.listingFailed')]; + } + const lines = [t('doctor.orphanedBranches')]; + let total = 0; + for (const slot of slots) { + total += slot.sizeBytes; + lines.push( + ` ${slot.branch} ${staleReasonLabel(slot.reason)} ${formatSlotSize(slot.sizeBytes)}`, + ); + } + lines.push(` ${t('doctor.orphanedBranches.total', { size: formatSlotSize(total) })}`); + if (slots.some(isDeleteCandidate)) { + lines.push(` ${t('doctor.orphanedBranches.reclaim')}`); + } + return lines; +} + function nativeStatusText(check: NativeCheckResult): string { if (check.ok) return '✓ lbugjs.node loaded'; switch (check.kind) { @@ -359,4 +400,22 @@ export const doctorCommand = async () => { console.log(` ${padDisplayEnd('', 12)}${cudaRedirect.detail}`); } } + // Doctor stays runtime-global. Add only a cwd leftover-slot section when + // this process is inside an indexed repo. Look up that repo's registry row + // for recorded branch slugs; do not report leftovers for every registered + // repo, and never delete. + const [cwdRepo, entries] = await Promise.all([findRepo(process.cwd()), listRegisteredRepos()]); + if (!cwdRepo) return; + const entry = findRegistryEntryByRepoPath(entries, cwdRepo.repoPath); + const slots = await listStaleBranchSlots({ + repoPath: cwdRepo.repoPath, + storagePath: cwdRepo.storagePath, + branches: entry?.branches, + }); + const orphanLines = orphanedBranchSlotDoctorLines(slots); + if (orphanLines.length === 0) return; + console.log(''); + for (const line of orphanLines) { + console.log(line); + } }; diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 10d69263f..95322f72c 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -96,6 +96,7 @@ const OPTION_DESCRIPTION_KEYS = { 'clean|--all': 'help.option.clean.all', 'clean|--branch ': 'help.option.clean.branch', 'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars', + 'clean|--stale': 'help.option.clean.stale', 'remove|-f, --force': 'help.option.force.confirmation', 'wiki|-f, --force': 'help.option.wiki.force', 'wiki|--provider ': 'help.option.wiki.provider', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 1198fdafd..aeee8fbd2 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -60,7 +60,28 @@ export const en = { 'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})', 'clean.notFoundHere': 'No indexed repository found in this directory.', 'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}', - 'clean.branchNotIndexed': 'No indexed branch named "{{branch}}" for this repository.', + '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.', + 'clean.stale.none': 'No leftover branch indexes to reclaim.', + 'clean.stale.preview': 'This will delete {{count}} leftover branch index(es):', + 'clean.stale.item': '{{branch}} {{reason}} {{path}} {{size}}', + 'clean.stale.registryOnlyPath': '(registry only)', + 'clean.stale.headsUnavailable': + 'Could not list local heads; leftover branch indexes were not deleted. Re-run `gitnexus clean --stale` when git is available.', + 'clean.stale.listingFailed': + 'Could not read leftover branch index directories; leftover indexes were not deleted. Check permissions on the branches/ directory and re-run `gitnexus clean --stale`.', + 'clean.stale.probeFailed': + 'Could not inspect leftover branch index path(s); those slots were not deleted.', + 'clean.stale.deleted': 'Deleted leftover branch index: {{branch}}', + 'clean.stale.failed': 'Could not delete leftover branch index "{{branch}}".', + 'clean.stale.skippedLive': + 'Skipped leftover branch index "{{branch}}" — it is a local head again.', + 'clean.stale.reason.refMissing': 'not a local head', + 'clean.stale.reason.diskOnly': 'leftover directory (no registry row)', + 'clean.stale.reason.registryOnly': 'registry row (directory gone)', + 'clean.stale.reason.headsUnavailable': 'could not list local heads', + 'clean.stale.reason.probeFailed': 'could not inspect slot path', + 'clean.stale.reason.listingFailed': 'could not list leftover directories', 'clean.deleteBranch': 'This will delete the branch index "{{branch}}" at: {{path}}', 'clean.deletedBranch': 'Deleted branch index: {{branch}}', 'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}', @@ -116,6 +137,9 @@ export const en = { 'doctor.runtime': 'Runtime', 'doctor.capabilities': 'Capabilities', 'doctor.embeddings': 'Embeddings', + 'doctor.orphanedBranches': 'Orphaned branch indexes', + 'doctor.orphanedBranches.total': 'Total: {{size}}', + 'doctor.orphanedBranches.reclaim': 'Reclaim with: gitnexus clean --stale', 'doctor.labels.os': 'OS:', 'doctor.labels.node': 'Node:', 'doctor.labels.gitnexus': 'GitNexus:', @@ -283,6 +307,7 @@ export const en = { 'help.option.clean.branch': 'Delete only the named branch index (not the workspace index)', 'help.option.clean.lbugSidecars': 'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)', + 'help.option.clean.stale': 'Reclaim leftover branch indexes that are not a live local head', 'help.option.wiki.force': 'Force full regeneration even if up to date', 'help.option.wiki.provider': 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 03f48adba..6d342abef 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -59,7 +59,26 @@ export const zhCN = { 'clean.deletedRepo': '已删除:{{name}}({{storagePath}})', 'clean.notFoundHere': '当前目录未找到已索引仓库。', 'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}', - 'clean.branchNotIndexed': '该仓库没有名为 “{{branch}}” 的已索引分支。', + 'clean.branchNotIndexed': + '该仓库没有名为 “{{branch}}” 的已索引分支。使用 `gitnexus clean --stale` 回收残留分支索引,或使用 `gitnexus list` 查看已记录名称。', + 'clean.stale.none': '没有可回收的残留分支索引。', + 'clean.stale.preview': '将删除 {{count}} 个残留分支索引:', + 'clean.stale.item': '{{branch}} {{reason}} {{path}} {{size}}', + 'clean.stale.registryOnlyPath': '(仅注册表)', + 'clean.stale.headsUnavailable': + '无法列出本地分支,因此未删除残留分支索引。请在 git 可用后重新运行 `gitnexus clean --stale`。', + 'clean.stale.listingFailed': + '无法读取残留分支索引目录,因此未删除残留索引。请检查 branches/ 目录权限后重新运行 `gitnexus clean --stale`。', + 'clean.stale.probeFailed': '无法检查残留分支索引路径,因此未删除这些槽位。', + 'clean.stale.deleted': '已删除残留分支索引:{{branch}}', + 'clean.stale.failed': '无法删除残留分支索引 “{{branch}}”。', + 'clean.stale.skippedLive': '已跳过残留分支索引 “{{branch}}” — 它再次成为本地分支。', + 'clean.stale.reason.refMissing': '不是本地分支', + 'clean.stale.reason.diskOnly': '残留目录(无注册表记录)', + 'clean.stale.reason.registryOnly': '注册表记录(目录已不存在)', + 'clean.stale.reason.headsUnavailable': '无法列出本地分支', + 'clean.stale.reason.probeFailed': '无法检查槽位路径', + 'clean.stale.reason.listingFailed': '无法列出残留目录', 'clean.deleteBranch': '将删除分支索引 “{{branch}}”,路径:{{path}}', 'clean.deletedBranch': '已删除分支索引:{{branch}}', 'clean.lbugSidecars.state': 'LadybugDB sidecar 状态:{{state}}', @@ -112,6 +131,9 @@ export const zhCN = { 'doctor.runtime': '运行时', 'doctor.capabilities': '能力', 'doctor.embeddings': '嵌入', + 'doctor.orphanedBranches': '残留分支索引', + 'doctor.orphanedBranches.total': '合计:{{size}}', + 'doctor.orphanedBranches.reclaim': '回收命令:gitnexus clean --stale', 'doctor.labels.os': '系统:', 'doctor.labels.node': 'Node:', 'doctor.labels.gitnexus': 'GitNexus:', @@ -262,6 +284,7 @@ export const zhCN = { 'help.option.clean.branch': '仅删除指定分支的索引(不影响工作区索引)', 'help.option.clean.lbugSidecars': '清理已暂存的 LadybugDB 恢复 sidecar(missing-shadow WAL 隔离文件与 dirty-recovery 暂存文件)', + 'help.option.clean.stale': '回收已不是本地 head 的残留分支索引', 'help.option.wiki.force': '即使已是最新也强制完整重新生成', 'help.option.wiki.provider': 'LLM 提供商:minimax、openai、openrouter、azure、custom、cursor、claude、codex、opencode 或 grok(默认:minimax)', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index a46606eca..01e7be835 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -349,6 +349,7 @@ program .option('-f, --force', 'Skip confirmation prompt') .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( '--lbug-sidecars', 'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)', diff --git a/gitnexus/src/cli/stale-branch-format.ts b/gitnexus/src/cli/stale-branch-format.ts new file mode 100644 index 000000000..6cc4017ba --- /dev/null +++ b/gitnexus/src/cli/stale-branch-format.ts @@ -0,0 +1,37 @@ +import { t } from './i18n/index.js'; +import { type StaleBranchReason, type StaleBranchSlot } from '../storage/stale-branch-slots.js'; + +export const formatSlotSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +export const staleReasonLabel = (reason: StaleBranchReason): string => { + switch (reason) { + case 'ref-missing': + return t('clean.stale.reason.refMissing'); + case 'disk-only': + return t('clean.stale.reason.diskOnly'); + case 'registry-only': + return t('clean.stale.reason.registryOnly'); + case 'heads-unavailable': + return t('clean.stale.reason.headsUnavailable'); + case 'probe-failed': + return t('clean.stale.reason.probeFailed'); + case 'listing-failed': + return t('clean.stale.reason.listingFailed'); + default: { + const _exhaustive: never = reason; + return _exhaustive; + } + } +}; + +export const formatStaleSlotLine = (slot: StaleBranchSlot): string => + t('clean.stale.item', { + branch: slot.branch, + reason: staleReasonLabel(slot.reason), + path: slot.dir ?? t('clean.stale.registryOnlyPath'), + size: formatSlotSize(slot.sizeBytes), + }); diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index b1b0adad0..919f8aef8 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -1,4 +1,4 @@ -import { execFileSync, execSync } from 'child_process'; +import { execFileSync, execSync, spawnSync } from 'child_process'; import { statSync, existsSync } from 'fs'; import path from 'path'; import os from 'os'; @@ -665,6 +665,34 @@ export const getCurrentBranch = (repoPath: string): string | null => { } }; +/** + * Local `refs/heads` names, or `null` when the directory is not a git + * worktree or git cannot run. An empty array means the listing succeeded + * and there are no local heads — that is not a listing failure (#3331). + */ +export const listLocalHeads = (repoPath: string): string[] | null => { + try { + const result = spawnSync('git', ['for-each-ref', '--format=%(refname)', 'refs/heads'], { + cwd: repoPath, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + maxBuffer: GIT_PATH_LIST_MAX_BUFFER, + }); + if (result.error || result.status !== 0) return null; + const output = (result.stdout ?? '').toString().trim(); + if (!output) return []; + return output + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('refs/heads/')) + .map((line) => line.slice('refs/heads/'.length)) + .filter((line) => line.length > 0); + } catch { + return null; + } +}; + /** * Sanitize a repository name to prevent argument injection and ensure * cross-platform filesystem compatibility. diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index aee05358f..5bc4ac1c9 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -191,6 +191,15 @@ export interface RegistryEntry { branches?: BranchSummary[]; } +/** Path-only registry lookup. Canonicalizes `repoPath` once. Does not throw. */ +export const findRegistryEntryByRepoPath = ( + entries: readonly RegistryEntry[], + repoPath: string, +): RegistryEntry | undefined => { + const repoKey = canonicalizePath(repoPath); + return entries.find((entry) => registryPathEquals(canonicalizePath(entry.path), repoKey)); +}; + const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`; // ─── Local Storage Helpers ───────────────────────────────────────────── diff --git a/gitnexus/src/storage/stale-branch-slots.ts b/gitnexus/src/storage/stale-branch-slots.ts new file mode 100644 index 000000000..8a6f69981 --- /dev/null +++ b/gitnexus/src/storage/stale-branch-slots.ts @@ -0,0 +1,384 @@ +/** + * Classify and reclaim leftover per-branch index slots (#3331). + * + * Live means a name in local `refs/heads`. Classification never reverses + * `branchSlug`; registry rows join through the same forward slug path + * `clean --branch` already computes. Directory delete happens before the + * registry drop; a failed rm keeps the summary so a later clean can retry. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { BRANCHES_DIR } from './branch-index.js'; +import { listLocalHeads } from './git.js'; +import { isMissingFilesystemError, loadMeta } from './repo-meta.js'; +import { getStoragePaths, removeBranchIndex } from './repo-manager.js'; + +export type StaleBranchReason = + | 'ref-missing' + | 'registry-only' + | 'disk-only' + | 'heads-unavailable' + | 'probe-failed' + | 'listing-failed'; + +export interface StaleBranchSlot { + branch: string; + dir: string | null; + sizeBytes: number; + reason: StaleBranchReason; +} + +export interface ListStaleBranchSlotsInput { + repoPath: string; + storagePath: string; + branches?: readonly { branch: string }[]; + /** Injected in tests. When omitted, listed from `repoPath`. */ + heads?: string[] | null; + /** Default true. `--stale --force` skips the size walk; it never prints sizes. */ + includeSize?: boolean; +} + +const slotDirForBranch = (repoPath: string, storagePath: string, branch: string): string => + path.dirname(getStoragePaths(repoPath, branch, storagePath).metaPath); + +/** Same bound as `mapPool` in repo-manager: cap concurrent slot I/O. */ +const STALE_SLOT_IO_CONCURRENCY = 8; + +const mapPool = async ( + items: readonly T[], + mapper: (item: T) => Promise, +): Promise => { + if (items.length === 0) return []; + const results = new Array(items.length); + let next = 0; + const workerCount = Math.max(1, Math.min(STALE_SLOT_IO_CONCURRENCY, items.length)); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (true) { + const index = next; + next += 1; + if (index >= items.length) return; + results[index] = await mapper(items[index] as T); + } + }), + ); + return results; +}; + +/** Proven directory, proven absence, or a probe error that is not ENOENT/ENOTDIR. */ +type DirectoryProbe = 'dir' | 'missing' | 'unreadable'; + +const probeDirectory = async (dir: string): Promise => { + try { + return (await fs.stat(dir)).isDirectory() ? 'dir' : 'unreadable'; + } catch (err) { + return isMissingFilesystemError(err) ? 'missing' : 'unreadable'; + } +}; + +const directorySizeBytes = async (root: string): Promise => { + let total = 0; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) break; + let entries; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => path.join(current, entry.name)); + for (const entry of entries) { + if (entry.isDirectory()) stack.push(path.join(current, entry.name)); + } + for (const file of files) { + try { + total += (await fs.stat(file)).size; + } catch { + // Skip files that disappear or become unreadable mid-walk. + } + } + } + return total; +}; + +const metadataBranch = async (dir: string): Promise => { + const meta = await loadMeta(dir); + return typeof meta?.branch === 'string' && meta.branch.length > 0 ? meta.branch : null; +}; + +export const listStaleBranchSlots = async ( + input: ListStaleBranchSlotsInput, +): Promise => { + const recorded = input.branches ?? []; + const branchesRoot = path.join(input.storagePath, BRANCHES_DIR); + + const registryByDir = new Map(); + for (const row of recorded) { + registryByDir.set( + path.resolve(slotDirForBranch(input.repoPath, input.storagePath, row.branch)), + row.branch, + ); + } + + let diskDirs: string[] = []; + try { + const entries = await fs.readdir(branchesRoot, { withFileTypes: true }); + diskDirs = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(branchesRoot, entry.name)); + } catch (err) { + if (!isMissingFilesystemError(err)) { + return [{ branch: '', dir: null, sizeBytes: 0, reason: 'listing-failed' }]; + } + diskDirs = []; + } + + if (recorded.length === 0 && diskDirs.length === 0) return []; + + const heads = input.heads !== undefined ? input.heads : listLocalHeads(input.repoPath); + const live = heads === null ? null : new Set(heads); + + const pending: Array> = []; + + const registryProbes = await mapPool([...registryByDir], async ([resolvedDir, branch]) => ({ + resolvedDir, + branch, + probe: await probeDirectory(resolvedDir), + })); + for (const { resolvedDir, branch, probe } of registryProbes) { + if (probe === 'unreadable') { + pending.push({ branch, dir: resolvedDir, reason: 'probe-failed' }); + continue; + } + const exists = probe === 'dir'; + const dir = exists ? resolvedDir : null; + if (live === null) { + pending.push({ branch, dir, reason: 'heads-unavailable' }); + continue; + } + if (!exists) { + pending.push({ branch, dir: null, reason: 'registry-only' }); + continue; + } + if (!live.has(branch)) { + pending.push({ branch, dir, reason: 'ref-missing' }); + } + } + + const leftoverDirs = diskDirs.filter((dir) => !registryByDir.has(path.resolve(dir))); + const leftoverMeta = await mapPool(leftoverDirs, async (dir) => ({ + dir, + branch: await metadataBranch(dir), + })); + for (const { dir, branch } of leftoverMeta) { + if (branch === null) continue; + const resolved = path.resolve(dir); + if (live === null) { + pending.push({ branch, dir: resolved, reason: 'heads-unavailable' }); + continue; + } + if (!live.has(branch)) { + pending.push({ branch, dir: resolved, reason: 'disk-only' }); + } + } + + const includeSize = input.includeSize !== false; + return mapPool(pending, async (row) => ({ + ...row, + sizeBytes: includeSize && row.dir ? await directorySizeBytes(row.dir) : 0, + })); +}; + +/** Lexical / realpath containment: `child` is a proper descendant of `parent`. */ +const isProperChildPath = (parent: string, child: string): boolean => { + const root = path.resolve(parent); + const resolved = path.resolve(child); + const relative = path.relative(root, resolved); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +}; + +const isSameNormalizedPath = (left: string, right: string): boolean => + path.relative(path.resolve(path.normalize(left)), path.resolve(path.normalize(right))) === ''; + +export const isContainedBranchDir = (storagePath: string, dir: string): boolean => + isProperChildPath(path.resolve(storagePath, BRANCHES_DIR), dir); + +const toError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err))); + +const keepRegistryFailure = (error: Error): RemoveBranchSlotResult => ({ + ok: false, + emptiedBranchesDir: false, + keptRegistry: true, + error, +}); + +const refuseOutsideSlot = (dir: string): RemoveBranchSlotResult => + keepRegistryFailure( + new Error(`Refusing to clean branch index outside the validated storage slot: ${dir}`), + ); + +const slotPathExists = async (slotDir: string): Promise => { + try { + await fs.lstat(slotDir); + return true; + } catch (err) { + return !isMissingFilesystemError(err); + } +}; + +/** + * Delete a lexically contained slot. Returns a failure result, or `null` when + * the slot path is gone and the registry row may drop. + */ +const removeValidatedSlotDir = async ( + storagePath: string, + dir: string, +): Promise => { + if (!isContainedBranchDir(storagePath, dir)) { + return refuseOutsideSlot(dir); + } + + const branchesRoot = path.resolve(storagePath, BRANCHES_DIR); + + let branchesStat: Awaited>; + try { + branchesStat = await fs.lstat(branchesRoot); + } catch (err) { + if (isMissingFilesystemError(err)) return null; + return keepRegistryFailure(toError(err)); + } + + // Never walk a branches/ symlink (rm of a child would delete the target). + if (branchesStat.isSymbolicLink()) { + return refuseOutsideSlot(dir); + } + + let realStorage: string; + let realBranches: string; + try { + realStorage = await fs.realpath(storagePath); + realBranches = await fs.realpath(branchesRoot); + } catch (err) { + if (isMissingFilesystemError(err)) return null; + return keepRegistryFailure(toError(err)); + } + + // Junctions may not report as symlinks from lstat; realpath must still land + // on storagePath/branches, not an outside tree. + const expectedBranches = path.normalize(path.join(realStorage, BRANCHES_DIR)); + if (!isSameNormalizedPath(realBranches, expectedBranches)) { + return refuseOutsideSlot(dir); + } + + let slotStat: Awaited>; + try { + slotStat = await fs.lstat(dir); + } catch (err) { + if (isMissingFilesystemError(err)) return null; + return keepRegistryFailure(toError(err)); + } + + let deleteError: Error | undefined; + try { + // A symlink, or a Windows junction that lstat reports as a directory, + // must be unlinked at the lexical path. Never fs.rm through a target + // that realpath places outside branches/. + const realDir = slotStat.isSymbolicLink() ? null : await fs.realpath(dir); + const unlinkOnly = + slotStat.isSymbolicLink() || (realDir !== null && !isProperChildPath(realBranches, realDir)); + + // Revalidate immediately before the destructive op. Another process can + // replace branches/ or the slot after the earlier lstat/realpath awaits. + const lastBranches = await fs.lstat(branchesRoot); + if (lastBranches.isSymbolicLink()) { + return refuseOutsideSlot(dir); + } + const lastSlot = await fs.lstat(dir); + const lastUnlinkOnly = lastSlot.isSymbolicLink() || unlinkOnly; + if (lastUnlinkOnly) { + await fs.unlink(dir); + } else { + const lastReal = await fs.realpath(dir); + if (!isProperChildPath(realBranches, lastReal)) { + await fs.unlink(dir); + } else { + await fs.rm(dir, { recursive: true, force: true }); + } + } + } catch (err) { + deleteError = toError(err); + } + + if (await slotPathExists(dir)) { + return keepRegistryFailure(deleteError ?? new Error(`Could not remove branch index: ${dir}`)); + } + return null; +}; + +export const isDeleteCandidate = (slot: StaleBranchSlot): boolean => + slot.reason === 'ref-missing' || slot.reason === 'registry-only' || slot.reason === 'disk-only'; + +export type StaleListingBlock = 'heads-unavailable' | 'listing-failed'; + +/** Git-list or branches/ listing failed; clean and doctor must not reclaim. */ +export const staleListingBlock = (slots: readonly StaleBranchSlot[]): StaleListingBlock | null => { + if (slots.some((slot) => slot.reason === 'heads-unavailable')) return 'heads-unavailable'; + if (slots.some((slot) => slot.reason === 'listing-failed')) return 'listing-failed'; + return null; +}; + +export interface RemoveBranchSlotInput { + repoPath: string; + storagePath: string; + branch: string; + /** Slot directory to remove, or `null` for a registry-only row. */ + dir: string | null; +} + +export interface RemoveBranchSlotResult { + ok: boolean; + emptiedBranchesDir: boolean; + keptRegistry: boolean; + error?: Error; +} + +const rmdirEmptyBranches = async (storagePath: string): Promise => { + try { + await fs.rmdir(path.join(storagePath, BRANCHES_DIR)); + return true; + } catch { + return false; + } +}; + +export const removeBranchSlot = async ( + input: RemoveBranchSlotInput, +): Promise => { + const { repoPath, storagePath, branch, dir } = input; + if (dir !== null) { + const slotDirError = await removeValidatedSlotDir(storagePath, dir); + if (slotDirError) return slotDirError; + } + + const dropRegistry = + dir === null || isSameNormalizedPath(dir, slotDirForBranch(repoPath, storagePath, branch)); + if (dropRegistry) { + try { + await removeBranchIndex(repoPath, branch); + } catch (err) { + return keepRegistryFailure(toError(err)); + } + } + const emptiedBranchesDir = await rmdirEmptyBranches(storagePath); + return { ok: true, emptiedBranchesDir, keptRegistry: !dropRegistry }; +}; diff --git a/gitnexus/test/integration/clean-stale-branch-slots.test.ts b/gitnexus/test/integration/clean-stale-branch-slots.test.ts new file mode 100644 index 000000000..9f594ce50 --- /dev/null +++ b/gitnexus/test/integration/clean-stale-branch-slots.test.ts @@ -0,0 +1,144 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanCommand } from '../../src/cli/clean.js'; +import { branchSlug } from '../../src/storage/branch-index.js'; +import { + listRegisteredRepos, + registerRepo, + saveMeta, + type RepoMeta, +} from '../../src/storage/repo-manager.js'; +import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js'; +import { createTempDir, type TestDBHandle } from '../helpers/test-db.js'; + +/** + * Issue #3331 repro: a pinned per-branch slot survives deleting the git + * branch; `clean --stale --force` reclaims it without naming the branch. + */ +describe('clean --stale leftover branch slots (#3331)', () => { + let home: TestDBHandle; + let fixture: TestDBHandle; + let savedHome: string | undefined; + + beforeEach(async () => { + home = await createTempDir('gitnexus-clean-stale-home-'); + fixture = await createTempDir('gitnexus-clean-stale-repo-'); + savedHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = home.dbPath; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + await fixture.cleanup(); + await home.cleanup(); + }); + + it('reclaims a slot after the git branch is deleted', async () => { + const repo = fixture.dbPath; + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n'); + commitAll(repo, 'init'); + execSync('git branch -M main', { cwd: repo, stdio: 'ignore', windowsHide: true }); + execSync('git branch feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true }); + + const storagePath = path.join(repo, '.gitnexus'); + const meta = (branch: string): RepoMeta => ({ + repoPath: repo, + lastCommit: 'aaa', + indexedAt: '2026-09-20T00:00:00.000Z', + branch, + stats: { files: 1, nodes: 1 }, + }); + await saveMeta(storagePath, meta('main')); + await registerRepo(repo, meta('main')); + await registerRepo(repo, meta('feature/x'), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, meta('feature/x')); + await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}'); + + execSync('git branch -D feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true }); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await cleanCommand({ stale: true, force: true }); + + await expect(fs.access(dir)).rejects.toThrow(); + await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow(); + await expect(fs.readFile(path.join(storagePath, 'parse-cache.json'), 'utf8')).resolves.toBe( + '{}', + ); + const [entry] = await listRegisteredRepos(); + expect(entry.branches).toBeUndefined(); + }); + + it('keeps a live slot when a tag shares the branch name', async () => { + const repo = fixture.dbPath; + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n'); + commitAll(repo, 'init'); + execSync('git branch -M main', { cwd: repo, stdio: 'ignore', windowsHide: true }); + execSync('git branch feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true }); + execSync('git tag feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true }); + + const storagePath = path.join(repo, '.gitnexus'); + const meta = (branch: string): RepoMeta => ({ + repoPath: repo, + lastCommit: 'aaa', + indexedAt: '2026-09-20T00:00:00.000Z', + branch, + stats: { files: 1, nodes: 1 }, + }); + await saveMeta(storagePath, meta('main')); + await registerRepo(repo, meta('main')); + await registerRepo(repo, meta('feature/x'), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, meta('feature/x')); + + vi.spyOn(process, 'cwd').mockReturnValue(repo); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await cleanCommand({ stale: true, force: true }); + + await expect(fs.access(dir)).resolves.toBeUndefined(); + const [entry] = await listRegisteredRepos(); + expect(entry.branches?.map((row) => row.branch)).toContain('feature/x'); + }); + + it('reclaims a slot after the branch is deleted even if a tag keeps the name', async () => { + const repo = fixture.dbPath; + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n'); + commitAll(repo, 'init'); + execSync('git branch -M main', { cwd: repo, stdio: 'ignore', windowsHide: true }); + execSync('git branch feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true }); + execSync('git tag feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true }); + + const storagePath = path.join(repo, '.gitnexus'); + const meta = (branch: string): RepoMeta => ({ + repoPath: repo, + lastCommit: 'aaa', + indexedAt: '2026-09-20T00:00:00.000Z', + branch, + stats: { files: 1, nodes: 1 }, + }); + await saveMeta(storagePath, meta('main')); + await registerRepo(repo, meta('main')); + await registerRepo(repo, meta('feature/x'), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, meta('feature/x')); + + execSync('git branch -D feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true }); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + await cleanCommand({ stale: true, force: true }); + + await expect(fs.access(dir)).rejects.toThrow(); + const [entry] = await listRegisteredRepos(); + expect(entry.branches).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/clean-command-ownership.test.ts b/gitnexus/test/unit/clean-command-ownership.test.ts index 22f4cca4f..de682fe89 100644 --- a/gitnexus/test/unit/clean-command-ownership.test.ts +++ b/gitnexus/test/unit/clean-command-ownership.test.ts @@ -117,3 +117,60 @@ describe('cleanCommand external storage ownership', () => { expect(remainingEntry).toMatchObject({ path: repoA, storagePath: storageB }); }); }); + +describe('cleanCommand named branch empty-dir cleanup (#3331)', () => { + let fixture: TestDBHandle; + let previousGitNexusHome: string | undefined; + let repo: string; + let storagePath: string; + + beforeEach(async () => { + fixture = await createTempDir(); + previousGitNexusHome = process.env.GITNEXUS_HOME; + const home = path.join(fixture.dbPath, 'home'); + repo = path.join(fixture.dbPath, 'repo'); + storagePath = path.join(repo, '.gitnexus'); + await fs.mkdir(home, { recursive: true }); + await fs.mkdir(repo, { recursive: true }); + initGitRepo(repo); + process.env.GITNEXUS_HOME = home; + + const { registerRepo, getStoragePaths, saveMeta } = + await import('../../src/storage/repo-manager.js'); + const meta = { + repoPath: repo, + lastCommit: 'aaa', + indexedAt: '2026-09-20T00:00:00.000Z', + branch: 'main', + stats: { files: 1, nodes: 1 }, + }; + await saveMeta(storagePath, meta); + await registerRepo(repo, meta); + await registerRepo( + repo, + { ...meta, branch: 'feature/x', lastCommit: 'bbb' }, + { + branch: 'feature/x', + }, + ); + const branchDir = path.dirname(getStoragePaths(repo, 'feature/x', storagePath).metaPath); + await saveMeta(branchDir, { ...meta, branch: 'feature/x' }); + expect(path.basename(path.dirname(branchDir))).toBe('branches'); + + vi.spyOn(process, 'cwd').mockReturnValue(repo); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (previousGitNexusHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = previousGitNexusHome; + await fixture.cleanup(); + }); + + it('named --branch --force rmdirs empty branches/ and keeps the workspace slot', async () => { + await cleanCommand({ branch: 'feature/x', force: true }); + await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toBeTruthy(); + await expect(fs.access(path.join(storagePath, 'gitnexus.json'))).resolves.toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/clean-stale.test.ts b/gitnexus/test/unit/clean-stale.test.ts new file mode 100644 index 000000000..c06194ccc --- /dev/null +++ b/gitnexus/test/unit/clean-stale.test.ts @@ -0,0 +1,353 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanCommand } from '../../src/cli/clean.js'; +import { t } from '../../src/cli/i18n/index.js'; +import { branchSlug } from '../../src/storage/branch-index.js'; +import * as git from '../../src/storage/git.js'; +import { + getStoragePaths, + listRegisteredRepos, + registerRepo, + saveMeta, + type RepoMeta, +} from '../../src/storage/repo-manager.js'; +import { INDEX_METADATA_FILE } from '../../src/storage/storage-constants.js'; +import { initGitRepo, commitAll } from '../helpers/temp-git-repo.js'; +import { createTempDir, type TestDBHandle } from '../helpers/test-db.js'; + +describe('cleanCommand --stale (#3331)', () => { + let home: TestDBHandle; + let fixture: TestDBHandle; + let repo: string; + let storagePath: string; + let savedHome: string | undefined; + let logs: string[]; + + const metaFor = (branch: string, repoPath: string): RepoMeta => ({ + repoPath, + lastCommit: 'aaa', + indexedAt: '2026-09-20T00:00:00.000Z', + branch, + stats: { files: 1, nodes: 1 }, + }); + + async function writeOwnedFlat(repoPath: string, dest: string): Promise { + await saveMeta(dest, metaFor('main', repoPath)); + } + + beforeEach(async () => { + home = await createTempDir(); + fixture = await createTempDir(); + repo = path.join(fixture.dbPath, 'repo'); + storagePath = path.join(repo, '.gitnexus'); + await fs.mkdir(repo, { recursive: true }); + savedHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = home.dbPath; + logs = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + await fixture.cleanup(); + await home.cleanup(); + }); + + it('previews leftover slots with reason and does not delete without --force', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, metaFor('feature/x', repo)); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true }); + + const output = logs.join('\n'); + expect(output).toContain(t('clean.stale.reason.refMissing')); + expect(output).toContain('feature/x'); + expect(output).toContain(t('common.runForceConfirm')); + await expect(fs.access(dir)).resolves.toBeUndefined(); + }); + + it('removes a leftover slot and empty branches/ with --force', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, metaFor('feature/x', repo)); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + expect(logs.join('\n')).toContain(t('clean.stale.deleted', { branch: 'feature/x' })); + await expect(fs.access(dir)).rejects.toThrow(); + await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow(); + await expect(fs.access(path.join(storagePath, INDEX_METADATA_FILE))).resolves.toBeUndefined(); + }); + + it('skips a leftover slot that is a local head again on force re-check', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, metaFor('feature/x', repo)); + const realListLocalHeads = git.listLocalHeads; + let calls = 0; + vi.spyOn(git, 'listLocalHeads').mockImplementation((repoPath: string) => { + calls += 1; + if (calls === 1) return realListLocalHeads(repoPath); + return ['main', 'feature/x']; + }); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + const output = logs.join('\n'); + expect(output).toContain(t('clean.stale.skippedLive', { branch: 'feature/x' })); + expect(output).not.toContain(t('clean.stale.deleted', { branch: 'feature/x' })); + await expect(fs.access(dir)).resolves.toBeUndefined(); + }); + + it('still deletes when force re-check returns an empty head list', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, metaFor('feature/x', repo)); + const realListLocalHeads = git.listLocalHeads; + let calls = 0; + vi.spyOn(git, 'listLocalHeads').mockImplementation((repoPath: string) => { + calls += 1; + if (calls === 1) return realListLocalHeads(repoPath); + return []; + }); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + expect(logs.join('\n')).toContain(t('clean.stale.deleted', { branch: 'feature/x' })); + await expect(fs.access(dir)).rejects.toThrow(); + }); + + it('does not delete when force re-check cannot list local heads', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, metaFor('feature/x', repo)); + const realListLocalHeads = git.listLocalHeads; + let calls = 0; + vi.spyOn(git, 'listLocalHeads').mockImplementation((repoPath: string) => { + calls += 1; + if (calls === 1) return realListLocalHeads(repoPath); + return null; + }); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + const output = logs.join('\n'); + expect(output).toContain(t('clean.stale.headsUnavailable')); + expect(output).not.toContain(t('clean.stale.deleted', { branch: 'feature/x' })); + await expect(fs.access(dir)).resolves.toBeUndefined(); + }); + + it('does not treat an obstructed slot path as none or delete it', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await fs.mkdir(path.dirname(dir), { recursive: true }); + await fs.writeFile(dir, 'not-a-directory'); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + const output = logs.join('\n'); + expect(output).toContain(t('clean.stale.probeFailed')); + expect(output).not.toContain(t('clean.stale.none')); + expect(output).not.toContain(t('clean.stale.deleted', { branch: 'feature/x' })); + await expect(fs.readFile(dir, 'utf8')).resolves.toBe('not-a-directory'); + }); + + it('does not delete when leftover directories cannot be listed', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, metaFor('feature/x', repo)); + const branchesRoot = path.join(storagePath, 'branches'); + const realReaddir = fs.readdir.bind(fs); + vi.spyOn(fs, 'readdir').mockImplementation((async (target: unknown, options?: unknown) => { + if (path.resolve(String(target)) === path.resolve(branchesRoot)) { + const err = new Error('EACCES') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return realReaddir( + target as Parameters[0], + options as Parameters[1], + ); + }) as typeof fs.readdir); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + expect(logs.join('\n')).toContain(t('clean.stale.listingFailed')); + expect(logs.join('\n')).not.toContain(t('clean.stale.deleted', { branch: 'feature/x' })); + expect(logs.join('\n')).not.toContain(t('clean.stale.none')); + await expect(fs.access(dir)).resolves.toBeUndefined(); + }); + + it('does not delete when local heads cannot be listed', async () => { + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, metaFor('feature/x', repo)); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + expect(logs.join('\n')).toContain(t('clean.stale.headsUnavailable')); + await expect(fs.access(dir)).resolves.toBeUndefined(); + }); + + it('removes a disk-only leftover directory', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await saveMeta(dir, metaFor('feature/x', repo)); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + await expect(fs.access(dir)).rejects.toThrow(); + }); + + it('does not drop a recorded branch when a stray disk-only dir claims its name', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + const branchesRoot = path.join(storagePath, 'branches'); + await fs.mkdir(branchesRoot, { recursive: true }); + const canonical = path.join(branchesRoot, branchSlug('feature/x')); + await fs.writeFile(canonical, 'not-a-directory'); + const stray = path.join(branchesRoot, 'mystery-deadbeef'); + await saveMeta(stray, metaFor('feature/x', repo)); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + expect(logs.join('\n')).toContain(t('clean.stale.deleted', { branch: 'feature/x' })); + expect(logs.join('\n')).toContain(t('clean.stale.probeFailed')); + await expect(fs.access(stray)).rejects.toThrow(); + await expect(fs.readFile(canonical, 'utf8')).resolves.toBe('not-a-directory'); + const [entry] = await listRegisteredRepos(); + expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']); + }); + + it('drops a registry-only row without requiring a directory', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' }); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + expect(logs.join('\n')).toContain(t('clean.stale.deleted', { branch: 'feature/x' })); + }); + + it('prints a none-found message when there are no leftover slots', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true }); + + expect(logs.join('\n')).toContain(t('clean.stale.none')); + }); + + it('prefers the --stale arm when --branch is also set', async () => { + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await writeOwnedFlat(repo, storagePath); + await registerRepo(repo, metaFor('main', repo)); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, branch: 'feature/x' }); + + expect(logs.join('\n')).toContain(t('clean.stale.none')); + expect(logs.join('\n')).not.toContain('feature/x'); + }); + + it('refuses --stale on a non-owned external storage path', async () => { + const foreign = path.join(fixture.dbPath, 'foreign-index'); + const other = path.join(fixture.dbPath, 'other'); + await fs.mkdir(foreign, { recursive: true }); + await fs.mkdir(other, { recursive: true }); + initGitRepo(repo); + await fs.writeFile(path.join(repo, 'README.md'), 'hi\n'); + commitAll(repo, 'init'); + await saveMeta(foreign, { + ...metaFor('main', other), + storagePath: foreign, + }); + await registerRepo( + repo, + { ...metaFor('main', repo), storagePath: foreign }, + { + storagePath: foreign, + }, + ); + const leftover = path.join(foreign, 'branches', branchSlug('feature/x')); + await saveMeta(leftover, metaFor('feature/x', other)); + vi.spyOn(process, 'cwd').mockReturnValue(repo); + + await cleanCommand({ stale: true, force: true }); + + await expect(fs.access(leftover)).resolves.toBeUndefined(); + expect(getStoragePaths(repo, undefined, foreign).storagePath).toBe(foreign); + expect(logs.join('\n')).toMatch(/No indexed repository|Refusing to clean leftover/); + }); +}); diff --git a/gitnexus/test/unit/doctor-format.test.ts b/gitnexus/test/unit/doctor-format.test.ts index bc6d3f394..04bcab0ab 100644 --- a/gitnexus/test/unit/doctor-format.test.ts +++ b/gitnexus/test/unit/doctor-format.test.ts @@ -3,13 +3,16 @@ import { displayWidth, doctorCommand, localEmbeddingDoctorStatus, + orphanedBranchSlotDoctorLines, padDisplayEnd, nativeStatusLine, pageSizeDoctorLines, poolSizeDoctorLine, } from '../../src/cli/doctor.js'; -import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js'; +import type { StaleBranchSlot } from '../../src/storage/stale-branch-slots.js'; +import { setCliLanguage, t, type SupportedCliLanguage } from '../../src/cli/i18n/index.js'; import type { NativeCheckResult } from '../../src/core/lbug/native-check.js'; +import { createTempDir } from '../helpers/test-db.js'; const nativeProbeState = vi.hoisted(() => ({ vectorLoaded: true, @@ -376,3 +379,81 @@ describe('doctor survives a malformed GITNEXUS_EMBEDDING_DIMS (#2385)', () => { await expect(doctorCommand()).resolves.toBeUndefined(); }); }); + +describe('orphanedBranchSlotDoctorLines (#3331)', () => { + const slot = (overrides: Partial): StaleBranchSlot => ({ + branch: 'feature/x', + dir: '/tmp/branches/feature_x', + sizeBytes: 1024, + reason: 'ref-missing', + ...overrides, + }); + + it('returns no lines when there are no leftover slots', () => { + expect(orphanedBranchSlotDoctorLines([])).toEqual([]); + }); + + it('prints branch, reason, size, total, and the clean --stale reclaim line', () => { + const lines = orphanedBranchSlotDoctorLines([slot({ sizeBytes: 4_800_000 })]); + expect(lines[0]).toBe(t('doctor.orphanedBranches')); + expect(lines.join('\n')).toContain('feature/x'); + expect(lines.join('\n')).toContain(t('clean.stale.reason.refMissing')); + expect(lines.join('\n')).toContain('4.6 MB'); + expect(lines.join('\n')).toContain(t('doctor.orphanedBranches.reclaim')); + expect(lines.join('\n')).toContain('gitnexus clean --stale'); + }); + + it('prints retry-git copy instead of reclaim when heads cannot be listed (#3337)', () => { + const lines = orphanedBranchSlotDoctorLines([ + slot({ reason: 'heads-unavailable', sizeBytes: 2048 }), + slot({ branch: 'other', reason: 'ref-missing', sizeBytes: 4096 }), + ]); + expect(lines).toEqual([t('clean.stale.headsUnavailable')]); + expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches')); + expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches.reclaim')); + }); + + it('prints only listingFailed when listing-failed is mixed with ref-missing', () => { + const lines = orphanedBranchSlotDoctorLines([ + slot({ reason: 'listing-failed', branch: '', dir: null, sizeBytes: 0 }), + slot({ reason: 'ref-missing' }), + ]); + expect(lines).toEqual([t('clean.stale.listingFailed')]); + expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches')); + expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches.reclaim')); + }); + + it('prints heading and probe-failed row without reclaim', () => { + const lines = orphanedBranchSlotDoctorLines([slot({ reason: 'probe-failed' })]); + expect(lines[0]).toBe(t('doctor.orphanedBranches')); + expect(lines.join('\n')).toContain('feature/x'); + expect(lines.join('\n')).toContain(t('clean.stale.reason.probeFailed')); + expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches.reclaim')); + }); + + it('includes reclaim when probe-failed is mixed with ref-missing', () => { + const lines = orphanedBranchSlotDoctorLines([ + slot({ reason: 'probe-failed' }), + slot({ branch: 'other', reason: 'ref-missing' }), + ]); + expect(lines[0]).toBe(t('doctor.orphanedBranches')); + expect(lines.join('\n')).toContain(t('clean.stale.reason.probeFailed')); + expect(lines.join('\n')).toContain(t('clean.stale.reason.refMissing')); + expect(lines.join('\n')).toContain(t('doctor.orphanedBranches.reclaim')); + }); + + it('does not print leftover slots when cwd is not an indexed repo', async () => { + const tmp = await createTempDir(); + try { + vi.spyOn(process, 'cwd').mockReturnValue(tmp.dbPath); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await doctorCommand(); + const output = log.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + expect(output).toContain(t('doctor.runtime')); + expect(output).not.toContain(t('doctor.orphanedBranches')); + } finally { + vi.restoreAllMocks(); + await tmp.cleanup(); + } + }); +}); diff --git a/gitnexus/test/unit/git.test.ts b/gitnexus/test/unit/git.test.ts index b99a75566..7ed94cfa8 100644 --- a/gitnexus/test/unit/git.test.ts +++ b/gitnexus/test/unit/git.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { execSync } from 'child_process'; +import { execSync, spawnSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -12,6 +12,7 @@ import { sanitizeRepoName, getDefaultBranch, getCurrentBranch, + listLocalHeads, getGitInfoExcludePath, getCoreExcludesFilePath, } from '../../src/storage/git.js'; @@ -19,9 +20,11 @@ import { // Mock child_process.execSync vi.mock('child_process', () => ({ execSync: vi.fn(), + spawnSync: vi.fn(), })); const mockExecSync = vi.mocked(execSync); +const mockSpawnSync = vi.mocked(spawnSync); describe('git utilities', () => { beforeEach(() => { @@ -136,6 +139,68 @@ describe('git utilities', () => { }); }); + describe('listLocalHeads (#3331)', () => { + it('returns local head names including a slashed branch', () => { + mockSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: 'refs/heads/main\nrefs/heads/feature/x\n', + stderr: '', + error: undefined, + } as ReturnType); + expect(listLocalHeads('/project')).toEqual(['main', 'feature/x']); + expect(mockSpawnSync).toHaveBeenCalledWith( + 'git', + ['for-each-ref', '--format=%(refname)', 'refs/heads'], + expect.objectContaining({ + cwd: '/project', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + maxBuffer: 64 * 1024 * 1024, + }), + ); + }); + + it('ignores lines that do not start with refs/heads/', () => { + mockSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: 'refs/heads/main\nheads/feature/x\nrefs/tags/feature/x\n', + stderr: '', + error: undefined, + } as ReturnType); + expect(listLocalHeads('/project')).toEqual(['main']); + }); + + it('returns an empty list when the repo has no local heads', () => { + mockSpawnSync.mockReturnValueOnce({ + status: 0, + stdout: '\n', + stderr: '', + error: undefined, + } as ReturnType); + expect(listLocalHeads('/project')).toEqual([]); + }); + + it('returns null when git exits non-zero', () => { + mockSpawnSync.mockReturnValueOnce({ + status: 128, + stdout: '', + stderr: 'fatal: not a git repository', + error: undefined, + } as ReturnType); + expect(listLocalHeads('/not-a-repo')).toBeNull(); + }); + + it('returns null when git cannot run', () => { + mockSpawnSync.mockReturnValueOnce({ + status: null, + stdout: '', + stderr: '', + error: Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }), + } as ReturnType); + expect(listLocalHeads('/missing-git')).toBeNull(); + }); + }); + describe('getGitRoot', () => { it('returns resolved path on success', () => { mockExecSync.mockReturnValueOnce(Buffer.from('/d/Projects/MyRepo\n')); diff --git a/gitnexus/test/unit/repo-manager-rm-failure.test.ts b/gitnexus/test/unit/repo-manager-rm-failure.test.ts index 4055f94ea..427cc8367 100644 --- a/gitnexus/test/unit/repo-manager-rm-failure.test.ts +++ b/gitnexus/test/unit/repo-manager-rm-failure.test.ts @@ -37,6 +37,10 @@ import { saveMeta, type RepoMeta, } from '../../src/storage/repo-manager.js'; +import { cleanCommand } from '../../src/cli/clean.js'; +import { removeBranchSlot } from '../../src/storage/stale-branch-slots.js'; +import { branchSlug } from '../../src/storage/branch-index.js'; +import { initGitRepo, commitAll } from '../helpers/temp-git-repo.js'; import { _captureLogger } from '../../src/core/logger.js'; import { createTempDir } from '../helpers/test-db.js'; @@ -132,3 +136,91 @@ describe('adoptFlatBranchLabel — rm failure keeps the branch summary (#2364 F4 expect(entry.branches).toBeUndefined(); }); }); + +describe('removeBranchSlot — rm failure keeps the branch summary (#3331)', () => { + let tmpHome: Awaited>; + let tmpRepo: Awaited>; + let savedGitnexusHome: string | undefined; + + const metaFor = (branch: string, lastCommit: string): RepoMeta => ({ + repoPath: '', + lastCommit, + indexedAt: '2026-07-03T12:00:00.000Z', + branch, + stats: { files: 1, nodes: 1 }, + }); + + beforeEach(async () => { + tmpHome = await createTempDir('gitnexus-stale-rm-failure-home-'); + tmpRepo = await createTempDir('gitnexus-stale-rm-failure-repo-'); + savedGitnexusHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = tmpHome.dbPath; + fsCtx.rmMock.mockClear(); + fsCtx.rmMock.mockImplementation((...args) => fsCtx.realRm!(...args)); + }); + + afterEach(async () => { + if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedGitnexusHome; + await tmpHome.cleanup(); + await tmpRepo.cleanup(); + }); + + it('keeps the registry row and does not rmdir branches/ on EBUSY', async () => { + await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111')); + await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); + const { storagePath, metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x'); + const dir = path.dirname(metaPath); + await saveMeta(dir, metaFor('feature/x', 'bbb2222')); + + fsCtx.rmMock.mockRejectedValueOnce(Object.assign(new Error('mock busy'), { code: 'EBUSY' })); + const result = await removeBranchSlot({ + repoPath: tmpRepo.dbPath, + storagePath, + branch: 'feature/x', + dir, + }); + + expect(result.ok).toBe(false); + expect(result.keptRegistry).toBe(true); + expect(result.emptiedBranchesDir).toBe(false); + const [entry] = await listRegisteredRepos(); + expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/x']); + await expect(fs.access(dir)).resolves.toBeUndefined(); + }); + + it('continues remaining --stale candidates after one rm failure', async () => { + initGitRepo(tmpRepo.dbPath); + await fs.writeFile(path.join(tmpRepo.dbPath, 'README.md'), 'hi\n'); + commitAll(tmpRepo.dbPath, 'init'); + const storagePath = path.join(tmpRepo.dbPath, '.gitnexus'); + await saveMeta(storagePath, { ...metaFor('main', 'aaa1111'), repoPath: tmpRepo.dbPath }); + await registerRepo(tmpRepo.dbPath, { ...metaFor('main', 'aaa1111'), repoPath: tmpRepo.dbPath }); + await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' }); + await registerRepo(tmpRepo.dbPath, metaFor('feature/y', 'ccc3333'), { branch: 'feature/y' }); + const dirX = path.join(storagePath, 'branches', branchSlug('feature/x')); + const dirY = path.join(storagePath, 'branches', branchSlug('feature/y')); + await saveMeta(dirX, metaFor('feature/x', 'bbb2222')); + await saveMeta(dirY, metaFor('feature/y', 'ccc3333')); + + fsCtx.rmMock.mockImplementation(async (target, options) => { + if (String(target) === dirX) { + throw Object.assign(new Error('mock busy'), { code: 'EBUSY' }); + } + return fsCtx.realRm!(target, options); + }); + + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpRepo.dbPath); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await cleanCommand({ stale: true, force: true }); + const [entry] = await listRegisteredRepos(); + expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/x']); + await expect(fs.access(dirX)).resolves.toBeUndefined(); + await expect(fs.access(dirY)).rejects.toThrow(); + } finally { + cwdSpy.mockRestore(); + logSpy.mockRestore(); + } + }); +}); diff --git a/gitnexus/test/unit/stale-branch-slots.test.ts b/gitnexus/test/unit/stale-branch-slots.test.ts new file mode 100644 index 000000000..dde191070 --- /dev/null +++ b/gitnexus/test/unit/stale-branch-slots.test.ts @@ -0,0 +1,590 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { branchSlug } from '../../src/storage/branch-index.js'; +import { INDEX_METADATA_FILE } from '../../src/storage/storage-constants.js'; +import { + listRegisteredRepos, + registerRepo, + type RepoMeta, +} from '../../src/storage/repo-manager.js'; +import * as repoManager from '../../src/storage/repo-manager.js'; +import { + isDeleteCandidate, + listStaleBranchSlots, + removeBranchSlot, + type StaleBranchSlot, +} from '../../src/storage/stale-branch-slots.js'; +import { createTempDir, type TestDBHandle } from '../helpers/test-db.js'; + +async function writeSlotMeta(dir: string, branch: string): Promise { + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, INDEX_METADATA_FILE), + JSON.stringify({ branch, lastCommit: 'abc', indexedAt: '2026-09-20T00:00:00.000Z' }), + ); +} + +describe('listStaleBranchSlots (#3331)', () => { + let fixture: TestDBHandle; + let repoPath: string; + let storagePath: string; + + beforeEach(async () => { + fixture = await createTempDir(); + repoPath = path.join(fixture.dbPath, 'repo'); + storagePath = path.join(repoPath, '.gitnexus'); + await fs.mkdir(repoPath, { recursive: true }); + }); + + afterEach(async () => { + await fixture.cleanup(); + }); + + it('classifies a recorded branch with no local head as ref-missing', async () => { + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await writeSlotMeta(dir, 'feature/x'); + await fs.writeFile(path.join(dir, 'blob.bin'), 'x'); + + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [{ branch: 'feature/x' }], + heads: ['main'], + }); + + expect(rows).toEqual([ + expect.objectContaining({ + branch: 'feature/x', + dir, + reason: 'ref-missing', + }), + ]); + expect(rows[0]?.sizeBytes).toBeGreaterThan(0); + expect(isDeleteCandidate(rows[0]!)).toBe(true); + }); + + it('does not classify a recorded branch that is still a local head', async () => { + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await writeSlotMeta(dir, 'feature/x'); + + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [{ branch: 'feature/x' }], + heads: ['main', 'feature/x'], + }); + + expect(rows).toEqual([]); + }); + + it('classifies a leftover directory with no registry row as disk-only', async () => { + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await writeSlotMeta(dir, 'feature/x'); + + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [], + heads: ['main'], + }); + + expect(rows).toEqual([ + expect.objectContaining({ + branch: 'feature/x', + dir, + reason: 'disk-only', + }), + ]); + }); + + it('does not classify a registry row when the slug path is unreadable', async () => { + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await writeSlotMeta(dir, 'feature/x'); + const realStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, 'stat').mockImplementation(async (target, options) => { + if (path.resolve(String(target)) === path.resolve(dir)) { + const err = new Error('EACCES') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return realStat(target, options); + }); + + try { + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [{ branch: 'feature/x' }], + heads: ['main'], + }); + expect(rows).toEqual([ + expect.objectContaining({ + branch: 'feature/x', + dir, + reason: 'probe-failed', + }), + ]); + expect(isDeleteCandidate(rows[0]!)).toBe(false); + } finally { + statSpy.mockRestore(); + } + }); + + it('classifies a regular file at the canonical slug path as probe-failed', async () => { + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await fs.mkdir(path.dirname(dir), { recursive: true }); + await fs.writeFile(dir, 'not a directory'); + + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [{ branch: 'feature/x' }], + heads: ['main'], + }); + + expect(rows).toEqual([ + expect.objectContaining({ + branch: 'feature/x', + dir, + reason: 'probe-failed', + }), + ]); + expect(isDeleteCandidate(rows[0]!)).toBe(false); + }); + + it('returns listing-failed when branches/ readdir fails with a non-missing error', async () => { + const branchesRoot = path.join(storagePath, 'branches'); + const realReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, 'readdir').mockImplementation((async ( + target: unknown, + options?: unknown, + ) => { + if (path.resolve(String(target)) === path.resolve(branchesRoot)) { + const err = new Error('EACCES') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return realReaddir( + target as Parameters[0], + options as Parameters[1], + ); + }) as typeof fs.readdir); + + try { + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [], + heads: ['main'], + }); + expect(rows).toEqual([ + { + branch: '', + dir: null, + sizeBytes: 0, + reason: 'listing-failed', + }, + ]); + expect(isDeleteCandidate(rows[0]!)).toBe(false); + } finally { + readdirSpy.mockRestore(); + } + }); + + it('does not classify registry rows when branches/ listing fails', async () => { + const branchesRoot = path.join(storagePath, 'branches'); + const realReaddir = fs.readdir.bind(fs); + const readdirSpy = vi.spyOn(fs, 'readdir').mockImplementation((async ( + target: unknown, + options?: unknown, + ) => { + if (path.resolve(String(target)) === path.resolve(branchesRoot)) { + const err = new Error('EACCES') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + } + return realReaddir( + target as Parameters[0], + options as Parameters[1], + ); + }) as typeof fs.readdir); + + try { + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [{ branch: 'feature/x' }], + heads: ['main'], + }); + expect(rows).toEqual([ + { + branch: '', + dir: null, + sizeBytes: 0, + reason: 'listing-failed', + }, + ]); + } finally { + readdirSpy.mockRestore(); + } + }); + + it('classifies a registry row whose directory is gone as registry-only', async () => { + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [{ branch: 'feature/x' }], + heads: ['main'], + }); + + expect(rows).toEqual([ + expect.objectContaining({ + branch: 'feature/x', + dir: null, + sizeBytes: 0, + reason: 'registry-only', + }), + ]); + }); + + it('does not classify a leftover directory with unreadable metadata and no registry row', async () => { + const dir = path.join(storagePath, 'branches', 'mystery-deadbeef'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, INDEX_METADATA_FILE), '{not-json'); + + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [], + heads: ['main'], + }); + + expect(rows).toEqual([]); + }); + + it('classifies from the recorded name when the slug path exists even if metadata is unreadable', async () => { + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, INDEX_METADATA_FILE), '{not-json'); + + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [{ branch: 'feature/x' }], + heads: ['main'], + }); + + expect(rows).toEqual([ + expect.objectContaining({ + branch: 'feature/x', + dir, + reason: 'ref-missing', + }), + ]); + }); + + it('tags every known slot heads-unavailable when heads cannot be listed', async () => { + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await writeSlotMeta(dir, 'feature/x'); + + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [{ branch: 'feature/x' }], + heads: null, + }); + + expect(rows).toEqual([ + expect.objectContaining({ + branch: 'feature/x', + dir, + reason: 'heads-unavailable', + }), + ]); + }); + + it('does not treat the workspace/flat slot as a branch directory', async () => { + await fs.mkdir(storagePath, { recursive: true }); + await fs.writeFile( + path.join(storagePath, INDEX_METADATA_FILE), + JSON.stringify({ branch: 'main' }), + ); + await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}'); + + const rows = await listStaleBranchSlots({ + repoPath, + storagePath, + branches: [], + heads: ['main'], + }); + + expect(rows).toEqual([]); + }); +}); + +describe('isDeleteCandidate', () => { + const slot = (reason: StaleBranchSlot['reason']): StaleBranchSlot => ({ + branch: 'feature/x', + dir: '/tmp/x', + sizeBytes: 0, + reason, + }); + + it('is true only for reclaimable leftover reasons', () => { + expect(isDeleteCandidate(slot('ref-missing'))).toBe(true); + expect(isDeleteCandidate(slot('registry-only'))).toBe(true); + expect(isDeleteCandidate(slot('disk-only'))).toBe(true); + expect(isDeleteCandidate(slot('heads-unavailable'))).toBe(false); + expect(isDeleteCandidate(slot('probe-failed'))).toBe(false); + expect(isDeleteCandidate(slot('listing-failed'))).toBe(false); + }); +}); + +describe('removeBranchSlot (#3331)', () => { + let home: TestDBHandle; + let fixture: TestDBHandle; + let repoPath: string; + let storagePath: string; + let savedHome: string | undefined; + + const metaFor = (branch: string): RepoMeta => ({ + repoPath: '', + lastCommit: 'abc', + indexedAt: '2026-09-20T00:00:00.000Z', + branch, + stats: { files: 1, nodes: 1 }, + }); + + beforeEach(async () => { + home = await createTempDir(); + fixture = await createTempDir(); + repoPath = path.join(fixture.dbPath, 'repo'); + storagePath = path.join(repoPath, '.gitnexus'); + await fs.mkdir(repoPath, { recursive: true }); + savedHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = home.dbPath; + }); + + afterEach(async () => { + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + await fixture.cleanup(); + await home.cleanup(); + }); + + it('removes the last slot, drops the registry row, and rmdirs empty branches/', async () => { + await registerRepo(repoPath, metaFor('main')); + await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await writeSlotMeta(dir, 'feature/x'); + await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}'); + + const result = await removeBranchSlot({ + repoPath, + storagePath, + branch: 'feature/x', + dir, + }); + + expect(result).toEqual({ ok: true, emptiedBranchesDir: true, keptRegistry: false }); + await expect(fs.access(dir)).rejects.toThrow(); + await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow(); + await expect(fs.readFile(path.join(storagePath, 'parse-cache.json'), 'utf8')).resolves.toBe( + '{}', + ); + const [entry] = await listRegisteredRepos(); + expect(entry.branches).toBeUndefined(); + }); + + it('leaves the other slot and branches/ when two slots exist', async () => { + await registerRepo(repoPath, metaFor('main')); + await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' }); + await registerRepo(repoPath, metaFor('feature/y'), { branch: 'feature/y' }); + const dirX = path.join(storagePath, 'branches', branchSlug('feature/x')); + const dirY = path.join(storagePath, 'branches', branchSlug('feature/y')); + await writeSlotMeta(dirX, 'feature/x'); + await writeSlotMeta(dirY, 'feature/y'); + + const result = await removeBranchSlot({ + repoPath, + storagePath, + branch: 'feature/x', + dir: dirX, + }); + + expect(result.ok).toBe(true); + expect(result.emptiedBranchesDir).toBe(false); + await expect(fs.access(dirY)).resolves.toBeUndefined(); + const [entry] = await listRegisteredRepos(); + expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/y']); + }); + + it('refuses a target outside branches/', async () => { + await registerRepo(repoPath, metaFor('main')); + await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' }); + await fs.mkdir(storagePath, { recursive: true }); + await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}'); + + const result = await removeBranchSlot({ + repoPath, + storagePath, + branch: 'feature/x', + dir: storagePath, + }); + + expect(result.ok).toBe(false); + expect(result.keptRegistry).toBe(true); + expect(result.emptiedBranchesDir).toBe(false); + await expect(fs.readFile(path.join(storagePath, 'parse-cache.json'), 'utf8')).resolves.toBe( + '{}', + ); + const [entry] = await listRegisteredRepos(); + expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']); + }); + + it('drops a registry-only row without requiring a directory', async () => { + await registerRepo(repoPath, metaFor('main')); + await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' }); + + const result = await removeBranchSlot({ + repoPath, + storagePath, + branch: 'feature/x', + dir: null, + }); + + expect(result.ok).toBe(true); + const [entry] = await listRegisteredRepos(); + expect(entry.branches).toBeUndefined(); + }); + + it('refuses when branches/ is a symlink or junction pointing outside', async () => { + await registerRepo(repoPath, metaFor('main')); + await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' }); + const outside = path.join(fixture.dbPath, 'outside-tree'); + const slug = branchSlug('feature/x'); + const outsideSlot = path.join(outside, slug); + await writeSlotMeta(outsideSlot, 'feature/x'); + await fs.writeFile(path.join(outsideSlot, 'payload.bin'), 'secret'); + await fs.mkdir(storagePath, { recursive: true }); + const branchesRoot = path.join(storagePath, 'branches'); + await fs.symlink(outside, branchesRoot, process.platform === 'win32' ? 'junction' : 'dir'); + const dir = path.join(branchesRoot, slug); + + const result = await removeBranchSlot({ + repoPath, + storagePath, + branch: 'feature/x', + dir, + }); + + expect(result.ok).toBe(false); + expect(result.keptRegistry).toBe(true); + expect(result.emptiedBranchesDir).toBe(false); + await expect(fs.access(outsideSlot)).resolves.toBeUndefined(); + await expect(fs.readFile(path.join(outsideSlot, 'payload.bin'), 'utf8')).resolves.toBe( + 'secret', + ); + const [entry] = await listRegisteredRepos(); + expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']); + }); + + it('unlinks a slot symlink or junction and leaves the outside target', async () => { + await registerRepo(repoPath, metaFor('main')); + await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' }); + const outside = path.join(fixture.dbPath, 'outside-slot'); + await writeSlotMeta(outside, 'feature/x'); + await fs.writeFile(path.join(outside, 'payload.bin'), 'secret'); + const branchesRoot = path.join(storagePath, 'branches'); + await fs.mkdir(branchesRoot, { recursive: true }); + const dir = path.join(branchesRoot, branchSlug('feature/x')); + await fs.symlink(outside, dir, process.platform === 'win32' ? 'junction' : 'dir'); + + const result = await removeBranchSlot({ + repoPath, + storagePath, + branch: 'feature/x', + dir, + }); + + expect(result).toEqual({ ok: true, emptiedBranchesDir: true, keptRegistry: false }); + await expect(fs.lstat(dir)).rejects.toThrow(); + await expect(fs.access(outside)).resolves.toBeUndefined(); + await expect(fs.readFile(path.join(outside, 'payload.bin'), 'utf8')).resolves.toBe('secret'); + const [entry] = await listRegisteredRepos(); + expect(entry.branches).toBeUndefined(); + }); + + it('deletes a normal leftover slot directory', async () => { + await registerRepo(repoPath, metaFor('main')); + await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await writeSlotMeta(dir, 'feature/x'); + await fs.writeFile(path.join(dir, 'payload.bin'), 'stale'); + + const result = await removeBranchSlot({ + repoPath, + storagePath, + branch: 'feature/x', + dir, + }); + + expect(result).toEqual({ ok: true, emptiedBranchesDir: true, keptRegistry: false }); + await expect(fs.access(dir)).rejects.toThrow(); + await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow(); + const [entry] = await listRegisteredRepos(); + expect(entry.branches).toBeUndefined(); + }); + + it('does not drop a registry row when removing a non-canonical disk-only dir', async () => { + await registerRepo(repoPath, metaFor('main')); + await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' }); + const canonical = path.join(storagePath, 'branches', branchSlug('feature/x')); + await writeSlotMeta(canonical, 'feature/x'); + const stray = path.join(storagePath, 'branches', 'mystery-deadbeef'); + await writeSlotMeta(stray, 'feature/x'); + + const result = await removeBranchSlot({ + repoPath, + storagePath, + branch: 'feature/x', + dir: stray, + }); + + expect(result).toEqual({ ok: true, emptiedBranchesDir: false, keptRegistry: true }); + await expect(fs.access(stray)).rejects.toThrow(); + await expect(fs.access(canonical)).resolves.toBeUndefined(); + const [entry] = await listRegisteredRepos(); + expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']); + }); + + it('keeps the registry row when removeBranchIndex rejects after a successful rm', async () => { + await registerRepo(repoPath, metaFor('main')); + await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' }); + const dir = path.join(storagePath, 'branches', branchSlug('feature/x')); + await writeSlotMeta(dir, 'feature/x'); + const spy = vi + .spyOn(repoManager, 'removeBranchIndex') + .mockRejectedValueOnce(new Error('lock timeout')); + + try { + const result = await removeBranchSlot({ + repoPath, + storagePath, + branch: 'feature/x', + dir, + }); + + expect(result.ok).toBe(false); + expect(result.keptRegistry).toBe(true); + expect(result.emptiedBranchesDir).toBe(false); + expect(result.error?.message).toBe('lock timeout'); + await expect(fs.access(dir)).rejects.toThrow(); + const [entry] = await listRegisteredRepos(); + expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']); + } finally { + spy.mockRestore(); + } + }); +});