mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
feat(cli): reclaim leftover per-branch indexes after branch delete (#3338)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * Address PR review feedback (#3338) - Describe disk-only leftover slots in --stale help, not only recorded branches. Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * revert: drop Unreleased changelog notes from this branch Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b888260a86
commit
620fd18a5c
17 changed files with 2075 additions and 17 deletions
|
|
@ -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 <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).
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'clean|--all': 'help.option.clean.all',
|
||||
'clean|--branch <name>': '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 <provider>': 'help.option.wiki.provider',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@ program
|
|||
.option('-f, --force', 'Skip confirmation prompt')
|
||||
.option('--all', 'Clean all indexed repos')
|
||||
.option('--branch <name>', '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)',
|
||||
|
|
|
|||
37
gitnexus/src/cli/stale-branch-format.ts
Normal file
37
gitnexus/src/cli/stale-branch-format.ts
Normal file
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
|
|
|||
384
gitnexus/src/storage/stale-branch-slots.ts
Normal file
384
gitnexus/src/storage/stale-branch-slots.ts
Normal file
|
|
@ -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 <T, R>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<R>,
|
||||
): Promise<R[]> => {
|
||||
if (items.length === 0) return [];
|
||||
const results = new Array<R>(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<DirectoryProbe> => {
|
||||
try {
|
||||
return (await fs.stat(dir)).isDirectory() ? 'dir' : 'unreadable';
|
||||
} catch (err) {
|
||||
return isMissingFilesystemError(err) ? 'missing' : 'unreadable';
|
||||
}
|
||||
};
|
||||
|
||||
const directorySizeBytes = async (root: string): Promise<number> => {
|
||||
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<string | null> => {
|
||||
const meta = await loadMeta(dir);
|
||||
return typeof meta?.branch === 'string' && meta.branch.length > 0 ? meta.branch : null;
|
||||
};
|
||||
|
||||
export const listStaleBranchSlots = async (
|
||||
input: ListStaleBranchSlotsInput,
|
||||
): Promise<StaleBranchSlot[]> => {
|
||||
const recorded = input.branches ?? [];
|
||||
const branchesRoot = path.join(input.storagePath, BRANCHES_DIR);
|
||||
|
||||
const registryByDir = new Map<string, string>();
|
||||
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<Omit<StaleBranchSlot, 'sizeBytes'>> = [];
|
||||
|
||||
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<boolean> => {
|
||||
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<RemoveBranchSlotResult | null> => {
|
||||
if (!isContainedBranchDir(storagePath, dir)) {
|
||||
return refuseOutsideSlot(dir);
|
||||
}
|
||||
|
||||
const branchesRoot = path.resolve(storagePath, BRANCHES_DIR);
|
||||
|
||||
let branchesStat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
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<ReturnType<typeof fs.lstat>>;
|
||||
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<boolean> => {
|
||||
try {
|
||||
await fs.rmdir(path.join(storagePath, BRANCHES_DIR));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeBranchSlot = async (
|
||||
input: RemoveBranchSlotInput,
|
||||
): Promise<RemoveBranchSlotResult> => {
|
||||
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 };
|
||||
};
|
||||
144
gitnexus/test/integration/clean-stale-branch-slots.test.ts
Normal file
144
gitnexus/test/integration/clean-stale-branch-slots.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
353
gitnexus/test/unit/clean-stale.test.ts
Normal file
353
gitnexus/test/unit/clean-stale.test.ts
Normal file
|
|
@ -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<void> {
|
||||
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<typeof realReaddir>[0],
|
||||
options as Parameters<typeof realReaddir>[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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>): 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof spawnSync>);
|
||||
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<typeof spawnSync>);
|
||||
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<typeof spawnSync>);
|
||||
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<typeof spawnSync>);
|
||||
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<typeof spawnSync>);
|
||||
expect(listLocalHeads('/missing-git')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitRoot', () => {
|
||||
it('returns resolved path on success', () => {
|
||||
mockExecSync.mockReturnValueOnce(Buffer.from('/d/Projects/MyRepo\n'));
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
590
gitnexus/test/unit/stale-branch-slots.test.ts
Normal file
590
gitnexus/test/unit/stale-branch-slots.test.ts
Normal file
|
|
@ -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<void> {
|
||||
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<typeof realReaddir>[0],
|
||||
options as Parameters<typeof realReaddir>[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<typeof realReaddir>[0],
|
||||
options as Parameters<typeof realReaddir>[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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue