gitnexus 1.6.13-rc.23 → 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.
Files changed (58) hide show
  1. package/dist/cli/clean.d.ts +1 -0
  2. package/dist/cli/clean.js +114 -13
  3. package/dist/cli/doctor.d.ts +7 -0
  4. package/dist/cli/doctor.js +50 -0
  5. package/dist/cli/help-i18n.js +1 -0
  6. package/dist/cli/i18n/en.d.ts +21 -1
  7. package/dist/cli/i18n/en.js +21 -1
  8. package/dist/cli/i18n/resources.d.ts +41 -1
  9. package/dist/cli/i18n/zh-CN.d.ts +20 -0
  10. package/dist/cli/i18n/zh-CN.js +21 -1
  11. package/dist/cli/index.js +1 -0
  12. package/dist/cli/stale-branch-format.d.ts +4 -0
  13. package/dist/cli/stale-branch-format.js +34 -0
  14. package/dist/storage/git.d.ts +6 -0
  15. package/dist/storage/git.js +31 -1
  16. package/dist/storage/repo-manager.d.ts +2 -0
  17. package/dist/storage/repo-manager.js +5 -0
  18. package/dist/storage/stale-branch-slots.d.ts +46 -0
  19. package/dist/storage/stale-branch-slots.js +300 -0
  20. package/package.json +1 -1
  21. package/web/assets/{abnfDiagram-PAR5IXJI-BbkO1pGM.js → abnfDiagram-PAR5IXJI-8u563MEU.js} +1 -1
  22. package/web/assets/{agent-CJi9-qSV.js → agent-DyMwsoMH.js} +2 -2
  23. package/web/assets/{architectureDiagram-2PNWDENW-DogGaV6A.js → architectureDiagram-2PNWDENW-BjWbN2PO.js} +1 -1
  24. package/web/assets/{chunk-32MCUW2P-Ctlz9gwz.js → chunk-32MCUW2P-DEek-0Ht.js} +1 -1
  25. package/web/assets/{chunk-CLS4B6BI-CUaYtG_9.js → chunk-CLS4B6BI-D8hARACd.js} +1 -1
  26. package/web/assets/{chunk-NF366YI6-Qj4nm-M7.js → chunk-NF366YI6-B-9n0uvs.js} +1 -1
  27. package/web/assets/{chunk-SLDQ463B-BV20g0Ip.js → chunk-SLDQ463B-BskBwVBC.js} +1 -1
  28. package/web/assets/{classDiagram-FGAMPOII-BJ3tM2Pw.js → classDiagram-FGAMPOII-DaSs7-5O.js} +1 -1
  29. package/web/assets/{classDiagram-v2-AI5RJEK4-BJ3tM2Pw.js → classDiagram-v2-AI5RJEK4-DaSs7-5O.js} +1 -1
  30. package/web/assets/{cynefinDiagram-2OOWUY4M-tlBa8eNB.js → cynefinDiagram-2OOWUY4M-DjbooVji.js} +1 -1
  31. package/web/assets/{dagre-MPVFI544-BYv3SuFs.js → dagre-MPVFI544-B-wqpxUN.js} +1 -1
  32. package/web/assets/{diagram-AE4NWXFW-CNU2EXJM.js → diagram-AE4NWXFW-Bkmf_O6L.js} +1 -1
  33. package/web/assets/{diagram-BUJID5UG-NdiVLl-k.js → diagram-BUJID5UG-CbUC2Fie.js} +1 -1
  34. package/web/assets/{diagram-C44CZ5NI-Crd33kec.js → diagram-C44CZ5NI-dwPTAnfj.js} +1 -1
  35. package/web/assets/{diagram-EYOLEXXS-DSuOnVRp.js → diagram-EYOLEXXS-OD8zf2mA.js} +1 -1
  36. package/web/assets/{ebnfDiagram-FTPZ3PV6-CZaFjKJN.js → ebnfDiagram-FTPZ3PV6-u4D1E694.js} +1 -1
  37. package/web/assets/{erDiagram-H3ERR26V-k9RqGdZo.js → erDiagram-H3ERR26V-BBtjOXBa.js} +1 -1
  38. package/web/assets/{flowDiagram-YHGXBVSY-J9Z9GYtM.js → flowDiagram-YHGXBVSY-C7uNeQLW.js} +1 -1
  39. package/web/assets/{index-C7HdBqix.js → index-BvcE-KTJ.js} +70 -70
  40. package/web/assets/{infoDiagram-LV547AFH-DIxUoHUu.js → infoDiagram-LV547AFH-BHH9OuWU.js} +1 -1
  41. package/web/assets/{ishikawaDiagram-PUJQGNDS-B17wOvKI.js → ishikawaDiagram-PUJQGNDS-uat_n043.js} +1 -1
  42. package/web/assets/{kanban-definition-JSQERAZB-CkMxG6ux.js → kanban-definition-JSQERAZB-Cc-n7-iv.js} +1 -1
  43. package/web/assets/{mindmap-definition-VBJCLQLM-D3NcUXdF.js → mindmap-definition-VBJCLQLM-CcnYi775.js} +1 -1
  44. package/web/assets/{node.browser-GepThmNa.js → node.browser-Dx1ppNym.js} +1 -1
  45. package/web/assets/{pegDiagram-7WILOQTJ-DK-8FVqJ.js → pegDiagram-7WILOQTJ-CjsU2E8I.js} +1 -1
  46. package/web/assets/{pieDiagram-EFA7CBSB-B5a1nRL0.js → pieDiagram-EFA7CBSB-DFarVbT5.js} +1 -1
  47. package/web/assets/{railroadDiagram-5NIQVNLQ-DX9znCx0.js → railroadDiagram-5NIQVNLQ-BOc4TKmb.js} +1 -1
  48. package/web/assets/{requirementDiagram-SXREGOKZ-GuKNX_G7.js → requirementDiagram-SXREGOKZ-IYeU8wzI.js} +1 -1
  49. package/web/assets/{sequenceDiagram-52ZFIFDL-BqvlsagX.js → sequenceDiagram-52ZFIFDL-Bvb237FL.js} +1 -1
  50. package/web/assets/{stateDiagram-3QAKPIVI-CGtuJb_I.js → stateDiagram-3QAKPIVI-Bj5eSjtk.js} +1 -1
  51. package/web/assets/{stateDiagram-v2-HN2P4RDZ-B9XtRUVy.js → stateDiagram-v2-HN2P4RDZ-CgNVPo1K.js} +1 -1
  52. package/web/assets/{swimlanes-QLVP42QD-D6DRxdhV.js → swimlanes-QLVP42QD-BBXlMTuI.js} +1 -1
  53. package/web/assets/{swimlanesDiagram-JKPDBFCN-BbdS67fA.js → swimlanesDiagram-JKPDBFCN-g8DoalWG.js} +1 -1
  54. package/web/assets/{timeline-definition-GS45XAXW-Nh9YvCo_.js → timeline-definition-GS45XAXW-Dkl4ie3U.js} +1 -1
  55. package/web/assets/{vennDiagram-QIQU2ZRJ-ClVab1a4.js → vennDiagram-QIQU2ZRJ-8swT0k92.js} +1 -1
  56. package/web/assets/{wardleyDiagram-U6YNM66N-BRhCx1wz.js → wardleyDiagram-U6YNM66N-CtH6gYM2.js} +1 -1
  57. package/web/assets/{xychartDiagram-XJAZEDNM-uPLq3TT_.js → xychartDiagram-XJAZEDNM-BkWLFLb6.js} +1 -1
  58. package/web/index.html +1 -1
@@ -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
  *