gitnexus 1.6.13-rc.24 → 1.6.13-rc.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,5 +8,6 @@ export declare const cleanCommand: (options?: {
8
8
  force?: boolean;
9
9
  all?: boolean;
10
10
  lbugSidecars?: boolean;
11
+ stale?: boolean;
11
12
  branch?: string;
12
13
  }) => Promise<void>;
package/dist/cli/clean.js CHANGED
@@ -7,11 +7,112 @@
7
7
  import fs from 'fs/promises';
8
8
  import path from 'path';
9
9
  import { logger } from '../core/logger.js';
10
- import { findRepo, unregisterRepo, listRegisteredRepos, getStoragePaths, removeBranchIndex, } from '../storage/repo-manager.js';
10
+ import { findRegistryEntryByRepoPath, findRepo, unregisterRepo, listRegisteredRepos, getStoragePaths, } from '../storage/repo-manager.js';
11
11
  import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js';
12
+ import { formatStaleSlotLine } from './stale-branch-format.js';
13
+ import { listLocalHeads } from '../storage/git.js';
14
+ import { isContainedBranchDir, isDeleteCandidate, listStaleBranchSlots, removeBranchSlot, staleListingBlock, } from '../storage/stale-branch-slots.js';
12
15
  import { cleanParkedLbugSidecars, inspectLbugSidecars, listParkedLbugSidecars, } from '../core/lbug/sidecar-recovery.js';
13
16
  import { t } from './i18n/index.js';
14
17
  export const cleanCommand = async (options) => {
18
+ // --stale: reclaim leftover per-branch slots whose recorded branch is not
19
+ // a live local head (#3331). Exclusive arm before --branch.
20
+ if (options?.stale) {
21
+ const cwd = process.cwd();
22
+ const repo = await findRepo(cwd);
23
+ if (!repo) {
24
+ console.log(t('clean.notFoundHere'));
25
+ return;
26
+ }
27
+ const entries = await listRegisteredRepos();
28
+ const entry = findRegistryEntryByRepoPath(entries, repo.repoPath);
29
+ let storagePath;
30
+ try {
31
+ storagePath = await requireDeletableStoragePath({
32
+ path: repo.repoPath,
33
+ storagePath: repo.storagePath,
34
+ });
35
+ }
36
+ catch (err) {
37
+ if (err instanceof StorageDeletionError) {
38
+ logger.error(`Refusing to clean leftover branch indexes: ${err.message}`);
39
+ return;
40
+ }
41
+ throw err;
42
+ }
43
+ const slots = await listStaleBranchSlots({
44
+ repoPath: repo.repoPath,
45
+ storagePath,
46
+ branches: entry?.branches,
47
+ includeSize: !options.force,
48
+ });
49
+ const listingBlock = staleListingBlock(slots);
50
+ if (listingBlock === 'heads-unavailable') {
51
+ console.log(t('clean.stale.headsUnavailable'));
52
+ for (const slot of slots.filter((row) => row.reason === 'heads-unavailable')) {
53
+ console.log(` - ${formatStaleSlotLine(slot)}`);
54
+ }
55
+ return;
56
+ }
57
+ if (listingBlock === 'listing-failed') {
58
+ console.log(t('clean.stale.listingFailed'));
59
+ return;
60
+ }
61
+ const candidates = slots.filter(isDeleteCandidate);
62
+ const probeFailed = slots.filter((slot) => slot.reason === 'probe-failed');
63
+ const printProbeFailed = () => {
64
+ console.log(t('clean.stale.probeFailed'));
65
+ for (const slot of probeFailed) {
66
+ console.log(` - ${formatStaleSlotLine(slot)}`);
67
+ }
68
+ };
69
+ if (candidates.length === 0) {
70
+ if (probeFailed.length > 0) {
71
+ printProbeFailed();
72
+ return;
73
+ }
74
+ console.log(t('clean.stale.none'));
75
+ return;
76
+ }
77
+ if (!options.force) {
78
+ console.log(t('clean.stale.preview', { count: candidates.length }));
79
+ for (const slot of candidates) {
80
+ console.log(` - ${formatStaleSlotLine(slot)}`);
81
+ }
82
+ if (probeFailed.length > 0) {
83
+ printProbeFailed();
84
+ }
85
+ console.log(`\n${t('common.runForceConfirm')}`);
86
+ return;
87
+ }
88
+ for (const slot of candidates) {
89
+ const heads = listLocalHeads(repo.repoPath);
90
+ if (heads === null) {
91
+ console.log(t('clean.stale.headsUnavailable'));
92
+ return;
93
+ }
94
+ if (heads.includes(slot.branch)) {
95
+ console.log(t('clean.stale.skippedLive', { branch: slot.branch }));
96
+ continue;
97
+ }
98
+ const result = await removeBranchSlot({
99
+ repoPath: repo.repoPath,
100
+ storagePath,
101
+ branch: slot.branch,
102
+ dir: slot.dir,
103
+ });
104
+ if (!result.ok) {
105
+ console.log(t('clean.stale.failed', { branch: slot.branch }));
106
+ logger.error({ err: result.error }, 'Failed to delete leftover branch index:');
107
+ continue;
108
+ }
109
+ console.log(t('clean.stale.deleted', { branch: slot.branch }));
110
+ }
111
+ if (probeFailed.length > 0) {
112
+ printProbeFailed();
113
+ }
114
+ return;
115
+ }
15
116
  // --branch <name>: remove a single non-primary branch's index (#2106 R7).
16
117
  // Resolve against the RECORDED branches[] summary (never by slugging the
17
118
  // user's raw input, which can disagree with the index-time-sanitized label).
@@ -23,7 +124,7 @@ export const cleanCommand = async (options) => {
23
124
  return;
24
125
  }
25
126
  const entries = await listRegisteredRepos();
26
- const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo.repoPath));
127
+ const entry = findRegistryEntryByRepoPath(entries, repo.repoPath);
27
128
  const summary = entry?.branches?.find((b) => b.branch === options.branch);
28
129
  if (!summary) {
29
130
  console.log(t('clean.branchNotIndexed', { branch: options.branch }));
@@ -45,10 +146,7 @@ export const cleanCommand = async (options) => {
45
146
  }
46
147
  const { lbugPath } = getStoragePaths(repo.repoPath, summary.branch, storagePath);
47
148
  const branchDir = path.dirname(lbugPath);
48
- // Safety guard: the target MUST live under the validated
49
- // storage slot's `branches/` directory before any destructive fs.rm.
50
- const branchesRoot = path.join(storagePath, 'branches') + path.sep;
51
- if (!branchDir.startsWith(branchesRoot)) {
149
+ if (!isContainedBranchDir(storagePath, branchDir)) {
52
150
  logger.error(`Refusing to clean branch index outside the validated storage slot: ${branchDir}`);
53
151
  return;
54
152
  }
@@ -57,14 +155,17 @@ export const cleanCommand = async (options) => {
57
155
  console.log(`\n${t('common.runForceConfirm')}`);
58
156
  return;
59
157
  }
60
- try {
61
- await fs.rm(branchDir, { recursive: true, force: true });
62
- await removeBranchIndex(repo.repoPath, summary.branch);
63
- console.log(t('clean.deletedBranch', { branch: summary.branch }));
64
- }
65
- catch (err) {
66
- logger.error({ err }, 'Failed to delete branch index:');
158
+ const result = await removeBranchSlot({
159
+ repoPath: repo.repoPath,
160
+ storagePath,
161
+ branch: summary.branch,
162
+ dir: branchDir,
163
+ });
164
+ if (!result.ok) {
165
+ logger.error({ err: result.error }, 'Failed to delete branch index:');
166
+ return;
67
167
  }
168
+ console.log(t('clean.deletedBranch', { branch: summary.branch }));
68
169
  return;
69
170
  }
70
171
  if (options?.lbugSidecars) {
@@ -1,6 +1,7 @@
1
1
  import { type EmbeddingRuntimeResolution } from '../core/embeddings/runtime-install.js';
2
2
  import { type NativeCheckResult } from '../core/lbug/native-check.js';
3
3
  import { type ValidatedUpdateCache } from '../core/update-cache.js';
4
+ import { type StaleBranchSlot } from '../storage/stale-branch-slots.js';
4
5
  export declare function displayWidth(value: string): number;
5
6
  export declare function padDisplayEnd(value: string, columns: number): string;
6
7
  /**
@@ -60,6 +61,12 @@ export declare function poolSizeDoctorLine(pool: number, envRaw: string | undefi
60
61
  * stderr right below said the opposite. Render what the check actually found.
61
62
  */
62
63
  export declare function nativeStatusLine(check: NativeCheckResult): string;
64
+ /**
65
+ * Cwd leftover-slot lines (#3331). Pure: no deletes and no registry scan.
66
+ * When heads cannot be listed, do not title rows as orphaned or name
67
+ * `clean --stale` (#3337): that command refuses to delete in the same state.
68
+ */
69
+ export declare function orphanedBranchSlotDoctorLines(slots: StaleBranchSlot[]): string[];
63
70
  export declare function cachedUpdateDoctorLine(options: {
64
71
  installedVersion: string;
65
72
  eligible: boolean;
@@ -13,6 +13,9 @@ import { updateEligibleInstallSync } from '../core/install-context.js';
13
13
  import { readValidatedUpdateCacheSync } from '../core/update-cache.js';
14
14
  import { t } from './i18n/index.js';
15
15
  import { cachedUpdateNoticeLine } from './update-notice.js';
16
+ import { formatSlotSize, staleReasonLabel } from './stale-branch-format.js';
17
+ import { findRegistryEntryByRepoPath, findRepo, listRegisteredRepos, } from '../storage/repo-manager.js';
18
+ import { isDeleteCandidate, listStaleBranchSlots, staleListingBlock, } from '../storage/stale-branch-slots.js';
16
19
  function isCombiningMark(codePoint) {
17
20
  return ((codePoint >= 0x0300 && codePoint <= 0x036f) ||
18
21
  (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
@@ -145,6 +148,33 @@ export function poolSizeDoctorLine(pool, envRaw) {
145
148
  export function nativeStatusLine(check) {
146
149
  return ` ${padDisplayEnd('native', 10)}${nativeStatusText(check)}`;
147
150
  }
151
+ /**
152
+ * Cwd leftover-slot lines (#3331). Pure: no deletes and no registry scan.
153
+ * When heads cannot be listed, do not title rows as orphaned or name
154
+ * `clean --stale` (#3337): that command refuses to delete in the same state.
155
+ */
156
+ export function orphanedBranchSlotDoctorLines(slots) {
157
+ if (slots.length === 0)
158
+ return [];
159
+ const listingBlock = staleListingBlock(slots);
160
+ if (listingBlock === 'heads-unavailable') {
161
+ return [t('clean.stale.headsUnavailable')];
162
+ }
163
+ if (listingBlock === 'listing-failed') {
164
+ return [t('clean.stale.listingFailed')];
165
+ }
166
+ const lines = [t('doctor.orphanedBranches')];
167
+ let total = 0;
168
+ for (const slot of slots) {
169
+ total += slot.sizeBytes;
170
+ lines.push(` ${slot.branch} ${staleReasonLabel(slot.reason)} ${formatSlotSize(slot.sizeBytes)}`);
171
+ }
172
+ lines.push(` ${t('doctor.orphanedBranches.total', { size: formatSlotSize(total) })}`);
173
+ if (slots.some(isDeleteCandidate)) {
174
+ lines.push(` ${t('doctor.orphanedBranches.reclaim')}`);
175
+ }
176
+ return lines;
177
+ }
148
178
  function nativeStatusText(check) {
149
179
  if (check.ok)
150
180
  return '✓ lbugjs.node loaded';
@@ -285,4 +315,24 @@ export const doctorCommand = async () => {
285
315
  console.log(` ${padDisplayEnd('', 12)}${cudaRedirect.detail}`);
286
316
  }
287
317
  }
318
+ // Doctor stays runtime-global. Add only a cwd leftover-slot section when
319
+ // this process is inside an indexed repo. Look up that repo's registry row
320
+ // for recorded branch slugs; do not report leftovers for every registered
321
+ // repo, and never delete.
322
+ const [cwdRepo, entries] = await Promise.all([findRepo(process.cwd()), listRegisteredRepos()]);
323
+ if (!cwdRepo)
324
+ return;
325
+ const entry = findRegistryEntryByRepoPath(entries, cwdRepo.repoPath);
326
+ const slots = await listStaleBranchSlots({
327
+ repoPath: cwdRepo.repoPath,
328
+ storagePath: cwdRepo.storagePath,
329
+ branches: entry?.branches,
330
+ });
331
+ const orphanLines = orphanedBranchSlotDoctorLines(slots);
332
+ if (orphanLines.length === 0)
333
+ return;
334
+ console.log('');
335
+ for (const line of orphanLines) {
336
+ console.log(line);
337
+ }
288
338
  };
@@ -92,6 +92,7 @@ const OPTION_DESCRIPTION_KEYS = {
92
92
  'clean|--all': 'help.option.clean.all',
93
93
  'clean|--branch <name>': 'help.option.clean.branch',
94
94
  'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
95
+ 'clean|--stale': 'help.option.clean.stale',
95
96
  'remove|-f, --force': 'help.option.force.confirmation',
96
97
  'wiki|-f, --force': 'help.option.wiki.force',
97
98
  'wiki|--provider <provider>': 'help.option.wiki.provider',
@@ -53,7 +53,23 @@ export declare const en: {
53
53
  readonly 'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})';
54
54
  readonly 'clean.notFoundHere': 'No indexed repository found in this directory.';
55
55
  readonly 'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}';
56
- readonly 'clean.branchNotIndexed': 'No indexed branch named "{{branch}}" for this repository.';
56
+ readonly '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.';
57
+ readonly 'clean.stale.none': 'No leftover branch indexes to reclaim.';
58
+ readonly 'clean.stale.preview': 'This will delete {{count}} leftover branch index(es):';
59
+ readonly 'clean.stale.item': '{{branch}} {{reason}} {{path}} {{size}}';
60
+ readonly 'clean.stale.registryOnlyPath': '(registry only)';
61
+ readonly 'clean.stale.headsUnavailable': 'Could not list local heads; leftover branch indexes were not deleted. Re-run `gitnexus clean --stale` when git is available.';
62
+ readonly '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`.';
63
+ readonly 'clean.stale.probeFailed': 'Could not inspect leftover branch index path(s); those slots were not deleted.';
64
+ readonly 'clean.stale.deleted': 'Deleted leftover branch index: {{branch}}';
65
+ readonly 'clean.stale.failed': 'Could not delete leftover branch index "{{branch}}".';
66
+ readonly 'clean.stale.skippedLive': 'Skipped leftover branch index "{{branch}}" — it is a local head again.';
67
+ readonly 'clean.stale.reason.refMissing': 'not a local head';
68
+ readonly 'clean.stale.reason.diskOnly': 'leftover directory (no registry row)';
69
+ readonly 'clean.stale.reason.registryOnly': 'registry row (directory gone)';
70
+ readonly 'clean.stale.reason.headsUnavailable': 'could not list local heads';
71
+ readonly 'clean.stale.reason.probeFailed': 'could not inspect slot path';
72
+ readonly 'clean.stale.reason.listingFailed': 'could not list leftover directories';
57
73
  readonly 'clean.deleteBranch': 'This will delete the branch index "{{branch}}" at: {{path}}';
58
74
  readonly 'clean.deletedBranch': 'Deleted branch index: {{branch}}';
59
75
  readonly 'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}';
@@ -95,6 +111,9 @@ export declare const en: {
95
111
  readonly 'doctor.runtime': 'Runtime';
96
112
  readonly 'doctor.capabilities': 'Capabilities';
97
113
  readonly 'doctor.embeddings': 'Embeddings';
114
+ readonly 'doctor.orphanedBranches': 'Orphaned branch indexes';
115
+ readonly 'doctor.orphanedBranches.total': 'Total: {{size}}';
116
+ readonly 'doctor.orphanedBranches.reclaim': 'Reclaim with: gitnexus clean --stale';
98
117
  readonly 'doctor.labels.os': 'OS:';
99
118
  readonly 'doctor.labels.node': 'Node:';
100
119
  readonly 'doctor.labels.gitnexus': 'GitNexus:';
@@ -215,6 +234,7 @@ export declare const en: {
215
234
  readonly 'help.option.clean.all': 'Clean all indexed repos';
216
235
  readonly 'help.option.clean.branch': 'Delete only the named branch index (not the workspace index)';
217
236
  readonly 'help.option.clean.lbugSidecars': 'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)';
237
+ readonly 'help.option.clean.stale': 'Reclaim leftover branch indexes that are not a live local head';
218
238
  readonly 'help.option.wiki.force': 'Force full regeneration even if up to date';
219
239
  readonly 'help.option.wiki.provider': 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)';
220
240
  readonly 'help.option.wiki.model': 'LLM model or deployment name (default: MiniMax-M3)';
@@ -53,7 +53,23 @@ export const en = {
53
53
  'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
54
54
  'clean.notFoundHere': 'No indexed repository found in this directory.',
55
55
  'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
56
- 'clean.branchNotIndexed': 'No indexed branch named "{{branch}}" for this repository.',
56
+ '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.',
57
+ 'clean.stale.none': 'No leftover branch indexes to reclaim.',
58
+ 'clean.stale.preview': 'This will delete {{count}} leftover branch index(es):',
59
+ 'clean.stale.item': '{{branch}} {{reason}} {{path}} {{size}}',
60
+ 'clean.stale.registryOnlyPath': '(registry only)',
61
+ 'clean.stale.headsUnavailable': 'Could not list local heads; leftover branch indexes were not deleted. Re-run `gitnexus clean --stale` when git is available.',
62
+ '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`.',
63
+ 'clean.stale.probeFailed': 'Could not inspect leftover branch index path(s); those slots were not deleted.',
64
+ 'clean.stale.deleted': 'Deleted leftover branch index: {{branch}}',
65
+ 'clean.stale.failed': 'Could not delete leftover branch index "{{branch}}".',
66
+ 'clean.stale.skippedLive': 'Skipped leftover branch index "{{branch}}" — it is a local head again.',
67
+ 'clean.stale.reason.refMissing': 'not a local head',
68
+ 'clean.stale.reason.diskOnly': 'leftover directory (no registry row)',
69
+ 'clean.stale.reason.registryOnly': 'registry row (directory gone)',
70
+ 'clean.stale.reason.headsUnavailable': 'could not list local heads',
71
+ 'clean.stale.reason.probeFailed': 'could not inspect slot path',
72
+ 'clean.stale.reason.listingFailed': 'could not list leftover directories',
57
73
  'clean.deleteBranch': 'This will delete the branch index "{{branch}}" at: {{path}}',
58
74
  'clean.deletedBranch': 'Deleted branch index: {{branch}}',
59
75
  'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}',
@@ -97,6 +113,9 @@ export const en = {
97
113
  'doctor.runtime': 'Runtime',
98
114
  'doctor.capabilities': 'Capabilities',
99
115
  'doctor.embeddings': 'Embeddings',
116
+ 'doctor.orphanedBranches': 'Orphaned branch indexes',
117
+ 'doctor.orphanedBranches.total': 'Total: {{size}}',
118
+ 'doctor.orphanedBranches.reclaim': 'Reclaim with: gitnexus clean --stale',
100
119
  'doctor.labels.os': 'OS:',
101
120
  'doctor.labels.node': 'Node:',
102
121
  'doctor.labels.gitnexus': 'GitNexus:',
@@ -217,6 +236,7 @@ export const en = {
217
236
  'help.option.clean.all': 'Clean all indexed repos',
218
237
  'help.option.clean.branch': 'Delete only the named branch index (not the workspace index)',
219
238
  'help.option.clean.lbugSidecars': 'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
239
+ 'help.option.clean.stale': 'Reclaim leftover branch indexes that are not a live local head',
220
240
  'help.option.wiki.force': 'Force full regeneration even if up to date',
221
241
  'help.option.wiki.provider': 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)',
222
242
  'help.option.wiki.model': 'LLM model or deployment name (default: MiniMax-M3)',
@@ -54,7 +54,23 @@ export declare const cliResources: {
54
54
  readonly 'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})';
55
55
  readonly 'clean.notFoundHere': 'No indexed repository found in this directory.';
56
56
  readonly 'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}';
57
- readonly 'clean.branchNotIndexed': 'No indexed branch named "{{branch}}" for this repository.';
57
+ readonly '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.';
58
+ readonly 'clean.stale.none': 'No leftover branch indexes to reclaim.';
59
+ readonly 'clean.stale.preview': 'This will delete {{count}} leftover branch index(es):';
60
+ readonly 'clean.stale.item': '{{branch}} {{reason}} {{path}} {{size}}';
61
+ readonly 'clean.stale.registryOnlyPath': '(registry only)';
62
+ readonly 'clean.stale.headsUnavailable': 'Could not list local heads; leftover branch indexes were not deleted. Re-run `gitnexus clean --stale` when git is available.';
63
+ readonly '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`.';
64
+ readonly 'clean.stale.probeFailed': 'Could not inspect leftover branch index path(s); those slots were not deleted.';
65
+ readonly 'clean.stale.deleted': 'Deleted leftover branch index: {{branch}}';
66
+ readonly 'clean.stale.failed': 'Could not delete leftover branch index "{{branch}}".';
67
+ readonly 'clean.stale.skippedLive': 'Skipped leftover branch index "{{branch}}" — it is a local head again.';
68
+ readonly 'clean.stale.reason.refMissing': 'not a local head';
69
+ readonly 'clean.stale.reason.diskOnly': 'leftover directory (no registry row)';
70
+ readonly 'clean.stale.reason.registryOnly': 'registry row (directory gone)';
71
+ readonly 'clean.stale.reason.headsUnavailable': 'could not list local heads';
72
+ readonly 'clean.stale.reason.probeFailed': 'could not inspect slot path';
73
+ readonly 'clean.stale.reason.listingFailed': 'could not list leftover directories';
58
74
  readonly 'clean.deleteBranch': 'This will delete the branch index "{{branch}}" at: {{path}}';
59
75
  readonly 'clean.deletedBranch': 'Deleted branch index: {{branch}}';
60
76
  readonly 'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}';
@@ -96,6 +112,9 @@ export declare const cliResources: {
96
112
  readonly 'doctor.runtime': 'Runtime';
97
113
  readonly 'doctor.capabilities': 'Capabilities';
98
114
  readonly 'doctor.embeddings': 'Embeddings';
115
+ readonly 'doctor.orphanedBranches': 'Orphaned branch indexes';
116
+ readonly 'doctor.orphanedBranches.total': 'Total: {{size}}';
117
+ readonly 'doctor.orphanedBranches.reclaim': 'Reclaim with: gitnexus clean --stale';
99
118
  readonly 'doctor.labels.os': 'OS:';
100
119
  readonly 'doctor.labels.node': 'Node:';
101
120
  readonly 'doctor.labels.gitnexus': 'GitNexus:';
@@ -216,6 +235,7 @@ export declare const cliResources: {
216
235
  readonly 'help.option.clean.all': 'Clean all indexed repos';
217
236
  readonly 'help.option.clean.branch': 'Delete only the named branch index (not the workspace index)';
218
237
  readonly 'help.option.clean.lbugSidecars': 'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)';
238
+ readonly 'help.option.clean.stale': 'Reclaim leftover branch indexes that are not a live local head';
219
239
  readonly 'help.option.wiki.force': 'Force full regeneration even if up to date';
220
240
  readonly 'help.option.wiki.provider': 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)';
221
241
  readonly 'help.option.wiki.model': 'LLM model or deployment name (default: MiniMax-M3)';
@@ -341,6 +361,22 @@ export declare const cliResources: {
341
361
  'clean.notFoundHere': string;
342
362
  'clean.deleteCurrent': string;
343
363
  'clean.branchNotIndexed': string;
364
+ 'clean.stale.none': string;
365
+ 'clean.stale.preview': string;
366
+ 'clean.stale.item': string;
367
+ 'clean.stale.registryOnlyPath': string;
368
+ 'clean.stale.headsUnavailable': string;
369
+ 'clean.stale.listingFailed': string;
370
+ 'clean.stale.probeFailed': string;
371
+ 'clean.stale.deleted': string;
372
+ 'clean.stale.failed': string;
373
+ 'clean.stale.skippedLive': string;
374
+ 'clean.stale.reason.refMissing': string;
375
+ 'clean.stale.reason.diskOnly': string;
376
+ 'clean.stale.reason.registryOnly': string;
377
+ 'clean.stale.reason.headsUnavailable': string;
378
+ 'clean.stale.reason.probeFailed': string;
379
+ 'clean.stale.reason.listingFailed': string;
344
380
  'clean.deleteBranch': string;
345
381
  'clean.deletedBranch': string;
346
382
  'clean.lbugSidecars.state': string;
@@ -382,6 +418,9 @@ export declare const cliResources: {
382
418
  'doctor.runtime': string;
383
419
  'doctor.capabilities': string;
384
420
  'doctor.embeddings': string;
421
+ 'doctor.orphanedBranches': string;
422
+ 'doctor.orphanedBranches.total': string;
423
+ 'doctor.orphanedBranches.reclaim': string;
385
424
  'doctor.labels.os': string;
386
425
  'doctor.labels.node': string;
387
426
  'doctor.labels.gitnexus': string;
@@ -502,6 +541,7 @@ export declare const cliResources: {
502
541
  'help.option.clean.all': string;
503
542
  'help.option.clean.branch': string;
504
543
  'help.option.clean.lbugSidecars': string;
544
+ 'help.option.clean.stale': string;
505
545
  'help.option.wiki.force': string;
506
546
  'help.option.wiki.provider': string;
507
547
  'help.option.wiki.model': string;
@@ -54,6 +54,22 @@ export declare const zhCN: {
54
54
  'clean.notFoundHere': string;
55
55
  'clean.deleteCurrent': string;
56
56
  'clean.branchNotIndexed': string;
57
+ 'clean.stale.none': string;
58
+ 'clean.stale.preview': string;
59
+ 'clean.stale.item': string;
60
+ 'clean.stale.registryOnlyPath': string;
61
+ 'clean.stale.headsUnavailable': string;
62
+ 'clean.stale.listingFailed': string;
63
+ 'clean.stale.probeFailed': string;
64
+ 'clean.stale.deleted': string;
65
+ 'clean.stale.failed': string;
66
+ 'clean.stale.skippedLive': string;
67
+ 'clean.stale.reason.refMissing': string;
68
+ 'clean.stale.reason.diskOnly': string;
69
+ 'clean.stale.reason.registryOnly': string;
70
+ 'clean.stale.reason.headsUnavailable': string;
71
+ 'clean.stale.reason.probeFailed': string;
72
+ 'clean.stale.reason.listingFailed': string;
57
73
  'clean.deleteBranch': string;
58
74
  'clean.deletedBranch': string;
59
75
  'clean.lbugSidecars.state': string;
@@ -95,6 +111,9 @@ export declare const zhCN: {
95
111
  'doctor.runtime': string;
96
112
  'doctor.capabilities': string;
97
113
  'doctor.embeddings': string;
114
+ 'doctor.orphanedBranches': string;
115
+ 'doctor.orphanedBranches.total': string;
116
+ 'doctor.orphanedBranches.reclaim': string;
98
117
  'doctor.labels.os': string;
99
118
  'doctor.labels.node': string;
100
119
  'doctor.labels.gitnexus': string;
@@ -215,6 +234,7 @@ export declare const zhCN: {
215
234
  'help.option.clean.all': string;
216
235
  'help.option.clean.branch': string;
217
236
  'help.option.clean.lbugSidecars': string;
237
+ 'help.option.clean.stale': string;
218
238
  'help.option.wiki.force': string;
219
239
  'help.option.wiki.provider': string;
220
240
  'help.option.wiki.model': string;
@@ -53,7 +53,23 @@ export const zhCN = {
53
53
  'clean.deletedRepo': '已删除:{{name}}({{storagePath}})',
54
54
  'clean.notFoundHere': '当前目录未找到已索引仓库。',
55
55
  'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
56
- 'clean.branchNotIndexed': '该仓库没有名为 “{{branch}}” 的已索引分支。',
56
+ 'clean.branchNotIndexed': '该仓库没有名为 “{{branch}}” 的已索引分支。使用 `gitnexus clean --stale` 回收残留分支索引,或使用 `gitnexus list` 查看已记录名称。',
57
+ 'clean.stale.none': '没有可回收的残留分支索引。',
58
+ 'clean.stale.preview': '将删除 {{count}} 个残留分支索引:',
59
+ 'clean.stale.item': '{{branch}} {{reason}} {{path}} {{size}}',
60
+ 'clean.stale.registryOnlyPath': '(仅注册表)',
61
+ 'clean.stale.headsUnavailable': '无法列出本地分支,因此未删除残留分支索引。请在 git 可用后重新运行 `gitnexus clean --stale`。',
62
+ 'clean.stale.listingFailed': '无法读取残留分支索引目录,因此未删除残留索引。请检查 branches/ 目录权限后重新运行 `gitnexus clean --stale`。',
63
+ 'clean.stale.probeFailed': '无法检查残留分支索引路径,因此未删除这些槽位。',
64
+ 'clean.stale.deleted': '已删除残留分支索引:{{branch}}',
65
+ 'clean.stale.failed': '无法删除残留分支索引 “{{branch}}”。',
66
+ 'clean.stale.skippedLive': '已跳过残留分支索引 “{{branch}}” — 它再次成为本地分支。',
67
+ 'clean.stale.reason.refMissing': '不是本地分支',
68
+ 'clean.stale.reason.diskOnly': '残留目录(无注册表记录)',
69
+ 'clean.stale.reason.registryOnly': '注册表记录(目录已不存在)',
70
+ 'clean.stale.reason.headsUnavailable': '无法列出本地分支',
71
+ 'clean.stale.reason.probeFailed': '无法检查槽位路径',
72
+ 'clean.stale.reason.listingFailed': '无法列出残留目录',
57
73
  'clean.deleteBranch': '将删除分支索引 “{{branch}}”,路径:{{path}}',
58
74
  'clean.deletedBranch': '已删除分支索引:{{branch}}',
59
75
  'clean.lbugSidecars.state': 'LadybugDB sidecar 状态:{{state}}',
@@ -95,6 +111,9 @@ export const zhCN = {
95
111
  'doctor.runtime': '运行时',
96
112
  'doctor.capabilities': '能力',
97
113
  'doctor.embeddings': '嵌入',
114
+ 'doctor.orphanedBranches': '残留分支索引',
115
+ 'doctor.orphanedBranches.total': '合计:{{size}}',
116
+ 'doctor.orphanedBranches.reclaim': '回收命令:gitnexus clean --stale',
98
117
  'doctor.labels.os': '系统:',
99
118
  'doctor.labels.node': 'Node:',
100
119
  'doctor.labels.gitnexus': 'GitNexus:',
@@ -215,6 +234,7 @@ export const zhCN = {
215
234
  'help.option.clean.all': '清理所有已索引仓库',
216
235
  'help.option.clean.branch': '仅删除指定分支的索引(不影响工作区索引)',
217
236
  'help.option.clean.lbugSidecars': '清理已暂存的 LadybugDB 恢复 sidecar(missing-shadow WAL 隔离文件与 dirty-recovery 暂存文件)',
237
+ 'help.option.clean.stale': '回收已不是本地 head 的残留分支索引',
218
238
  'help.option.wiki.force': '即使已是最新也强制完整重新生成',
219
239
  'help.option.wiki.provider': 'LLM 提供商:minimax、openai、openrouter、azure、custom、cursor、claude、codex、opencode 或 grok(默认:minimax)',
220
240
  'help.option.wiki.model': 'LLM 模型或 deployment 名称(默认:MiniMax-M3)',
package/dist/cli/index.js CHANGED
@@ -211,6 +211,7 @@ program
211
211
  .option('-f, --force', 'Skip confirmation prompt')
212
212
  .option('--all', 'Clean all indexed repos')
213
213
  .option('--branch <name>', 'Delete only the named branch index (not the workspace index)')
214
+ .option('--stale', 'Reclaim leftover branch indexes that are not a live local head')
214
215
  .option('--lbug-sidecars', 'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)')
215
216
  .action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
216
217
  program
@@ -0,0 +1,4 @@
1
+ import { type StaleBranchReason, type StaleBranchSlot } from '../storage/stale-branch-slots.js';
2
+ export declare const formatSlotSize: (bytes: number) => string;
3
+ export declare const staleReasonLabel: (reason: StaleBranchReason) => string;
4
+ export declare const formatStaleSlotLine: (slot: StaleBranchSlot) => string;
@@ -0,0 +1,34 @@
1
+ import { t } from './i18n/index.js';
2
+ export const formatSlotSize = (bytes) => {
3
+ if (bytes < 1024)
4
+ return `${bytes} B`;
5
+ if (bytes < 1024 * 1024)
6
+ return `${(bytes / 1024).toFixed(1)} KB`;
7
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
8
+ };
9
+ export const staleReasonLabel = (reason) => {
10
+ switch (reason) {
11
+ case 'ref-missing':
12
+ return t('clean.stale.reason.refMissing');
13
+ case 'disk-only':
14
+ return t('clean.stale.reason.diskOnly');
15
+ case 'registry-only':
16
+ return t('clean.stale.reason.registryOnly');
17
+ case 'heads-unavailable':
18
+ return t('clean.stale.reason.headsUnavailable');
19
+ case 'probe-failed':
20
+ return t('clean.stale.reason.probeFailed');
21
+ case 'listing-failed':
22
+ return t('clean.stale.reason.listingFailed');
23
+ default: {
24
+ const _exhaustive = reason;
25
+ return _exhaustive;
26
+ }
27
+ }
28
+ };
29
+ export const formatStaleSlotLine = (slot) => t('clean.stale.item', {
30
+ branch: slot.branch,
31
+ reason: staleReasonLabel(slot.reason),
32
+ path: slot.dir ?? t('clean.stale.registryOnlyPath'),
33
+ size: formatSlotSize(slot.sizeBytes),
34
+ });
@@ -227,6 +227,12 @@ export declare const getDefaultBranch: (repoPath: string) => string | null;
227
227
  * "HEAD" (#2106).
228
228
  */
229
229
  export declare const getCurrentBranch: (repoPath: string) => string | null;
230
+ /**
231
+ * Local `refs/heads` names, or `null` when the directory is not a git
232
+ * worktree or git cannot run. An empty array means the listing succeeded
233
+ * and there are no local heads — that is not a listing failure (#3331).
234
+ */
235
+ export declare const listLocalHeads: (repoPath: string) => string[] | null;
230
236
  /**
231
237
  * Sanitize a repository name to prevent argument injection and ensure
232
238
  * cross-platform filesystem compatibility.
@@ -1,4 +1,4 @@
1
- import { execFileSync, execSync } from 'child_process';
1
+ import { execFileSync, execSync, spawnSync } from 'child_process';
2
2
  import { statSync, existsSync } from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
@@ -633,6 +633,36 @@ export const getCurrentBranch = (repoPath) => {
633
633
  return null;
634
634
  }
635
635
  };
636
+ /**
637
+ * Local `refs/heads` names, or `null` when the directory is not a git
638
+ * worktree or git cannot run. An empty array means the listing succeeded
639
+ * and there are no local heads — that is not a listing failure (#3331).
640
+ */
641
+ export const listLocalHeads = (repoPath) => {
642
+ try {
643
+ const result = spawnSync('git', ['for-each-ref', '--format=%(refname)', 'refs/heads'], {
644
+ cwd: repoPath,
645
+ encoding: 'utf-8',
646
+ stdio: ['ignore', 'pipe', 'ignore'],
647
+ windowsHide: true,
648
+ maxBuffer: GIT_PATH_LIST_MAX_BUFFER,
649
+ });
650
+ if (result.error || result.status !== 0)
651
+ return null;
652
+ const output = (result.stdout ?? '').toString().trim();
653
+ if (!output)
654
+ return [];
655
+ return output
656
+ .split('\n')
657
+ .map((line) => line.trim())
658
+ .filter((line) => line.startsWith('refs/heads/'))
659
+ .map((line) => line.slice('refs/heads/'.length))
660
+ .filter((line) => line.length > 0);
661
+ }
662
+ catch {
663
+ return null;
664
+ }
665
+ };
636
666
  /**
637
667
  * Sanitize a repository name to prevent argument injection and ensure
638
668
  * cross-platform filesystem compatibility.
@@ -120,6 +120,8 @@ export interface RegistryEntry {
120
120
  */
121
121
  branches?: BranchSummary[];
122
122
  }
123
+ /** Path-only registry lookup. Canonicalizes `repoPath` once. Does not throw. */
124
+ export declare const findRegistryEntryByRepoPath: (entries: readonly RegistryEntry[], repoPath: string) => RegistryEntry | undefined;
123
125
  /**
124
126
  * Get paths to key storage files.
125
127
  *
@@ -110,6 +110,11 @@ export const registryPathEquals = (a, b) => process.platform === 'win32' ? a.toL
110
110
  * differently-spelled forms of the same dir still match.
111
111
  */
112
112
  export const cloneDirBelongsToEntry = (cloneDir, entryPath) => registryPathEquals(canonicalizePath(cloneDir), canonicalizePath(entryPath));
113
+ /** Path-only registry lookup. Canonicalizes `repoPath` once. Does not throw. */
114
+ export const findRegistryEntryByRepoPath = (entries, repoPath) => {
115
+ const repoKey = canonicalizePath(repoPath);
116
+ return entries.find((entry) => registryPathEquals(canonicalizePath(entry.path), repoKey));
117
+ };
113
118
  const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
114
119
  // ─── Local Storage Helpers ─────────────────────────────────────────────
115
120
  /**
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Classify and reclaim leftover per-branch index slots (#3331).
3
+ *
4
+ * Live means a name in local `refs/heads`. Classification never reverses
5
+ * `branchSlug`; registry rows join through the same forward slug path
6
+ * `clean --branch` already computes. Directory delete happens before the
7
+ * registry drop; a failed rm keeps the summary so a later clean can retry.
8
+ */
9
+ export type StaleBranchReason = 'ref-missing' | 'registry-only' | 'disk-only' | 'heads-unavailable' | 'probe-failed' | 'listing-failed';
10
+ export interface StaleBranchSlot {
11
+ branch: string;
12
+ dir: string | null;
13
+ sizeBytes: number;
14
+ reason: StaleBranchReason;
15
+ }
16
+ export interface ListStaleBranchSlotsInput {
17
+ repoPath: string;
18
+ storagePath: string;
19
+ branches?: readonly {
20
+ branch: string;
21
+ }[];
22
+ /** Injected in tests. When omitted, listed from `repoPath`. */
23
+ heads?: string[] | null;
24
+ /** Default true. `--stale --force` skips the size walk; it never prints sizes. */
25
+ includeSize?: boolean;
26
+ }
27
+ export declare const listStaleBranchSlots: (input: ListStaleBranchSlotsInput) => Promise<StaleBranchSlot[]>;
28
+ export declare const isContainedBranchDir: (storagePath: string, dir: string) => boolean;
29
+ export declare const isDeleteCandidate: (slot: StaleBranchSlot) => boolean;
30
+ export type StaleListingBlock = 'heads-unavailable' | 'listing-failed';
31
+ /** Git-list or branches/ listing failed; clean and doctor must not reclaim. */
32
+ export declare const staleListingBlock: (slots: readonly StaleBranchSlot[]) => StaleListingBlock | null;
33
+ export interface RemoveBranchSlotInput {
34
+ repoPath: string;
35
+ storagePath: string;
36
+ branch: string;
37
+ /** Slot directory to remove, or `null` for a registry-only row. */
38
+ dir: string | null;
39
+ }
40
+ export interface RemoveBranchSlotResult {
41
+ ok: boolean;
42
+ emptiedBranchesDir: boolean;
43
+ keptRegistry: boolean;
44
+ error?: Error;
45
+ }
46
+ export declare const removeBranchSlot: (input: RemoveBranchSlotInput) => Promise<RemoveBranchSlotResult>;
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Classify and reclaim leftover per-branch index slots (#3331).
3
+ *
4
+ * Live means a name in local `refs/heads`. Classification never reverses
5
+ * `branchSlug`; registry rows join through the same forward slug path
6
+ * `clean --branch` already computes. Directory delete happens before the
7
+ * registry drop; a failed rm keeps the summary so a later clean can retry.
8
+ */
9
+ import fs from 'fs/promises';
10
+ import path from 'path';
11
+ import { BRANCHES_DIR } from './branch-index.js';
12
+ import { listLocalHeads } from './git.js';
13
+ import { isMissingFilesystemError, loadMeta } from './repo-meta.js';
14
+ import { getStoragePaths, removeBranchIndex } from './repo-manager.js';
15
+ const slotDirForBranch = (repoPath, storagePath, branch) => path.dirname(getStoragePaths(repoPath, branch, storagePath).metaPath);
16
+ /** Same bound as `mapPool` in repo-manager: cap concurrent slot I/O. */
17
+ const STALE_SLOT_IO_CONCURRENCY = 8;
18
+ const mapPool = async (items, mapper) => {
19
+ if (items.length === 0)
20
+ return [];
21
+ const results = new Array(items.length);
22
+ let next = 0;
23
+ const workerCount = Math.max(1, Math.min(STALE_SLOT_IO_CONCURRENCY, items.length));
24
+ await Promise.all(Array.from({ length: workerCount }, async () => {
25
+ while (true) {
26
+ const index = next;
27
+ next += 1;
28
+ if (index >= items.length)
29
+ return;
30
+ results[index] = await mapper(items[index]);
31
+ }
32
+ }));
33
+ return results;
34
+ };
35
+ const probeDirectory = async (dir) => {
36
+ try {
37
+ return (await fs.stat(dir)).isDirectory() ? 'dir' : 'unreadable';
38
+ }
39
+ catch (err) {
40
+ return isMissingFilesystemError(err) ? 'missing' : 'unreadable';
41
+ }
42
+ };
43
+ const directorySizeBytes = async (root) => {
44
+ let total = 0;
45
+ const stack = [root];
46
+ while (stack.length > 0) {
47
+ const current = stack.pop();
48
+ if (current === undefined)
49
+ break;
50
+ let entries;
51
+ try {
52
+ entries = await fs.readdir(current, { withFileTypes: true });
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ const files = entries
58
+ .filter((entry) => entry.isFile())
59
+ .map((entry) => path.join(current, entry.name));
60
+ for (const entry of entries) {
61
+ if (entry.isDirectory())
62
+ stack.push(path.join(current, entry.name));
63
+ }
64
+ for (const file of files) {
65
+ try {
66
+ total += (await fs.stat(file)).size;
67
+ }
68
+ catch {
69
+ // Skip files that disappear or become unreadable mid-walk.
70
+ }
71
+ }
72
+ }
73
+ return total;
74
+ };
75
+ const metadataBranch = async (dir) => {
76
+ const meta = await loadMeta(dir);
77
+ return typeof meta?.branch === 'string' && meta.branch.length > 0 ? meta.branch : null;
78
+ };
79
+ export const listStaleBranchSlots = async (input) => {
80
+ const recorded = input.branches ?? [];
81
+ const branchesRoot = path.join(input.storagePath, BRANCHES_DIR);
82
+ const registryByDir = new Map();
83
+ for (const row of recorded) {
84
+ registryByDir.set(path.resolve(slotDirForBranch(input.repoPath, input.storagePath, row.branch)), row.branch);
85
+ }
86
+ let diskDirs = [];
87
+ try {
88
+ const entries = await fs.readdir(branchesRoot, { withFileTypes: true });
89
+ diskDirs = entries
90
+ .filter((entry) => entry.isDirectory())
91
+ .map((entry) => path.join(branchesRoot, entry.name));
92
+ }
93
+ catch (err) {
94
+ if (!isMissingFilesystemError(err)) {
95
+ return [{ branch: '', dir: null, sizeBytes: 0, reason: 'listing-failed' }];
96
+ }
97
+ diskDirs = [];
98
+ }
99
+ if (recorded.length === 0 && diskDirs.length === 0)
100
+ return [];
101
+ const heads = input.heads !== undefined ? input.heads : listLocalHeads(input.repoPath);
102
+ const live = heads === null ? null : new Set(heads);
103
+ const pending = [];
104
+ const registryProbes = await mapPool([...registryByDir], async ([resolvedDir, branch]) => ({
105
+ resolvedDir,
106
+ branch,
107
+ probe: await probeDirectory(resolvedDir),
108
+ }));
109
+ for (const { resolvedDir, branch, probe } of registryProbes) {
110
+ if (probe === 'unreadable') {
111
+ pending.push({ branch, dir: resolvedDir, reason: 'probe-failed' });
112
+ continue;
113
+ }
114
+ const exists = probe === 'dir';
115
+ const dir = exists ? resolvedDir : null;
116
+ if (live === null) {
117
+ pending.push({ branch, dir, reason: 'heads-unavailable' });
118
+ continue;
119
+ }
120
+ if (!exists) {
121
+ pending.push({ branch, dir: null, reason: 'registry-only' });
122
+ continue;
123
+ }
124
+ if (!live.has(branch)) {
125
+ pending.push({ branch, dir, reason: 'ref-missing' });
126
+ }
127
+ }
128
+ const leftoverDirs = diskDirs.filter((dir) => !registryByDir.has(path.resolve(dir)));
129
+ const leftoverMeta = await mapPool(leftoverDirs, async (dir) => ({
130
+ dir,
131
+ branch: await metadataBranch(dir),
132
+ }));
133
+ for (const { dir, branch } of leftoverMeta) {
134
+ if (branch === null)
135
+ continue;
136
+ const resolved = path.resolve(dir);
137
+ if (live === null) {
138
+ pending.push({ branch, dir: resolved, reason: 'heads-unavailable' });
139
+ continue;
140
+ }
141
+ if (!live.has(branch)) {
142
+ pending.push({ branch, dir: resolved, reason: 'disk-only' });
143
+ }
144
+ }
145
+ const includeSize = input.includeSize !== false;
146
+ return mapPool(pending, async (row) => ({
147
+ ...row,
148
+ sizeBytes: includeSize && row.dir ? await directorySizeBytes(row.dir) : 0,
149
+ }));
150
+ };
151
+ /** Lexical / realpath containment: `child` is a proper descendant of `parent`. */
152
+ const isProperChildPath = (parent, child) => {
153
+ const root = path.resolve(parent);
154
+ const resolved = path.resolve(child);
155
+ const relative = path.relative(root, resolved);
156
+ return (relative !== '' &&
157
+ relative !== '..' &&
158
+ !relative.startsWith(`..${path.sep}`) &&
159
+ !path.isAbsolute(relative));
160
+ };
161
+ const isSameNormalizedPath = (left, right) => path.relative(path.resolve(path.normalize(left)), path.resolve(path.normalize(right))) === '';
162
+ export const isContainedBranchDir = (storagePath, dir) => isProperChildPath(path.resolve(storagePath, BRANCHES_DIR), dir);
163
+ const toError = (err) => (err instanceof Error ? err : new Error(String(err)));
164
+ const keepRegistryFailure = (error) => ({
165
+ ok: false,
166
+ emptiedBranchesDir: false,
167
+ keptRegistry: true,
168
+ error,
169
+ });
170
+ const refuseOutsideSlot = (dir) => keepRegistryFailure(new Error(`Refusing to clean branch index outside the validated storage slot: ${dir}`));
171
+ const slotPathExists = async (slotDir) => {
172
+ try {
173
+ await fs.lstat(slotDir);
174
+ return true;
175
+ }
176
+ catch (err) {
177
+ return !isMissingFilesystemError(err);
178
+ }
179
+ };
180
+ /**
181
+ * Delete a lexically contained slot. Returns a failure result, or `null` when
182
+ * the slot path is gone and the registry row may drop.
183
+ */
184
+ const removeValidatedSlotDir = async (storagePath, dir) => {
185
+ if (!isContainedBranchDir(storagePath, dir)) {
186
+ return refuseOutsideSlot(dir);
187
+ }
188
+ const branchesRoot = path.resolve(storagePath, BRANCHES_DIR);
189
+ let branchesStat;
190
+ try {
191
+ branchesStat = await fs.lstat(branchesRoot);
192
+ }
193
+ catch (err) {
194
+ if (isMissingFilesystemError(err))
195
+ return null;
196
+ return keepRegistryFailure(toError(err));
197
+ }
198
+ // Never walk a branches/ symlink (rm of a child would delete the target).
199
+ if (branchesStat.isSymbolicLink()) {
200
+ return refuseOutsideSlot(dir);
201
+ }
202
+ let realStorage;
203
+ let realBranches;
204
+ try {
205
+ realStorage = await fs.realpath(storagePath);
206
+ realBranches = await fs.realpath(branchesRoot);
207
+ }
208
+ catch (err) {
209
+ if (isMissingFilesystemError(err))
210
+ return null;
211
+ return keepRegistryFailure(toError(err));
212
+ }
213
+ // Junctions may not report as symlinks from lstat; realpath must still land
214
+ // on storagePath/branches, not an outside tree.
215
+ const expectedBranches = path.normalize(path.join(realStorage, BRANCHES_DIR));
216
+ if (!isSameNormalizedPath(realBranches, expectedBranches)) {
217
+ return refuseOutsideSlot(dir);
218
+ }
219
+ let slotStat;
220
+ try {
221
+ slotStat = await fs.lstat(dir);
222
+ }
223
+ catch (err) {
224
+ if (isMissingFilesystemError(err))
225
+ return null;
226
+ return keepRegistryFailure(toError(err));
227
+ }
228
+ let deleteError;
229
+ try {
230
+ // A symlink, or a Windows junction that lstat reports as a directory,
231
+ // must be unlinked at the lexical path. Never fs.rm through a target
232
+ // that realpath places outside branches/.
233
+ const realDir = slotStat.isSymbolicLink() ? null : await fs.realpath(dir);
234
+ const unlinkOnly = slotStat.isSymbolicLink() || (realDir !== null && !isProperChildPath(realBranches, realDir));
235
+ // Revalidate immediately before the destructive op. Another process can
236
+ // replace branches/ or the slot after the earlier lstat/realpath awaits.
237
+ const lastBranches = await fs.lstat(branchesRoot);
238
+ if (lastBranches.isSymbolicLink()) {
239
+ return refuseOutsideSlot(dir);
240
+ }
241
+ const lastSlot = await fs.lstat(dir);
242
+ const lastUnlinkOnly = lastSlot.isSymbolicLink() || unlinkOnly;
243
+ if (lastUnlinkOnly) {
244
+ await fs.unlink(dir);
245
+ }
246
+ else {
247
+ const lastReal = await fs.realpath(dir);
248
+ if (!isProperChildPath(realBranches, lastReal)) {
249
+ await fs.unlink(dir);
250
+ }
251
+ else {
252
+ await fs.rm(dir, { recursive: true, force: true });
253
+ }
254
+ }
255
+ }
256
+ catch (err) {
257
+ deleteError = toError(err);
258
+ }
259
+ if (await slotPathExists(dir)) {
260
+ return keepRegistryFailure(deleteError ?? new Error(`Could not remove branch index: ${dir}`));
261
+ }
262
+ return null;
263
+ };
264
+ export const isDeleteCandidate = (slot) => slot.reason === 'ref-missing' || slot.reason === 'registry-only' || slot.reason === 'disk-only';
265
+ /** Git-list or branches/ listing failed; clean and doctor must not reclaim. */
266
+ export const staleListingBlock = (slots) => {
267
+ if (slots.some((slot) => slot.reason === 'heads-unavailable'))
268
+ return 'heads-unavailable';
269
+ if (slots.some((slot) => slot.reason === 'listing-failed'))
270
+ return 'listing-failed';
271
+ return null;
272
+ };
273
+ const rmdirEmptyBranches = async (storagePath) => {
274
+ try {
275
+ await fs.rmdir(path.join(storagePath, BRANCHES_DIR));
276
+ return true;
277
+ }
278
+ catch {
279
+ return false;
280
+ }
281
+ };
282
+ export const removeBranchSlot = async (input) => {
283
+ const { repoPath, storagePath, branch, dir } = input;
284
+ if (dir !== null) {
285
+ const slotDirError = await removeValidatedSlotDir(storagePath, dir);
286
+ if (slotDirError)
287
+ return slotDirError;
288
+ }
289
+ const dropRegistry = dir === null || isSameNormalizedPath(dir, slotDirForBranch(repoPath, storagePath, branch));
290
+ if (dropRegistry) {
291
+ try {
292
+ await removeBranchIndex(repoPath, branch);
293
+ }
294
+ catch (err) {
295
+ return keepRegistryFailure(toError(err));
296
+ }
297
+ }
298
+ const emptiedBranchesDir = await rmdirEmptyBranches(storagePath);
299
+ return { ok: true, emptiedBranchesDir, keptRegistry: !dropRegistry };
300
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.24",
3
+ "version": "1.6.13-rc.25",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",