gitnexus 1.6.9-rc.45 → 1.6.9-rc.47

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.
package/README.md CHANGED
@@ -144,7 +144,7 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
144
144
  | `group_list` | List configured repository groups |
145
145
  | `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
146
146
 
147
- > With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for multi-branch indexes. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
147
+ > With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
148
148
 
149
149
  ## MCP Resources
150
150
 
@@ -1005,10 +1005,11 @@ const analyzeCommandImpl = async (inputPath, cliOptions) => {
1005
1005
  // preserving the rest of the block (incl. --skills community rows). No-op
1006
1006
  // when the value already matches, so a routine up-to-date run is silent
1007
1007
  // (#1996 tri-review P2).
1008
- // Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the
1009
- // PRIMARY/flat index (#2106 R2). A non-primary branch's up-to-date
1010
- // analyze must not churn the committed AGENTS.md — this mirrors the
1011
- // in-pipeline `if (!placement.branch)` gate around generateAIContextFiles.
1008
+ // Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the flat
1009
+ // WORKSPACE index (#2106 R2, #2354). A pinned --branch sub-index's
1010
+ // up-to-date analyze must not churn the committed AGENTS.md — this
1011
+ // mirrors the in-pipeline `if (!placement.branch)` gate around
1012
+ // generateAIContextFiles.
1012
1013
  let baseRefRefreshed = [];
1013
1014
  if (result.isPrimaryBranch !== false) {
1014
1015
  try {
@@ -28,7 +28,7 @@ export declare const en: {
28
28
  readonly 'status.currentCommit': "Current commit";
29
29
  readonly 'status.branch': "Branch";
30
30
  readonly 'status.detached': "(detached HEAD)";
31
- readonly 'status.branchNotIndexed': "⚠️ current branch not indexed (primary index is for '{{primary}}'; run gitnexus analyze)";
31
+ readonly 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)";
32
32
  readonly 'status.status': "Status";
33
33
  readonly 'status.upToDate': "✅ up-to-date";
34
34
  readonly 'status.stale': "⚠️ stale (re-run gitnexus analyze)";
@@ -172,7 +172,7 @@ export declare const en: {
172
172
  readonly 'help.option.force.confirmation': "Skip confirmation prompt";
173
173
  readonly 'help.option.uninstall.force': "Apply the changes (default is a dry-run preview)";
174
174
  readonly 'help.option.clean.all': "Clean all indexed repos";
175
- readonly 'help.option.clean.branch': "Delete only the named branch index (not the primary)";
175
+ readonly 'help.option.clean.branch': "Delete only the named branch index (not the workspace index)";
176
176
  readonly 'help.option.clean.lbugSidecars': "Clean quarantined LadybugDB missing-shadow WAL sidecars";
177
177
  readonly 'help.option.wiki.force': "Force full regeneration even if up to date";
178
178
  readonly 'help.option.wiki.provider': "LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)";
@@ -28,7 +28,7 @@ export const en = {
28
28
  'status.currentCommit': 'Current commit',
29
29
  'status.branch': 'Branch',
30
30
  'status.detached': '(detached HEAD)',
31
- 'status.branchNotIndexed': "⚠️ current branch not indexed (primary index is for '{{primary}}'; run gitnexus analyze)",
31
+ 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)",
32
32
  'status.status': 'Status',
33
33
  'status.upToDate': '✅ up-to-date',
34
34
  'status.stale': '⚠️ stale (re-run gitnexus analyze)',
@@ -172,7 +172,7 @@ export const en = {
172
172
  'help.option.force.confirmation': 'Skip confirmation prompt',
173
173
  'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
174
174
  'help.option.clean.all': 'Clean all indexed repos',
175
- 'help.option.clean.branch': 'Delete only the named branch index (not the primary)',
175
+ 'help.option.clean.branch': 'Delete only the named branch index (not the workspace index)',
176
176
  'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
177
177
  'help.option.wiki.force': 'Force full regeneration even if up to date',
178
178
  'help.option.wiki.provider': 'LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)',
@@ -29,7 +29,7 @@ export declare const cliResources: {
29
29
  readonly 'status.currentCommit': "Current commit";
30
30
  readonly 'status.branch': "Branch";
31
31
  readonly 'status.detached': "(detached HEAD)";
32
- readonly 'status.branchNotIndexed': "⚠️ current branch not indexed (primary index is for '{{primary}}'; run gitnexus analyze)";
32
+ readonly 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)";
33
33
  readonly 'status.status': "Status";
34
34
  readonly 'status.upToDate': "✅ up-to-date";
35
35
  readonly 'status.stale': "⚠️ stale (re-run gitnexus analyze)";
@@ -173,7 +173,7 @@ export declare const cliResources: {
173
173
  readonly 'help.option.force.confirmation': "Skip confirmation prompt";
174
174
  readonly 'help.option.uninstall.force': "Apply the changes (default is a dry-run preview)";
175
175
  readonly 'help.option.clean.all': "Clean all indexed repos";
176
- readonly 'help.option.clean.branch': "Delete only the named branch index (not the primary)";
176
+ readonly 'help.option.clean.branch': "Delete only the named branch index (not the workspace index)";
177
177
  readonly 'help.option.clean.lbugSidecars': "Clean quarantined LadybugDB missing-shadow WAL sidecars";
178
178
  readonly 'help.option.wiki.force': "Force full regeneration even if up to date";
179
179
  readonly 'help.option.wiki.provider': "LLM provider: openai, openrouter, azure, custom, cursor, claude, codex, or opencode (default: openai)";
@@ -271,7 +271,7 @@ export declare const cliResources: {
271
271
  'status.currentCommit': string;
272
272
  'status.branch': string;
273
273
  'status.detached': string;
274
- 'status.branchNotIndexed': string;
274
+ 'status.workspaceIndexLabel': string;
275
275
  'status.status': string;
276
276
  'status.upToDate': string;
277
277
  'status.stale': string;
@@ -28,7 +28,7 @@ export declare const zhCN: {
28
28
  'status.currentCommit': string;
29
29
  'status.branch': string;
30
30
  'status.detached': string;
31
- 'status.branchNotIndexed': string;
31
+ 'status.workspaceIndexLabel': string;
32
32
  'status.status': string;
33
33
  'status.upToDate': string;
34
34
  'status.stale': string;
@@ -28,7 +28,7 @@ export const zhCN = {
28
28
  'status.currentCommit': '当前提交',
29
29
  'status.branch': '分支',
30
30
  'status.detached': '(分离 HEAD)',
31
- 'status.branchNotIndexed': "⚠️ 当前分支未索引(主索引对应 '{{primary}}';请运行 gitnexus analyze",
31
+ 'status.workspaceIndexLabel': "工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)",
32
32
  'status.status': '状态',
33
33
  'status.upToDate': '✅ 已是最新',
34
34
  'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
@@ -172,7 +172,7 @@ export const zhCN = {
172
172
  'help.option.force.confirmation': '跳过确认提示',
173
173
  'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
174
174
  'help.option.clean.all': '清理所有已索引仓库',
175
- 'help.option.clean.branch': '仅删除指定分支的索引(不影响主索引)',
175
+ 'help.option.clean.branch': '仅删除指定分支的索引(不影响工作区索引)',
176
176
  'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
177
177
  'help.option.wiki.force': '即使已是最新也强制完整重新生成',
178
178
  'help.option.wiki.provider': 'LLM 提供商:openai、openrouter、azure、custom、cursor、claude、codex 或 opencode(默认:openai)',
package/dist/cli/index.js CHANGED
@@ -49,9 +49,9 @@ program
49
49
  'for supported languages. Opt-in; off by default. (#2081 M1)')
50
50
  .option('--default-branch <branch>', 'Default branch used in the generated regression-compare example (base_ref). ' +
51
51
  'Falls back to .gitnexusrc, then auto-detected origin/HEAD, then "main".')
52
- .option('--branch <name>', 'Index the working tree under a specific branch slot (multi-branch indexing). ' +
53
- 'Defaults to the checked-out branch; the primary/first-indexed branch keeps the ' +
54
- 'flat index and others get their own. Distinct from --default-branch (cosmetic base_ref).')
52
+ .option('--branch <name>', 'Pin the working tree into a dedicated per-branch index slot (multi-branch indexing). ' +
53
+ 'Without this flag, analyze always updates the workspace index, which follows the ' +
54
+ 'checked-out working tree. Distinct from --default-branch (cosmetic base_ref).')
55
55
  .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
56
56
  .option('--skip-skills', 'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
57
57
  'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
@@ -158,7 +158,7 @@ program
158
158
  .description('Delete GitNexus index for current repo')
159
159
  .option('-f, --force', 'Skip confirmation prompt')
160
160
  .option('--all', 'Clean all indexed repos')
161
- .option('--branch <name>', 'Delete only the named branch index (not the primary)')
161
+ .option('--branch <name>', 'Delete only the named branch index (not the workspace index)')
162
162
  .option('--lbug-sidecars', 'Clean quarantined LadybugDB missing-shadow WAL sidecars')
163
163
  .action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
164
164
  program
@@ -30,25 +30,25 @@ export const statusCommand = async () => {
30
30
  }
31
31
  const currentCommit = getCurrentCommit(repo.repoPath);
32
32
  const currentBranch = getCurrentBranch(repo.repoPath);
33
- // Pick the index matching the checked-out branch (#2106). The flat index
34
- // belongs to the primary branch (repo.meta.branch); when the current branch
35
- // differs and has its own index, report that one. Legacy/no-branch metas and
36
- // detached HEAD fall through to the flat index (unchanged behavior).
33
+ // Pick the index matching the checked-out branch (#2106/#2354). A pinned
34
+ // `--branch` sub-index for the current branch wins; otherwise report the
35
+ // flat workspace index, which follows the checked-out working tree — the
36
+ // commit comparison below then says whether it needs a re-analyze. Legacy/
37
+ // no-branch metas and detached HEAD also fall through to the flat index.
37
38
  let activeMeta = repo.meta;
38
- let currentBranchIndexed = true;
39
+ let workspaceLagsBranch = false;
39
40
  if (currentBranch && repo.meta.branch && currentBranch !== repo.meta.branch) {
40
41
  const { metaPath } = getStoragePaths(repo.repoPath, currentBranch);
41
42
  const branchMeta = await loadMeta(path.dirname(metaPath));
42
43
  if (branchMeta)
43
44
  activeMeta = branchMeta;
44
45
  else
45
- currentBranchIndexed = false;
46
+ workspaceLagsBranch = true;
46
47
  }
47
48
  console.log(`${t('status.repository')}: ${repo.repoPath}`);
48
49
  console.log(`${t('status.branch')}: ${currentBranch ?? t('status.detached')}`);
49
- if (!currentBranchIndexed) {
50
- console.log(`${t('status.status')}: ${t('status.branchNotIndexed', { primary: repo.meta.branch ?? '' })}`);
51
- return;
50
+ if (workspaceLagsBranch) {
51
+ console.log(t('status.workspaceIndexLabel', { primary: repo.meta.branch ?? '' }));
52
52
  }
53
53
  const isUpToDate = currentCommit === activeMeta.lastCommit;
54
54
  console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
@@ -103,13 +103,13 @@ export interface AnalyzeOptions {
103
103
  */
104
104
  defaultBranch?: string;
105
105
  /**
106
- * Index-branch selector (#2106). Distinct from `defaultBranch` (which only
107
- * affects generated AGENTS.md/CLAUDE.md base_ref text). When set, this run is
108
- * labelled as that branch and routed to a per-branch index slot unless it is
109
- * the primary branch. When `undefined`, the branch is auto-detected from the
110
- * checked-out HEAD (the flat/primary slot for the first-indexed branch, a
111
- * `branches/<slug>/` sub-directory otherwise). Detached HEAD / non-git always
112
- * maps to the flat slot.
106
+ * Index-branch selector (#2106, #2354). Distinct from `defaultBranch` (which
107
+ * only affects generated AGENTS.md/CLAUDE.md base_ref text). When set, this
108
+ * run is pinned to a per-branch index slot (`branches/<slug>/`) unless the
109
+ * label matches the flat slot's recorded branch. When `undefined`, the run
110
+ * always targets the flat workspace slot, which follows the checked-out
111
+ * working tree; the auto-detected branch is only recorded as the slot's
112
+ * informational label. Detached HEAD / non-git also map to the flat slot.
113
113
  */
114
114
  branch?: string;
115
115
  /**
@@ -176,10 +176,12 @@ export interface AnalyzeResult {
176
176
  */
177
177
  ftsSkipped?: boolean;
178
178
  /**
179
- * True when the index this run produced/validated is the primary/flat slot
180
- * (#2106 R2). `false` for a non-primary branch index. Lets the CLI skip
181
- * repo-root AGENTS.md/CLAUDE.md refreshes (e.g. the base_ref fast-path) for a
179
+ * True when the index this run produced/validated is the flat workspace
180
+ * slot (#2106 R2, inverted by #2354 to follow the checked-out branch).
181
+ * `false` for a pinned `--branch` sub-index. Lets the CLI skip repo-root
182
+ * AGENTS.md/CLAUDE.md refreshes (e.g. the base_ref fast-path) for a pinned
182
183
  * branch analyze, mirroring the in-pipeline `if (!placement.branch)` gate.
184
+ * (The historical "primary" name is kept — it is public API surface.)
183
185
  */
184
186
  isPrimaryBranch?: boolean;
185
187
  }
@@ -197,14 +199,6 @@ export declare const PHASE_LABELS: Record<string, string>;
197
199
  * the {@link AnalyzeCallbacks} interface — it never writes to stdout/stderr
198
200
  * directly and never calls `process.exit()`.
199
201
  */
200
- /**
201
- * Build the primary-inversion warning (#2106 R8), or `undefined` when there is
202
- * nothing to warn about. Pure + exported for testing. Both inputs are trimmed
203
- * (a diagnostic — a missed warning is low-harm; a false warning is the thing to
204
- * avoid). `defaultBranch` is the repo's `origin/HEAD` branch (null when unset,
205
- * e.g. fresh clones / CI), `flatOwner` is the branch that owns the flat slot.
206
- */
207
- export declare const primaryInversionWarning: (defaultBranch: string | null | undefined, flatOwner: string | null | undefined) => string | undefined;
208
202
  /**
209
203
  * Collect the recorded parse-cache chunk keys across the flat + every branch
210
204
  * metadata directory under a flat `.gitnexus` storage, EXCLUDING `excludeDir`
@@ -18,7 +18,7 @@ import { createSearchFTSIndexes, initialiseSearchFTSStemmer, verifySearchFTSInde
18
18
  import { cjkSegmentationModeMismatch, getSearchFTSCjkSegmentation, initialiseSearchFTSCjkSegmentation, } from './search/cjk-segmentation.js';
19
19
  import { resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
20
20
  import { startWalCheckpointDriver, } from './lbug/wal-checkpoint-driver.js';
21
- import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, isRepoRegistered, cleanupOldKuzuFiles, reconcileMetadataFiles, isMissingFilesystemError, INDEX_METADATA_FILE, INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js';
21
+ import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, adoptFlatBranchLabel, isReadOnlyFilesystemError, isRepoRegistered, cleanupOldKuzuFiles, reconcileMetadataFiles, isMissingFilesystemError, INDEX_METADATA_FILE, INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js';
22
22
  import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
23
23
  import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_CDG_EDGES_PER_FUNCTION, } from './ingestion/cfg/emit.js';
24
24
  import { DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from './ingestion/taint/propagate.js';
@@ -31,7 +31,7 @@ import { extractChangedSubgraph, computeEffectiveWriteSet, } from './incremental
31
31
  import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
32
32
  import { loadParseCache, saveParseCache, pruneCache, PARSE_CACHE_VERSION, } from '../storage/parse-cache.js';
33
33
  import { getDurableParsedFileDir, pruneAndSaveDurableParsedFileStore, } from '../storage/parsedfile-store.js';
34
- import { getCurrentCommit, getCurrentBranch, getDefaultBranch, getRemoteUrl, hasGitDir, getInferredRepoName, resolveRepoIdentityRoot, } from '../storage/git.js';
34
+ import { getCurrentCommit, getCurrentBranch, getRemoteUrl, hasGitDir, getInferredRepoName, resolveRepoIdentityRoot, } from '../storage/git.js';
35
35
  import { generateAIContextFiles } from '../cli/ai-context.js';
36
36
  import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
37
37
  import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
@@ -79,22 +79,6 @@ export const PHASE_LABELS = {
79
79
  * the {@link AnalyzeCallbacks} interface — it never writes to stdout/stderr
80
80
  * directly and never calls `process.exit()`.
81
81
  */
82
- /**
83
- * Build the primary-inversion warning (#2106 R8), or `undefined` when there is
84
- * nothing to warn about. Pure + exported for testing. Both inputs are trimmed
85
- * (a diagnostic — a missed warning is low-harm; a false warning is the thing to
86
- * avoid). `defaultBranch` is the repo's `origin/HEAD` branch (null when unset,
87
- * e.g. fresh clones / CI), `flatOwner` is the branch that owns the flat slot.
88
- */
89
- export const primaryInversionWarning = (defaultBranch, flatOwner) => {
90
- const norm = (s) => s?.trim() || undefined;
91
- const d = norm(defaultBranch);
92
- const o = norm(flatOwner);
93
- if (!d || !o || d === o)
94
- return undefined;
95
- return (`Warning: the default branch "${d}" is not the primary index — "${o}" owns the flat slot. ` +
96
- `Run \`gitnexus clean --branch ${o}\` then re-index on "${d}", or query it explicitly with \`--branch ${d}\`.`);
97
- };
98
82
  /**
99
83
  * Collect the recorded parse-cache chunk keys across the flat + every branch
100
84
  * metadata directory under a flat `.gitnexus` storage, EXCLUDING `excludeDir`
@@ -293,13 +277,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
293
277
  }
294
278
  const repoHasGit = hasGitDir(repoPath);
295
279
  const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
296
- // ── #2106: resolve which branch slot this run writes to ───────────────
280
+ // ── #2106/#2354: resolve which branch slot this run writes to ─────────
297
281
  // `branchLabel` is the branch identity recorded in meta.json (incl. the
298
- // primary). `placement.branch` is undefined for the flat/primary slot (the
299
- // lbug/meta paths stay byte-identical to single-branch behavior) and set for
300
- // a `branches/<slug>/` sub-directory. Explicit `--branch` is always honored;
301
- // otherwise auto-detect the checked-out branch (null for detached HEAD /
302
- // non-git flat slot).
282
+ // flat workspace slot). `placement.branch` is undefined for the flat slot
283
+ // (the lbug/meta paths stay byte-identical to single-branch behavior) and
284
+ // set for a `branches/<slug>/` sub-directory. Only an explicit `--branch`
285
+ // can route to a sub-directory; a plain analyze ALWAYS targets the flat
286
+ // slot, which follows the checked-out working tree (#2354) — the
287
+ // auto-detected branch (null for detached HEAD / non-git) is recorded as
288
+ // the slot's informational label only.
303
289
  // Normalize the auto-detected branch the same way an explicit `--branch` is
304
290
  // validated (#2106 R1): a git ref the branch-name rules forbid (backtick,
305
291
  // `~ ^ : ? *`, leading `-`, `..`) becomes `null` → the flat slot, matching
@@ -318,7 +304,7 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
318
304
  `Check out "${options.branch}" before indexing it, or omit --branch to index the current branch.`);
319
305
  }
320
306
  const branchLabel = options.branch ?? checkedOutBranch;
321
- const placement = await resolveBranchPlacement(repoPath, branchLabel);
307
+ const placement = options.branch ? await resolveBranchPlacement(repoPath, branchLabel) : {};
322
308
  const { lbugPath, metaPath } = getStoragePaths(repoPath, placement.branch);
323
309
  // metaPath now points to the metadata file (gitnexus.json) in a branch-specific directory.
324
310
  // metaDir is the directory containing the metadata file (and branch-specific DBs).
@@ -336,21 +322,6 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
336
322
  log(`Metadata reconciliation failed (non-critical${code ? `, ${code}` : ''}); continuing.`);
337
323
  }
338
324
  const existingMeta = await loadMeta(metaDir);
339
- // ── #2106 (R8): warn when the repo's default branch is not the primary ──
340
- // A non-default branch can own the flat slot (it was indexed first). That
341
- // index is still fully queryable via `--branch`, so this is an ergonomics
342
- // wart, not data loss — we only warn (no risky relocation of a live DB).
343
- if (repoHasGit) {
344
- // Who owns the flat slot after this run? For a flat/primary run it is this
345
- // run's resolved label (carrying an existing stamp forward); for a branch
346
- // run the flat owner is unchanged, so read the flat meta.
347
- const flatOwner = placement.branch
348
- ? (await loadMeta(storagePath))?.branch
349
- : (branchLabel ?? existingMeta?.branch);
350
- const warning = primaryInversionWarning(getDefaultBranch(repoPath), flatOwner);
351
- if (warning)
352
- log(warning);
353
- }
354
325
  // ── FTS-only repair path ────────────────────────────────────────────
355
326
  if (options.repairFts) {
356
327
  if (!existingMeta) {
@@ -550,6 +521,38 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
550
521
  // opt-in branch so the common fast path keeps its single-stat cost.
551
522
  const healUnregistered = options.allowDuplicateName === true && !(await isRepoRegistered(repoPath));
552
523
  if (!dirty && !healUnregistered) {
524
+ // ── #2354: restamp the workspace label on a same-commit branch flip ──
525
+ // The flat slot follows the checked-out working tree; a branch switch
526
+ // at the SAME commit with a clean tree changes nothing the pipeline
527
+ // must rebuild, but the slot's informational `branch` label (and the
528
+ // registry copy that query-side branch scoping reads) would go stale.
529
+ // Detached HEAD / non-git (branchLabel === null) keeps the existing
530
+ // stamp, mirroring the end-of-run meta write.
531
+ if (!placement.branch && branchLabel && existingMeta.branch !== branchLabel) {
532
+ // Adopt first, stamp last (#2364 review F3): this block's retry
533
+ // guard is `existingMeta.branch !== branchLabel`, so stamping the
534
+ // meta before the registry/shadow cleanup would flip the guard and
535
+ // lock in any partial failure — with saveMeta last, a failed adopt
536
+ // leaves the guard true and the next same-commit run self-heals
537
+ // (adopt is idempotent). The whole sync is best-effort: the label
538
+ // is informational and the flat DB content is byte-valid for both
539
+ // labels here (same commit, clean tree), so an "Already up to
540
+ // date" run must not fail over it; read-only storage — the
541
+ // documented Docker :ro workflow (#1549) — degrades to a warning.
542
+ try {
543
+ await adoptFlatBranchLabel(repoPath, branchLabel);
544
+ await saveMeta(metaDir, { ...existingMeta, branch: branchLabel });
545
+ }
546
+ catch (err) {
547
+ // EACCES/EPERM also arise from ownership problems and transient
548
+ // Windows locks, so keep the real error visible alongside the
549
+ // #1549 read-only hint instead of replacing it.
550
+ const reason = isReadOnlyFilesystemError(err)
551
+ ? `${err.message} — storage may be read-only (#1549)`
552
+ : err.message;
553
+ log(`Warning: could not restamp the workspace branch label (${reason}); will retry on the next run.`);
554
+ }
555
+ }
553
556
  await ensureGitNexusIgnored(repoPath);
554
557
  return {
555
558
  // `resolveRepoIdentityRoot` collapses worktree roots to the
@@ -1201,6 +1204,24 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
1201
1204
  // top-level fields (#2106).
1202
1205
  branch: placement.branch,
1203
1206
  });
1207
+ // ── #2354: the flat workspace slot has adopted this run's branch ──────
1208
+ // Drop a now-shadowed `branches/<slug>/` sub-index for the same label
1209
+ // (unreachable once the flat slot serves it) and align the registry's
1210
+ // top-level branch label. Best-effort like the parse-cache save above
1211
+ // (#2364 review F5): the index is complete and registered, and a failure
1212
+ // here leaves only a stale registry label / undeleted shadowed dir —
1213
+ // never wrong routing, because the flat meta this run already stamped is
1214
+ // what applyBranchScope trusts. Retried by the next content-changing run
1215
+ // (same-commit fast-path runs skip it: their guard compares the
1216
+ // already-stamped meta label).
1217
+ if (!placement.branch && branchLabel) {
1218
+ try {
1219
+ await adoptFlatBranchLabel(repoPath, branchLabel);
1220
+ }
1221
+ catch (e) {
1222
+ log(`Warning: could not sync the workspace branch label (${e.message}); continuing.`);
1223
+ }
1224
+ }
1204
1225
  // Keep generated .gitnexus contents ignored without editing the user's root .gitignore.
1205
1226
  await ensureGitNexusIgnored(repoPath);
1206
1227
  // ── Generate AI context files (best-effort) ───────────────────────
@@ -67,7 +67,7 @@ interface RepoHandle {
67
67
  stats?: RegistryEntry['stats'];
68
68
  /** Primary/flat branch name, when known (#2106). */
69
69
  branch?: string;
70
- /** Non-primary branch indexes available for this repo (#2106). */
70
+ /** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
71
71
  branches?: BranchSummary[];
72
72
  }
73
73
  /**
@@ -122,7 +122,7 @@ export interface RepoListing {
122
122
  }>;
123
123
  /** Primary/flat branch name, when known (#2106). */
124
124
  branch?: string;
125
- /** Non-primary branch indexes available for this repo (#2106). */
125
+ /** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
126
126
  branches?: Array<Omit<BranchSummary, 'stats'>>;
127
127
  }
128
128
  /** Continuation metadata for the paginated `list_repos` MCP tool (#2119). */
@@ -236,14 +236,28 @@ export declare class LocalBackend {
236
236
  /**
237
237
  * Re-point a resolved repo handle at a specific branch index (#2106).
238
238
  *
239
- * - No `branch` (default) → the primary/flat handle, unchanged (backward
239
+ * - No `branch` (default) → the flat workspace handle, unchanged (backward
240
240
  * compatible: every existing caller passes no branch).
241
- * - `branch` equal to the known primary → the flat handle.
242
- * - `branch` matching an indexed non-primary branch a handle whose
241
+ * - `branch` equal to the flat slot's **on-disk** recorded branch → the
242
+ * flat handle. The disk meta is read before any cached state is trusted
243
+ * (#2364 review F1): the flat slot follows the checked-out working tree
244
+ * (#2354), so a plain analyze after a branch switch restamps the meta
245
+ * without any repo-resolution miss that would refresh a long-lived
246
+ * server's cached handle — the cached label can otherwise serve another
247
+ * branch's content under the old name (the pool staleness reinit
248
+ * hot-swaps content without updating `handle.branch`).
249
+ * - `branch` matching an indexed pinned branch → a handle whose
243
250
  * `lbugPath` points at `branches/<slug>/lbug`; the connection pool keys by
244
- * `lbugPath`, so this is the only change needed to scope every tool.
245
- * - `branch` that was never indexed a clear error (never a silently-empty
246
- * result against the wrong DB).
251
+ * `lbugPath`, so this is the only change needed to scope every tool. The
252
+ * sub-index lbug must actually exist on disk `adoptFlatBranchLabel`
253
+ * deletes the whole dir when the flat slot takes ownership, and a stale
254
+ * cached summary must not route to the deleted path.
255
+ * - Cached `handle.branch` is trusted only when there is no readable flat
256
+ * meta to contradict it (legacy shapes, #2106 R4).
257
+ * - Any miss → a clear error (never a silently-empty result against the
258
+ * wrong DB), after exactly one `refreshRepos()` so newly-pinned branches
259
+ * and restamped labels the cached handle predates resolve on the next
260
+ * call.
247
261
  */
248
262
  private applyBranchScope;
249
263
  /**
@@ -809,45 +809,110 @@ export class LocalBackend {
809
809
  /**
810
810
  * Re-point a resolved repo handle at a specific branch index (#2106).
811
811
  *
812
- * - No `branch` (default) → the primary/flat handle, unchanged (backward
812
+ * - No `branch` (default) → the flat workspace handle, unchanged (backward
813
813
  * compatible: every existing caller passes no branch).
814
- * - `branch` equal to the known primary → the flat handle.
815
- * - `branch` matching an indexed non-primary branch a handle whose
814
+ * - `branch` equal to the flat slot's **on-disk** recorded branch → the
815
+ * flat handle. The disk meta is read before any cached state is trusted
816
+ * (#2364 review F1): the flat slot follows the checked-out working tree
817
+ * (#2354), so a plain analyze after a branch switch restamps the meta
818
+ * without any repo-resolution miss that would refresh a long-lived
819
+ * server's cached handle — the cached label can otherwise serve another
820
+ * branch's content under the old name (the pool staleness reinit
821
+ * hot-swaps content without updating `handle.branch`).
822
+ * - `branch` matching an indexed pinned branch → a handle whose
816
823
  * `lbugPath` points at `branches/<slug>/lbug`; the connection pool keys by
817
- * `lbugPath`, so this is the only change needed to scope every tool.
818
- * - `branch` that was never indexed a clear error (never a silently-empty
819
- * result against the wrong DB).
824
+ * `lbugPath`, so this is the only change needed to scope every tool. The
825
+ * sub-index lbug must actually exist on disk `adoptFlatBranchLabel`
826
+ * deletes the whole dir when the flat slot takes ownership, and a stale
827
+ * cached summary must not route to the deleted path.
828
+ * - Cached `handle.branch` is trusted only when there is no readable flat
829
+ * meta to contradict it (legacy shapes, #2106 R4).
830
+ * - Any miss → a clear error (never a silently-empty result against the
831
+ * wrong DB), after exactly one `refreshRepos()` so newly-pinned branches
832
+ * and restamped labels the cached handle predates resolve on the next
833
+ * call.
820
834
  */
821
835
  async applyBranchScope(handle, branch) {
822
836
  if (!branch)
823
837
  return handle;
824
- if (handle.branch && handle.branch === branch)
825
- return handle;
826
- const summary = handle.branches?.find((b) => b.branch === branch);
827
- if (summary) {
828
- const { lbugPath } = getStoragePaths(handle.repoPath, branch);
838
+ // At most one cache refresh per resolution: enough for the NEXT call to
839
+ // see fresh handles, without paying two registry re-scans when several
840
+ // stale arms fire in one degraded resolution.
841
+ let refreshed = false;
842
+ const refreshOnce = async () => {
843
+ if (refreshed)
844
+ return;
845
+ refreshed = true;
846
+ await this.refreshRepos().catch(() => { });
847
+ };
848
+ // One small JSON read per scoped call; mid-run meta writes preserve the
849
+ // old label until the end-of-run atomic stamp (run-analyze dirty stamps
850
+ // spread the existing meta), so this read never runs ahead of the DB.
851
+ const flatMeta = await loadMeta(path.dirname(handle.lbugPath));
852
+ if (flatMeta?.branch && flatMeta.branch === branch) {
853
+ // The disk meta decides routing, so it also supplies the metadata —
854
+ // the cached handle's label/commit/stats can predate the restamp.
829
855
  return {
830
856
  ...handle,
831
- lbugPath,
832
- indexedAt: summary.indexedAt,
833
- lastCommit: summary.lastCommit,
834
- stats: summary.stats,
857
+ branch: flatMeta.branch,
858
+ indexedAt: flatMeta.indexedAt ?? handle.indexedAt,
859
+ lastCommit: flatMeta.lastCommit ?? handle.lastCommit,
860
+ stats: flatMeta.stats ?? handle.stats,
835
861
  };
836
862
  }
837
- // Legacy entry (pre-#2106): the registry has no recorded primary `branch`,
838
- // so a `--branch <primary>` request misses the checks above. Read the flat
839
- // meta.json (next to the flat handle's lbug) to learn the primary and serve
840
- // the flat handle only when it actually matches never serve flat for an
841
- // arbitrary unindexed branch (#2106 R4).
842
- if (!handle.branch) {
843
- const flatMeta = await loadMeta(path.dirname(handle.lbugPath));
844
- if (flatMeta?.branch && flatMeta.branch === branch)
863
+ // A registry entry claiming `branch` both as the flat label AND as a
864
+ // pinned summary is an adopt-degraded state (rm kept the summary while
865
+ // the label restamped) never serve the possibly stale-vintage pin for
866
+ // a label the flat slot claims; fall through to the honest error.
867
+ const summary = handle.branch !== branch ? handle.branches?.find((b) => b.branch === branch) : undefined;
868
+ if (summary) {
869
+ const { lbugPath } = getStoragePaths(handle.repoPath, branch);
870
+ // The lbug is the artifact the pool opens, so its presence is the
871
+ // serviceability truth — a half-deleted dir can outlive its meta.json
872
+ // while the lbug is gone, and vice versa (#2364 review F1 arm ii).
873
+ // Only provably-absent errno counts as missing: a transient EACCES/EIO
874
+ // on a healthy pinned sub-index must serve the handle (the pool open
875
+ // surfaces the real error) rather than a false "not indexed".
876
+ const probeCode = await fs.access(lbugPath).then(() => null, (e) => e?.code ?? 'UNKNOWN');
877
+ const subIndexMissing = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';
878
+ if (!subIndexMissing) {
879
+ return {
880
+ ...handle,
881
+ lbugPath,
882
+ indexedAt: summary.indexedAt,
883
+ lastCommit: summary.lastCommit,
884
+ stats: summary.stats,
885
+ };
886
+ }
887
+ // Stale summary (sub-index adopted/deleted): refresh so later calls see
888
+ // fresh handles, then fall through — the flat meta above is the truth.
889
+ await refreshOnce();
890
+ }
891
+ if (handle.branch && handle.branch === branch) {
892
+ // No readable flat meta (missing/corrupt — loadMeta → null): keep the
893
+ // pre-#2354 trust in the cached label (#2106 R4 legacy shapes). A
894
+ // readable meta that names another branch means the label is stale.
895
+ if (!flatMeta?.branch)
845
896
  return handle;
846
897
  }
847
- const indexed = [handle.branch, ...(handle.branches?.map((b) => b.branch) ?? [])].filter(Boolean);
848
- const available = indexed.length > 0 ? indexed.join(', ') : '(primary only)';
898
+ // Every miss refreshes once before erroring: newly-pinned branches and
899
+ // restamped labels the cached handle predates become resolvable on the
900
+ // caller's next attempt (the cache otherwise only refreshes on repo-
901
+ // resolution misses and list_repos).
902
+ await refreshOnce();
903
+ // The flat slot's label comes from the authoritative meta when readable —
904
+ // never echo a cached label the meta just contradicted (a "not indexed:
905
+ // main / indexed: main" self-contradiction). Cached summaries may still
906
+ // lag; they are a hint, not a promise.
907
+ const flatLabel = flatMeta?.branch ?? handle.branch;
908
+ const indexed = [flatLabel, ...(handle.branches?.map((b) => b.branch) ?? [])].filter((b) => Boolean(b) && b !== branch);
909
+ const available = indexed.length > 0 ? indexed.join(', ') : '(workspace only)';
910
+ // Post-#2354 a bare `analyze --branch <X>` refuses to run unless X is
911
+ // checked out, so the guidance must lead with the checkout (#2364 F6).
849
912
  throw new Error(`Branch "${branch}" is not indexed for "${handle.name}". ` +
850
- `Indexed branches: ${available}. Run: gitnexus analyze --branch ${branch}`);
913
+ `Indexed branches: ${available}. The workspace index follows the ` +
914
+ `checked-out branch — check out "${branch}" and re-run: gitnexus analyze ` +
915
+ `(add --branch ${branch} while it is checked out to pin a separate sub-index).`);
851
916
  }
852
917
  /**
853
918
  * Try to resolve a repo from the in-memory cache. Returns null on miss.
package/dist/mcp/tools.js CHANGED
@@ -834,10 +834,11 @@ for (const tool of GITNEXUS_TOOLS) {
834
834
  if (tool.inputSchema.properties.branch)
835
835
  continue;
836
836
  // Optional — `required` is left unchanged so omitting `branch` keeps today's
837
- // default/primary-branch behavior. Ignored in group mode (repo starts "@").
837
+ // workspace-index behavior. Ignored in group mode (repo starts "@").
838
838
  tool.inputSchema.properties.branch = {
839
839
  type: 'string',
840
- description: 'Optional: scope to a specific branch index (multi-branch repos, #2106). ' +
841
- 'Omit for the default/primary branch. Ignored in group mode.',
840
+ description: 'Optional: scope to a pinned branch index (multi-branch repos, #2106). ' +
841
+ 'Omit for the workspace index, which follows the checked-out working tree. ' +
842
+ 'Ignored in group mode.',
842
843
  };
843
844
  }
@@ -34,18 +34,20 @@ export declare const BRANCHES_DIR = "branches";
34
34
  */
35
35
  export declare const branchSlug: (rawRef: string) => string;
36
36
  /**
37
- * Decide where a freshly-analyzed branch's index lives: the flat (primary) slot
38
- * or a per-branch sub-directory (#2106 KTD2).
37
+ * Decide where an EXPLICIT `--branch` run's index lives: the flat workspace
38
+ * slot or a per-branch sub-directory (#2106 KTD2, #2354).
39
39
  *
40
- * Returns `{}` for the flat/primary placement (byte-identical layout) or
41
- * `{ branch }` for a `branches/<slug>/` sub-directory. The flat slot is owned by
42
- * the FIRST branch indexed, recorded as `branch` in the flat `meta.json`; a
43
- * different checked-out branch then auto-routes to its own sub-directory so it
44
- * never overwrites the primary index.
40
+ * Only explicit `--branch` runs consult this a plain analyze always targets
41
+ * the flat slot, which follows the checked-out working tree (#2354; gated at
42
+ * the `runFullAnalysis` call site). Returns `{}` for the flat placement
43
+ * (byte-identical layout) or `{ branch }` for a `branches/<slug>/`
44
+ * sub-directory: when the requested label matches the flat slot's recorded
45
+ * `branch` label the run updates the flat slot in place (identical content —
46
+ * `--branch` requires the label to be checked out); any other label gets its
47
+ * own pinned sub-directory that plain analyzes won't touch.
45
48
  *
46
- * `label` is the resolved index-branch (explicit `--branch`, else the
47
- * checked-out branch, else `null`). A `null` label — detached HEAD, non-git
48
- * folder, or CI checkout — always maps to the flat slot.
49
+ * A `null` label detached HEAD, non-git folder, or CI checkout — always
50
+ * maps to the flat slot.
49
51
  */
50
52
  export declare const resolveBranchPlacement: (repoPath: string, label: string | null) => Promise<{
51
53
  branch?: string;
@@ -28,18 +28,20 @@ export const branchSlug = (rawRef) => {
28
28
  return `${safe}-${hash}`;
29
29
  };
30
30
  /**
31
- * Decide where a freshly-analyzed branch's index lives: the flat (primary) slot
32
- * or a per-branch sub-directory (#2106 KTD2).
31
+ * Decide where an EXPLICIT `--branch` run's index lives: the flat workspace
32
+ * slot or a per-branch sub-directory (#2106 KTD2, #2354).
33
33
  *
34
- * Returns `{}` for the flat/primary placement (byte-identical layout) or
35
- * `{ branch }` for a `branches/<slug>/` sub-directory. The flat slot is owned by
36
- * the FIRST branch indexed, recorded as `branch` in the flat `meta.json`; a
37
- * different checked-out branch then auto-routes to its own sub-directory so it
38
- * never overwrites the primary index.
34
+ * Only explicit `--branch` runs consult this a plain analyze always targets
35
+ * the flat slot, which follows the checked-out working tree (#2354; gated at
36
+ * the `runFullAnalysis` call site). Returns `{}` for the flat placement
37
+ * (byte-identical layout) or `{ branch }` for a `branches/<slug>/`
38
+ * sub-directory: when the requested label matches the flat slot's recorded
39
+ * `branch` label the run updates the flat slot in place (identical content —
40
+ * `--branch` requires the label to be checked out); any other label gets its
41
+ * own pinned sub-directory that plain analyzes won't touch.
39
42
  *
40
- * `label` is the resolved index-branch (explicit `--branch`, else the
41
- * checked-out branch, else `null`). A `null` label — detached HEAD, non-git
42
- * folder, or CI checkout — always maps to the flat slot.
43
+ * A `null` label detached HEAD, non-git folder, or CI checkout — always
44
+ * maps to the flat slot.
43
45
  */
44
46
  export const resolveBranchPlacement = async (repoPath, label) => {
45
47
  // Detached HEAD / non-git / no label → flat (CI-safe, byte-identical).
@@ -381,6 +381,7 @@ export declare const reconcileMetadataFiles: (repoPath: string) => Promise<boole
381
381
  * Find .gitnexus by walking up from a starting path
382
382
  */
383
383
  export declare const findRepo: (startPath: string) => Promise<IndexedRepo | null>;
384
+ export declare function isReadOnlyFilesystemError(err: unknown): boolean;
384
385
  /**
385
386
  * True for errors that prove a path is absent (ENOENT/ENOTDIR) — as opposed
386
387
  * to transient/permission failures (EIO/EACCES/EBUSY…) where the file may
@@ -500,6 +501,24 @@ export declare const unregisterRepo: (repoPath: string) => Promise<void>;
500
501
  * registry shape legacy-clean.
501
502
  */
502
503
  export declare const removeBranchIndex: (repoPath: string, branch: string) => Promise<boolean>;
504
+ /**
505
+ * Record that the flat workspace slot now serves `branch` (#2354).
506
+ *
507
+ * The flat index follows the checked-out working tree, so when a plain
508
+ * analyze lands on a branch that also has a pinned `branches/<slug>/`
509
+ * sub-index, that sub-index becomes permanently shadowed — explicit
510
+ * `--branch` runs re-resolve to the flat slot and query-side branch scoping
511
+ * serves the flat handle first. Delete the shadowed directory and drop its
512
+ * registry summary in the same pass (leaving either half behind would strand
513
+ * un-cleanable disk bloat), and refresh the entry's top-level `branch` label
514
+ * so `list`/`list_repos`/branch-scoped queries stay coherent.
515
+ *
516
+ * Deliberately narrow for the analyze fast path: a missing registry entry is
517
+ * a no-op — including the sub-index deletion, which only runs for registered
518
+ * repos (never self-heals an unregistered repo, per #2264/#1169; the registry
519
+ * check precedes the rm per #2364 review F2) — and no subprocess is spawned.
520
+ */
521
+ export declare const adoptFlatBranchLabel: (repoPath: string, branch: string) => Promise<void>;
503
522
  /**
504
523
  * Thrown by {@link resolveRegistryEntry} when no registered repo matches
505
524
  * the caller's target string (by alias, basename, remote-inferred name,
@@ -423,7 +423,7 @@ export const findRepo = async (startPath) => {
423
423
  }
424
424
  return null;
425
425
  };
426
- function isReadOnlyFilesystemError(err) {
426
+ export function isReadOnlyFilesystemError(err) {
427
427
  const code = err?.code;
428
428
  return code === 'EROFS' || code === 'EACCES' || code === 'EPERM';
429
429
  }
@@ -831,6 +831,87 @@ export const removeBranchIndex = async (repoPath, branch) => {
831
831
  await writeRegistry(entries);
832
832
  return true;
833
833
  };
834
+ /**
835
+ * Record that the flat workspace slot now serves `branch` (#2354).
836
+ *
837
+ * The flat index follows the checked-out working tree, so when a plain
838
+ * analyze lands on a branch that also has a pinned `branches/<slug>/`
839
+ * sub-index, that sub-index becomes permanently shadowed — explicit
840
+ * `--branch` runs re-resolve to the flat slot and query-side branch scoping
841
+ * serves the flat handle first. Delete the shadowed directory and drop its
842
+ * registry summary in the same pass (leaving either half behind would strand
843
+ * un-cleanable disk bloat), and refresh the entry's top-level `branch` label
844
+ * so `list`/`list_repos`/branch-scoped queries stay coherent.
845
+ *
846
+ * Deliberately narrow for the analyze fast path: a missing registry entry is
847
+ * a no-op — including the sub-index deletion, which only runs for registered
848
+ * repos (never self-heals an unregistered repo, per #2264/#1169; the registry
849
+ * check precedes the rm per #2364 review F2) — and no subprocess is spawned.
850
+ */
851
+ export const adoptFlatBranchLabel = async (repoPath, branch) => {
852
+ const canonicalInput = canonicalizePath(repoPath);
853
+ const isRegistered = (list) => list.findIndex((e) => registryPathEquals(canonicalizePath(e.path), canonicalInput));
854
+ // Cheap membership gate only (#2364 review F2): never touch the disk for an
855
+ // unregistered repo. The mutate below re-reads its own fresh snapshot.
856
+ if (isRegistered(await readRegistry()) < 0)
857
+ return; // no-op, disk included (no self-heal)
858
+ const resolved = path.resolve(repoPath);
859
+ const { storagePath } = getStoragePaths(resolved);
860
+ // Remove a shadowed sub-index directory, mirroring `clean --branch`'s
861
+ // containment guard: the target MUST live under .gitnexus/branches/.
862
+ const branchDir = path.join(storagePath, BRANCHES_DIR, branchSlug(branch));
863
+ const branchesRoot = path.join(storagePath, BRANCHES_DIR) + path.sep;
864
+ let dirGone = false;
865
+ if (branchDir.startsWith(branchesRoot)) {
866
+ let rmError;
867
+ await fs.rm(branchDir, { recursive: true, force: true }).catch((err) => {
868
+ rmError = err;
869
+ });
870
+ // The registry summary may be dropped only for a verifiably-gone
871
+ // directory: `clean --branch` resolves its target solely via the
872
+ // recorded summary, so dropping it while the dir survives (e.g. Windows
873
+ // EBUSY on an lbug held open by a live MCP server) would strand
874
+ // un-cleanable disk bloat (#2364 review F4). A resolved force:true rm
875
+ // proves absence; on failure, probe the disk and treat only
876
+ // provably-absent errno as gone — EACCES/EIO are "not provably absent",
877
+ // the same polarity as listRegisteredRepos({ validate: true }).
878
+ if (!rmError) {
879
+ dirGone = true;
880
+ }
881
+ else {
882
+ const probeCode = await fs.access(branchDir).then(() => null, (e) => e?.code ?? 'UNKNOWN');
883
+ dirGone = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';
884
+ }
885
+ if (dirGone) {
886
+ // Non-recursive by design: only removes the parent when no other pinned
887
+ // sub-index remains, so an empty branches/ dir doesn't read as "pinned".
888
+ await fs.rmdir(path.join(storagePath, BRANCHES_DIR)).catch(() => { });
889
+ }
890
+ else {
891
+ logger.warn({ path: branchDir, code: rmError?.code }, 'Could not remove the shadowed branch sub-index; keeping its registry summary so `gitnexus clean --branch` can still target it.');
892
+ }
893
+ }
894
+ // Re-read AFTER the potentially slow recursive rm: the registry is a
895
+ // multi-writer whole-file overwrite, and writing a pre-rm snapshot would
896
+ // silently clobber concurrent registerRepo/removeBranchIndex writers —
897
+ // the #2106 R9 re-read-before-write discipline registerRepo follows.
898
+ const entries = await readRegistry();
899
+ const idx = isRegistered(entries);
900
+ if (idx < 0)
901
+ return; // unregistered concurrently → still a no-op
902
+ const entry = entries[idx];
903
+ const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
904
+ const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
905
+ if (entry.branch === branch && !droppedSummary)
906
+ return; // already coherent
907
+ entry.branch = branch;
908
+ if (remaining && remaining.length > 0)
909
+ entry.branches = remaining;
910
+ else
911
+ delete entry.branches;
912
+ entries[idx] = entry;
913
+ await writeRegistry(entries);
914
+ };
834
915
  /**
835
916
  * Thrown by {@link resolveRegistryEntry} when no registered repo matches
836
917
  * the caller's target string (by alias, basename, remote-inferred name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9-rc.45",
3
+ "version": "1.6.9-rc.47",
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",