gitnexus 1.6.7 → 1.6.8-rc.10

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 (82) hide show
  1. package/README.md +20 -0
  2. package/dist/cli/analyze-config.js +39 -0
  3. package/dist/cli/analyze.d.ts +16 -0
  4. package/dist/cli/analyze.js +34 -6
  5. package/dist/cli/clean.d.ts +1 -0
  6. package/dist/cli/clean.js +43 -1
  7. package/dist/cli/eval-server.js +43 -0
  8. package/dist/cli/help-i18n.js +6 -0
  9. package/dist/cli/i18n/en.d.ts +11 -0
  10. package/dist/cli/i18n/en.js +11 -0
  11. package/dist/cli/i18n/resources.d.ts +22 -0
  12. package/dist/cli/i18n/zh-CN.d.ts +11 -0
  13. package/dist/cli/i18n/zh-CN.js +11 -0
  14. package/dist/cli/index.js +9 -0
  15. package/dist/cli/list.js +12 -0
  16. package/dist/cli/optional-grammars.d.ts +6 -8
  17. package/dist/cli/optional-grammars.js +8 -11
  18. package/dist/cli/status.js +26 -5
  19. package/dist/cli/tool.d.ts +5 -0
  20. package/dist/cli/tool.js +5 -0
  21. package/dist/core/embeddings/embedder.js +4 -0
  22. package/dist/core/embeddings/embedding-pipeline.js +27 -16
  23. package/dist/core/embeddings/onnxruntime-common-resolver.d.ts +6 -0
  24. package/dist/core/embeddings/onnxruntime-common-resolver.js +130 -0
  25. package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
  26. package/dist/core/group/extractors/http-patterns/java.js +3 -50
  27. package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
  28. package/dist/core/group/extractors/include-extractor.js +7 -7
  29. package/dist/core/ingestion/language-provider.d.ts +25 -2
  30. package/dist/core/ingestion/languages/c-cpp.js +11 -4
  31. package/dist/core/ingestion/languages/java.js +3 -0
  32. package/dist/core/ingestion/languages/kotlin/query.js +3 -2
  33. package/dist/core/ingestion/languages/kotlin.js +5 -1
  34. package/dist/core/ingestion/parsing-processor.js +21 -0
  35. package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
  36. package/dist/core/ingestion/pipeline.d.ts +9 -0
  37. package/dist/core/ingestion/route-extractors/spring-shared.d.ts +50 -0
  38. package/dist/core/ingestion/route-extractors/spring-shared.js +80 -0
  39. package/dist/core/ingestion/route-extractors/spring.d.ts +35 -0
  40. package/dist/core/ingestion/route-extractors/spring.js +136 -0
  41. package/dist/core/ingestion/workers/clone-safety.d.ts +109 -0
  42. package/dist/core/ingestion/workers/clone-safety.js +465 -0
  43. package/dist/core/ingestion/workers/parse-worker.d.ts +10 -0
  44. package/dist/core/ingestion/workers/parse-worker.js +27 -52
  45. package/dist/core/ingestion/workers/post-result.d.ts +22 -0
  46. package/dist/core/ingestion/workers/post-result.js +87 -0
  47. package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
  48. package/dist/core/ingestion/workers/result-merge.js +43 -0
  49. package/dist/core/ingestion/workers/worker-pool.d.ts +6 -0
  50. package/dist/core/ingestion/workers/worker-pool.js +28 -14
  51. package/dist/core/lbug/lbug-adapter.d.ts +19 -0
  52. package/dist/core/lbug/lbug-adapter.js +56 -1
  53. package/dist/core/run-analyze.d.ts +45 -0
  54. package/dist/core/run-analyze.js +175 -24
  55. package/dist/core/tree-sitter/parser-loader.js +5 -4
  56. package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
  57. package/dist/core/tree-sitter/vendored-grammars.js +57 -0
  58. package/dist/mcp/core/embedder.js +4 -0
  59. package/dist/mcp/local/local-backend.d.ts +55 -2
  60. package/dist/mcp/local/local-backend.js +363 -25
  61. package/dist/mcp/tools.js +32 -0
  62. package/dist/server/analyze-worker-ipc.d.ts +58 -0
  63. package/dist/server/analyze-worker-ipc.js +16 -0
  64. package/dist/server/analyze-worker.js +7 -1
  65. package/dist/storage/branch-index.d.ts +52 -0
  66. package/dist/storage/branch-index.js +65 -0
  67. package/dist/storage/git.d.ts +11 -0
  68. package/dist/storage/git.js +28 -0
  69. package/dist/storage/parse-cache.js +3 -0
  70. package/dist/storage/repo-manager.d.ts +57 -2
  71. package/dist/storage/repo-manager.js +132 -18
  72. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +24 -4
  73. package/hooks/claude/gitnexus-hook.cjs +18 -3
  74. package/package.json +2 -2
  75. package/scripts/assert-publish-grammar-coverage.cjs +31 -0
  76. package/scripts/build-tree-sitter-grammars.cjs +16 -10
  77. package/vendor/tree-sitter-c/package.json +1 -1
  78. package/vendor/tree-sitter-dart/package.json +1 -1
  79. package/vendor/tree-sitter-kotlin/package.json +1 -1
  80. package/vendor/tree-sitter-proto/package.json +1 -1
  81. package/vendor/tree-sitter-swift/package.json +1 -1
  82. package/scripts/materialize-vendor-grammars.cjs +0 -97
@@ -0,0 +1,58 @@
1
+ /**
2
+ * JSON-safe projection of `AnalyzeResult` for the analyze-worker → parent IPC
3
+ * boundary (#2112 boundary audit; #2135).
4
+ *
5
+ * The forked analyze worker (`analyze-worker.ts`) reports completion to the
6
+ * parent over `child_process` IPC, which uses Node's DEFAULT `'json'`
7
+ * serialization — `api.ts` forks the worker with no `serialization:` option, so
8
+ * the channel runs `JSON.stringify`/`JSON.parse`, NOT V8 structured clone.
9
+ *
10
+ * `AnalyzeResult.pipelineResult` is populated on every successful analysis
11
+ * (`run-analyze.ts`) and carries `pipelineResult.graph` — the live
12
+ * `KnowledgeGraph` closure object. Sending the raw result across this channel is
13
+ * wrong three ways:
14
+ * 1. Waste — the graph's `nodes`/`relationships` getters force-materialize the
15
+ * ENTIRE graph into two arrays, then JSON-stringify them, on every analyze.
16
+ * On a large repo (the #2112 scenario) that is a multi-hundred-MB
17
+ * stringify+parse whose result is immediately discarded.
18
+ * 2. Silent corruption — the graph's methods are own function properties;
19
+ * `JSON.stringify` drops them with no error, so a `pipelineResult.graph`
20
+ * that survived the wire is a data-only husk whose `forEachNode(...)` throws
21
+ * "is not a function" far from the cause.
22
+ * 3. Conditional crash — a BigInt or circular reference anywhere in the
23
+ * payload makes `process.send` throw `TypeError` synchronously; the throw is
24
+ * caught in the worker and re-sent as `{type:'error'}`, turning a
25
+ * SUCCESSFUL analysis (DB already written) into a reported FAILURE. This is
26
+ * the #2112 failure family on the server path, and — unlike the parse-worker
27
+ * result boundary — it has no clone-safety net.
28
+ *
29
+ * The parent (`api.ts`) reads only `result.repoName`; `pipelineResult`'s real
30
+ * consumers (CLI skill generation) call `runFullAnalysis` in-process and never
31
+ * cross this fork. So the worker sends an explicit allowlist of the scalar
32
+ * fields, JSON-safe by construction.
33
+ */
34
+ import type { AnalyzeResult } from '../core/run-analyze.js';
35
+ /**
36
+ * The JSON-safe subset of `AnalyzeResult` that crosses the analyze-worker IPC
37
+ * boundary. A `Pick` allowlist — NOT `Omit<…, 'pipelineResult'>`. With `Pick`
38
+ * the allowlist IS the type, so the projection is exhaustive by construction:
39
+ * `projectAnalyzeResultForIpc`'s return literal must name exactly these keys
40
+ * (omitting one is a compile error), and a new field added to `AnalyzeResult`
41
+ * is simply absent from the wire until it is *deliberately* added here. `Omit`
42
+ * couldn't give that guarantee — it kept every other field, including OPTIONAL
43
+ * ones (e.g. `isPrimaryBranch?`), so an optional non-serializable field could be
44
+ * advertised by the type yet silently dropped by the runtime allowlist.
45
+ *
46
+ * `isPrimaryBranch` is intentionally excluded: the parent (`api.ts`) reads only
47
+ * `repoName`, and nothing consumes `isPrimaryBranch` across this fork (its CLI
48
+ * consumer calls `runFullAnalysis` in-process). Add a field here only when a
49
+ * server-side IPC consumer actually needs it — and only if it is JSON-safe.
50
+ */
51
+ export type AnalyzeResultIpc = Pick<AnalyzeResult, 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped'>;
52
+ /**
53
+ * Project an `AnalyzeResult` down to the JSON-safe fields the parent consumes,
54
+ * dropping `pipelineResult` (the live `KnowledgeGraph`) and any other field not
55
+ * in the `AnalyzeResultIpc` allowlist. The return literal is exhaustive over
56
+ * `AnalyzeResultIpc` (a missing key is a compile error).
57
+ */
58
+ export declare function projectAnalyzeResultForIpc(result: AnalyzeResult): AnalyzeResultIpc;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Project an `AnalyzeResult` down to the JSON-safe fields the parent consumes,
3
+ * dropping `pipelineResult` (the live `KnowledgeGraph`) and any other field not
4
+ * in the `AnalyzeResultIpc` allowlist. The return literal is exhaustive over
5
+ * `AnalyzeResultIpc` (a missing key is a compile error).
6
+ */
7
+ export function projectAnalyzeResultForIpc(result) {
8
+ return {
9
+ repoName: result.repoName,
10
+ repoPath: result.repoPath,
11
+ stats: result.stats,
12
+ alreadyUpToDate: result.alreadyUpToDate,
13
+ ftsRepairedOnly: result.ftsRepairedOnly,
14
+ ftsSkipped: result.ftsSkipped,
15
+ };
16
+ }
@@ -11,6 +11,7 @@
11
11
  * Child -> Parent: { type: 'error', message: string }
12
12
  */
13
13
  import { runFullAnalysis } from '../core/run-analyze.js';
14
+ import { projectAnalyzeResultForIpc } from './analyze-worker-ipc.js';
14
15
  import { closeLbug } from '../core/lbug/lbug-adapter.js';
15
16
  function send(msg) {
16
17
  process.send?.(msg);
@@ -48,7 +49,12 @@ process.on('message', async (msg) => {
48
49
  send({ type: 'progress', phase: 'log', percent: -1, message });
49
50
  },
50
51
  });
51
- send({ type: 'complete', result });
52
+ // Send a JSON-safe projection, NOT the raw result: the IPC channel is
53
+ // default-JSON serialization and `result.pipelineResult` carries the live
54
+ // KnowledgeGraph (wasteful to materialize, silently corrupted by JSON, and
55
+ // a BigInt/circular value would throw and mis-report this success as a
56
+ // failure). See analyze-worker-ipc.ts.
57
+ send({ type: 'complete', result: projectAnalyzeResultForIpc(result) });
52
58
  }
53
59
  catch (err) {
54
60
  send({ type: 'error', message: err?.message || 'Analysis failed' });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Branch-index primitives (#2106).
3
+ *
4
+ * Extracted from `repo-manager.ts` to keep the multi-branch slug/placement
5
+ * logic in one focused module. `getStoragePaths`, `loadMeta`, and the registry
6
+ * I/O stay in `repo-manager.ts`; this module imports the two it needs at
7
+ * call-time only (no module-load cross-calls), so the repo-manager ⇄
8
+ * branch-index import cycle is ESM-safe. `repo-manager.ts` re-exports these so
9
+ * existing import sites keep working unchanged.
10
+ */
11
+ import { type RepoMeta } from './repo-manager.js';
12
+ /**
13
+ * Per-branch index summary nested under a registry entry (#2106). Records
14
+ * non-primary branches indexed for the same repo path so `list`, `status`, and
15
+ * `list_repos` can surface them without a second registry entry.
16
+ */
17
+ export interface BranchSummary {
18
+ /** Git branch name this sub-index represents. */
19
+ branch: string;
20
+ indexedAt: string;
21
+ lastCommit: string;
22
+ stats?: RepoMeta['stats'];
23
+ }
24
+ /** Branch-index sub-directory name, relative to the flat `.gitnexus` storage. */
25
+ export declare const BRANCHES_DIR = "branches";
26
+ /**
27
+ * Filesystem-safe slug for a git branch ref (#2106).
28
+ *
29
+ * `sanitizeRepoName` alone is lossy — it maps `/`→`_`, so `feature/x` and
30
+ * `feature_x` would collide into the same directory. We append a short sha256
31
+ * of the RAW ref (mirroring `assignRepoId`'s digest fallback) so two distinct
32
+ * refs can never share a branch directory, while keeping the human prefix
33
+ * readable.
34
+ */
35
+ export declare const branchSlug: (rawRef: string) => string;
36
+ /**
37
+ * Decide where a freshly-analyzed branch's index lives: the flat (primary) slot
38
+ * or a per-branch sub-directory (#2106 KTD2).
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.
45
+ *
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
+ */
50
+ export declare const resolveBranchPlacement: (repoPath: string, label: string | null) => Promise<{
51
+ branch?: string;
52
+ }>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Branch-index primitives (#2106).
3
+ *
4
+ * Extracted from `repo-manager.ts` to keep the multi-branch slug/placement
5
+ * logic in one focused module. `getStoragePaths`, `loadMeta`, and the registry
6
+ * I/O stay in `repo-manager.ts`; this module imports the two it needs at
7
+ * call-time only (no module-load cross-calls), so the repo-manager ⇄
8
+ * branch-index import cycle is ESM-safe. `repo-manager.ts` re-exports these so
9
+ * existing import sites keep working unchanged.
10
+ */
11
+ import { createHash } from 'crypto';
12
+ import { sanitizeRepoName } from './git.js';
13
+ import { getStoragePaths, loadMeta } from './repo-manager.js';
14
+ /** Branch-index sub-directory name, relative to the flat `.gitnexus` storage. */
15
+ export const BRANCHES_DIR = 'branches';
16
+ /**
17
+ * Filesystem-safe slug for a git branch ref (#2106).
18
+ *
19
+ * `sanitizeRepoName` alone is lossy — it maps `/`→`_`, so `feature/x` and
20
+ * `feature_x` would collide into the same directory. We append a short sha256
21
+ * of the RAW ref (mirroring `assignRepoId`'s digest fallback) so two distinct
22
+ * refs can never share a branch directory, while keeping the human prefix
23
+ * readable.
24
+ */
25
+ export const branchSlug = (rawRef) => {
26
+ const safe = sanitizeRepoName(rawRef);
27
+ const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8);
28
+ return `${safe}-${hash}`;
29
+ };
30
+ /**
31
+ * Decide where a freshly-analyzed branch's index lives: the flat (primary) slot
32
+ * or a per-branch sub-directory (#2106 KTD2).
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.
39
+ *
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
+ */
44
+ export const resolveBranchPlacement = async (repoPath, label) => {
45
+ // Detached HEAD / non-git / no label → flat (CI-safe, byte-identical).
46
+ if (!label)
47
+ return {};
48
+ const { storagePath } = getStoragePaths(repoPath);
49
+ const flatMeta = await loadMeta(storagePath);
50
+ // The flat slot's owner is authoritative ONLY when it is a non-empty string.
51
+ // A corrupt/hand-edited meta (empty string, or a non-string value that slips
52
+ // past JSON typing) must not be trusted to route the real primary into a
53
+ // sub-directory (#2106 review R5).
54
+ const owner = flatMeta && typeof flatMeta.branch === 'string' && flatMeta.branch.length > 0
55
+ ? flatMeta.branch
56
+ : undefined;
57
+ // Fresh repo (no flat index) or legacy/unstamped flat index (no recorded
58
+ // owner): the current label claims/adopts the flat slot. The legacy case
59
+ // preserves today's overwrite-in-place behavior until the slot is stamped.
60
+ if (!owner)
61
+ return {};
62
+ // Flat slot is owned. Same branch → flat; otherwise this branch gets its own
63
+ // sub-directory.
64
+ return owner === label ? {} : { branch: label };
65
+ };
@@ -118,6 +118,17 @@ export declare const getRemoteOriginUrl: (repoPath: string) => string | null;
118
118
  * git is unavailable, so callers can fall back to a configured/default branch.
119
119
  */
120
120
  export declare const getDefaultBranch: (repoPath: string) => string | null;
121
+ /**
122
+ * Name of the currently checked-out branch, or `null` when HEAD is detached
123
+ * (CI checkouts, `git checkout <sha>`), the directory is not a git worktree, or
124
+ * git is unavailable.
125
+ *
126
+ * `git rev-parse --abbrev-ref HEAD` prints the literal `HEAD` for a detached
127
+ * checkout. We map that (and empty output) to `null` so callers fall back to the
128
+ * flat/default index rather than ever creating a branch literally named
129
+ * "HEAD" (#2106).
130
+ */
131
+ export declare const getCurrentBranch: (repoPath: string) => string | null;
121
132
  /**
122
133
  * Sanitize a repository name to prevent argument injection and ensure
123
134
  * cross-platform filesystem compatibility.
@@ -295,6 +295,34 @@ export const getDefaultBranch = (repoPath) => {
295
295
  return null;
296
296
  }
297
297
  };
298
+ /**
299
+ * Name of the currently checked-out branch, or `null` when HEAD is detached
300
+ * (CI checkouts, `git checkout <sha>`), the directory is not a git worktree, or
301
+ * git is unavailable.
302
+ *
303
+ * `git rev-parse --abbrev-ref HEAD` prints the literal `HEAD` for a detached
304
+ * checkout. We map that (and empty output) to `null` so callers fall back to the
305
+ * flat/default index rather than ever creating a branch literally named
306
+ * "HEAD" (#2106).
307
+ */
308
+ export const getCurrentBranch = (repoPath) => {
309
+ try {
310
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
311
+ cwd: repoPath,
312
+ // Suppress stderr -- see getCurrentCommit comment and #1172.
313
+ stdio: ['ignore', 'pipe', 'ignore'],
314
+ windowsHide: true,
315
+ })
316
+ .toString()
317
+ .trim();
318
+ if (!branch || branch === 'HEAD')
319
+ return null;
320
+ return branch;
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ };
298
326
  /**
299
327
  * Sanitize a repository name to prevent argument injection and ensure
300
328
  * cross-platform filesystem compatibility.
@@ -149,6 +149,9 @@ export const slimParseWorkerResultsForCache = (chunkResults) => {
149
149
  assignments: [],
150
150
  constructorBindings: [],
151
151
  parsedFiles: [],
152
+ // #2112: a clone-safety skip list is per-run telemetry, not graph data —
153
+ // replay ignores it. Drop it so it doesn't bloat the cached shard.
154
+ skippedPaths: [],
152
155
  });
153
156
  }
154
157
  return slim;
@@ -5,6 +5,9 @@
5
5
  * Also maintains a global registry at ~/.gitnexus/registry.json
6
6
  * so the MCP server can discover indexed repos from any cwd.
7
7
  */
8
+ import { branchSlug, resolveBranchPlacement, type BranchSummary } from './branch-index.js';
9
+ export { branchSlug, resolveBranchPlacement };
10
+ export type { BranchSummary };
8
11
  /**
9
12
  * Normalise a repo path for registry comparison across platforms
10
13
  * (#664 review feedback from @evander-wang).
@@ -83,6 +86,23 @@ export interface RepoMeta {
83
86
  /** Number of files in the writable set, for diagnostic logs. */
84
87
  toWriteCount: number;
85
88
  };
89
+ /**
90
+ * Name of the git branch this index represents (#2106). Absent for the
91
+ * default/legacy single-branch case so the flat `meta.json` stays
92
+ * byte-identical to pre-multi-branch output. When present in the FLAT
93
+ * `meta.json`, it records which branch "owns" the flat slot (the first
94
+ * branch indexed); per-branch indexes under `branches/<slug>/` always carry
95
+ * their own `branch`.
96
+ */
97
+ branch?: string;
98
+ /**
99
+ * The parse-cache chunk keys this branch's index needs (#2106 R6). The
100
+ * parse-cache and durable parsedfile store live ONCE at the repo root and are
101
+ * shared across branches; recording each branch's live chunk keys lets the
102
+ * prune step union them so re-analyzing one branch doesn't evict another
103
+ * branch's still-live shards. Additive/optional; absent in legacy metas.
104
+ */
105
+ cacheKeys?: string[];
86
106
  }
87
107
  /**
88
108
  * Bumped whenever incremental-indexing invariants change incompatibly.
@@ -107,15 +127,33 @@ export interface RegistryEntry {
107
127
  /** See {@link RepoMeta.remoteUrl}. Mirrored from meta at register time. */
108
128
  remoteUrl?: string;
109
129
  stats?: RepoMeta['stats'];
130
+ /**
131
+ * Branch name owning the flat/primary index (#2106). Mirrors the flat
132
+ * `meta.branch`. Absent for legacy single-branch entries and non-git repos —
133
+ * additive and backward compatible.
134
+ */
135
+ branch?: string;
136
+ /**
137
+ * Non-primary branch indexes for this same path (#2106). Absent when only the
138
+ * primary branch is indexed, preserving the one-entry-per-path model and the
139
+ * legacy registry shape.
140
+ */
141
+ branches?: BranchSummary[];
110
142
  }
111
143
  /**
112
144
  * Get the .gitnexus storage path for a repository
113
145
  */
114
146
  export declare const getStoragePath: (repoPath: string) => string;
115
147
  /**
116
- * Get paths to key storage files
148
+ * Get paths to key storage files.
149
+ *
150
+ * `storagePath` is ALWAYS the flat `<repo>/.gitnexus` — content-addressed
151
+ * caches (`parse-cache/`, `parsedfile-store/`) live there and are shared
152
+ * across branches (#2106 KTD7). When `branch` is provided, only `lbugPath` and
153
+ * `metaPath` are scoped under `branches/<slug>/`; the flat call (no `branch`)
154
+ * returns byte-identical paths to the pre-multi-branch behavior.
117
155
  */
118
- export declare const getStoragePaths: (repoPath: string) => {
156
+ export declare const getStoragePaths: (repoPath: string, branch?: string) => {
119
157
  storagePath: string;
120
158
  lbugPath: string;
121
159
  metaPath: string;
@@ -213,6 +251,14 @@ export interface RegisterRepoOptions {
213
251
  * re-run the full pipeline.
214
252
  */
215
253
  allowDuplicateName?: boolean;
254
+ /**
255
+ * Non-primary branch this run indexed (#2106). When set, the branch's
256
+ * summary is upserted into the entry's `branches[]` and the primary
257
+ * top-level fields are left untouched. When `undefined`, this is a
258
+ * primary/flat run that refreshes the top-level fields (and preserves any
259
+ * existing branch summaries).
260
+ */
261
+ branch?: string;
216
262
  }
217
263
  /**
218
264
  * Thrown by {@link registerRepo} when a requested name is already in
@@ -262,6 +308,15 @@ export declare const registerRepo: (repoPath: string, meta: RepoMeta, opts?: Reg
262
308
  * Called after `gitnexus clean`.
263
309
  */
264
310
  export declare const unregisterRepo: (repoPath: string) => Promise<void>;
311
+ /**
312
+ * Remove a single non-primary branch's summary from a repo's registry entry
313
+ * (#2106 R7). Called by `gitnexus clean --branch`. Returns `true` when a
314
+ * matching `branches[]` summary was found and removed; `false` otherwise (so
315
+ * the CLI can report "no such indexed branch" without crashing). The top-level
316
+ * primary entry is left intact; an empty `branches[]` is dropped to keep the
317
+ * registry shape legacy-clean.
318
+ */
319
+ export declare const removeBranchIndex: (repoPath: string, branch: string) => Promise<boolean>;
265
320
  /**
266
321
  * Thrown by {@link resolveRegistryEntry} when no registered repo matches
267
322
  * the caller's target string (by alias, basename, remote-inferred name,
@@ -11,6 +11,10 @@ import path from 'path';
11
11
  import os from 'os';
12
12
  import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
13
13
  import { logger } from '../core/logger.js';
14
+ import { branchSlug, BRANCHES_DIR, resolveBranchPlacement, } from './branch-index.js';
15
+ // Re-export the #2106 branch primitives (extracted to branch-index.ts, R10) so
16
+ // existing `repo-manager` import sites and tests keep working unchanged.
17
+ export { branchSlug, resolveBranchPlacement };
14
18
  /**
15
19
  * Normalise a repo path for registry comparison across platforms
16
20
  * (#664 review feedback from @evander-wang).
@@ -64,14 +68,21 @@ export const getStoragePath = (repoPath) => {
64
68
  return path.join(path.resolve(repoPath), GITNEXUS_DIR);
65
69
  };
66
70
  /**
67
- * Get paths to key storage files
71
+ * Get paths to key storage files.
72
+ *
73
+ * `storagePath` is ALWAYS the flat `<repo>/.gitnexus` — content-addressed
74
+ * caches (`parse-cache/`, `parsedfile-store/`) live there and are shared
75
+ * across branches (#2106 KTD7). When `branch` is provided, only `lbugPath` and
76
+ * `metaPath` are scoped under `branches/<slug>/`; the flat call (no `branch`)
77
+ * returns byte-identical paths to the pre-multi-branch behavior.
68
78
  */
69
- export const getStoragePaths = (repoPath) => {
79
+ export const getStoragePaths = (repoPath, branch) => {
70
80
  const storagePath = getStoragePath(repoPath);
81
+ const baseDir = branch ? path.join(storagePath, BRANCHES_DIR, branchSlug(branch)) : storagePath;
71
82
  return {
72
83
  storagePath,
73
- lbugPath: path.join(storagePath, 'lbug'),
74
- metaPath: path.join(storagePath, 'meta.json'),
84
+ lbugPath: path.join(baseDir, 'lbug'),
85
+ metaPath: path.join(baseDir, 'meta.json'),
75
86
  };
76
87
  };
77
88
  /**
@@ -310,7 +321,13 @@ export const readRegistry = async () => {
310
321
  const writeRegistry = async (entries) => {
311
322
  const dir = getGlobalDir();
312
323
  await fs.mkdir(dir, { recursive: true });
313
- await fs.writeFile(getGlobalRegistryPath(), JSON.stringify(entries, null, 2), 'utf-8');
324
+ // Atomic tmp+rename (mirrors saveMeta): a crash mid-write can never leave a
325
+ // truncated/half-written registry.json that the next load would treat as
326
+ // empty and silently drop every registered repo (#2106 R9).
327
+ const target = getGlobalRegistryPath();
328
+ const tmp = `${target}.tmp`;
329
+ await fs.writeFile(tmp, JSON.stringify(entries, null, 2), 'utf-8');
330
+ await fs.rename(tmp, target);
314
331
  };
315
332
  /**
316
333
  * Thrown by {@link registerRepo} when a requested name is already in
@@ -466,22 +483,89 @@ export const registerRepo = async (repoPath, meta, opts) => {
466
483
  throw new RegistryNameCollisionError(name, collidingEntry.path, resolved);
467
484
  }
468
485
  }
469
- const entry = {
470
- name,
471
- path: resolved,
472
- storagePath,
473
- indexedAt: meta.indexedAt,
474
- lastCommit: meta.lastCommit,
475
- remoteUrl: meta.remoteUrl,
476
- stats: meta.stats,
477
- };
478
- if (existingIdx >= 0) {
479
- entries[existingIdx] = entry;
486
+ // This run's branch summary (non-primary runs only); hoisted so the
487
+ // re-read-before-write merge below can re-apply it against a fresh snapshot.
488
+ const summary = opts?.branch
489
+ ? {
490
+ branch: opts.branch,
491
+ indexedAt: meta.indexedAt,
492
+ lastCommit: meta.lastCommit,
493
+ stats: meta.stats,
494
+ }
495
+ : null;
496
+ let entry;
497
+ if (summary) {
498
+ // Non-primary branch run (#2106): keep the primary's top-level fields and
499
+ // upsert this branch into branches[]. One entry per path is preserved.
500
+ // When the registry entry is missing (lost/rebuilt registry.json), rebuild
501
+ // the primary top-level from the FLAT meta.json rather than this branch's
502
+ // meta, so `--branch <primary>` can still resolve (#2106 review).
503
+ const flatMeta = existing ? null : await loadMeta(storagePath);
504
+ const base = existing ?? {
505
+ name,
506
+ path: resolved,
507
+ storagePath,
508
+ indexedAt: flatMeta?.indexedAt ?? meta.indexedAt,
509
+ lastCommit: flatMeta?.lastCommit ?? meta.lastCommit,
510
+ remoteUrl: flatMeta?.remoteUrl ?? meta.remoteUrl,
511
+ stats: flatMeta?.stats ?? meta.stats,
512
+ ...(flatMeta?.branch ? { branch: flatMeta.branch } : {}),
513
+ };
514
+ const branches = (base.branches ?? []).filter((b) => b.branch !== summary.branch);
515
+ branches.push(summary);
516
+ entry = { ...base, name, branches };
480
517
  }
481
518
  else {
482
- entries.push(entry);
519
+ // Primary/flat run: refresh top-level fields, preserve any branch summaries
520
+ // already recorded for this path so a primary re-analyze does not drop them.
521
+ entry = {
522
+ name,
523
+ path: resolved,
524
+ storagePath,
525
+ indexedAt: meta.indexedAt,
526
+ lastCommit: meta.lastCommit,
527
+ remoteUrl: meta.remoteUrl,
528
+ stats: meta.stats,
529
+ ...(meta.branch ? { branch: meta.branch } : {}),
530
+ ...(existing?.branches ? { branches: existing.branches } : {}),
531
+ };
532
+ }
533
+ // Re-read immediately before writing to narrow the lost-update window (#2106
534
+ // R9): re-derive THIS run's delta against the FRESHEST snapshot so a
535
+ // concurrent change to the OTHER axis (a branch upsert vs a primary refresh)
536
+ // survives instead of being clobbered by a stale entry-time view.
537
+ const fresh = await readRegistry();
538
+ const freshIdx = fresh.findIndex((e) => {
539
+ const a = canonicalizePath(e.path);
540
+ return process.platform === 'win32'
541
+ ? a.toLowerCase() === canonicalInput.toLowerCase()
542
+ : a === canonicalInput;
543
+ });
544
+ const freshExisting = freshIdx >= 0 ? fresh[freshIdx] : null;
545
+ let merged;
546
+ if (summary) {
547
+ // Branch run: keep the FRESH top-level + branches, just upsert our summary.
548
+ const base = freshExisting ?? entry;
549
+ const branches = (base.branches ?? []).filter((b) => b.branch !== summary.branch);
550
+ branches.push(summary);
551
+ merged = { ...base, name, branches };
483
552
  }
484
- await writeRegistry(entries);
553
+ else {
554
+ // Primary run: apply our refreshed top-level, but defer to the FRESH
555
+ // branches[] (a concurrent branch upsert or `clean --branch` wins).
556
+ merged = { ...entry };
557
+ if (freshExisting?.branches)
558
+ merged.branches = freshExisting.branches;
559
+ else
560
+ delete merged.branches;
561
+ }
562
+ if (freshIdx >= 0) {
563
+ fresh[freshIdx] = merged;
564
+ }
565
+ else {
566
+ fresh.push(merged);
567
+ }
568
+ await writeRegistry(fresh);
485
569
  return name;
486
570
  };
487
571
  /**
@@ -500,6 +584,36 @@ export const unregisterRepo = async (repoPath) => {
500
584
  const filtered = entries.filter((e) => !matches(canonicalizePath(e.path), resolved));
501
585
  await writeRegistry(filtered);
502
586
  };
587
+ /**
588
+ * Remove a single non-primary branch's summary from a repo's registry entry
589
+ * (#2106 R7). Called by `gitnexus clean --branch`. Returns `true` when a
590
+ * matching `branches[]` summary was found and removed; `false` otherwise (so
591
+ * the CLI can report "no such indexed branch" without crashing). The top-level
592
+ * primary entry is left intact; an empty `branches[]` is dropped to keep the
593
+ * registry shape legacy-clean.
594
+ */
595
+ export const removeBranchIndex = async (repoPath, branch) => {
596
+ const resolved = canonicalizePath(repoPath);
597
+ const matches = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
598
+ const entries = await readRegistry();
599
+ const idx = entries.findIndex((e) => matches(canonicalizePath(e.path), resolved));
600
+ if (idx < 0)
601
+ return false;
602
+ const entry = entries[idx];
603
+ const before = entry.branches?.length ?? 0;
604
+ if (!entry.branches || before === 0)
605
+ return false;
606
+ const remaining = entry.branches.filter((b) => b.branch !== branch);
607
+ if (remaining.length === before)
608
+ return false; // branch not recorded
609
+ if (remaining.length > 0)
610
+ entry.branches = remaining;
611
+ else
612
+ delete entry.branches;
613
+ entries[idx] = entry;
614
+ await writeRegistry(entries);
615
+ return true;
616
+ };
503
617
  /**
504
618
  * Thrown by {@link resolveRegistryEntry} when no registered repo matches
505
619
  * the caller's target string (by alias, basename, remote-inferred name,
@@ -91,10 +91,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
91
91
  return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
92
92
  }
93
93
 
94
+ /**
95
+ * Whether opt-in diagnostics should be written to the hook's stderr. Strict
96
+ * hook runners validate hook output, so normal, non-error skip paths must stay
97
+ * silent unless the operator explicitly asks for diagnostics via GITNEXUS_DEBUG.
98
+ * See issue #1913.
99
+ */
100
+ function isDebugEnabled() {
101
+ return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
102
+ }
103
+
94
104
  function extractAugmentContext(stderr) {
95
105
  const output = (stderr || '').trim();
96
106
  const marker = output.indexOf('[GitNexus]');
97
- const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
107
+ const debug = isDebugEnabled();
98
108
  if (debug && output.length > 0) {
99
109
  // Emit the FULL discarded prefix (everything before the marker, or all of
100
110
  // it when no marker is present) so suppressed diagnostics — LadybugDB lock
@@ -258,8 +268,14 @@ function buildAfterToolContext(input) {
258
268
  if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) {
259
269
  const hint = buildStaleIndexHint(gitNexusDir, cwd);
260
270
  if (hint) {
261
- process.stderr.write(`${hint}\n`);
271
+ // The hint always reaches the agent via additionalContext (parts). Mirror
272
+ // it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict
273
+ // hook runners see no unexpected output on this normal path (#1913). The
274
+ // claude hook never mirrored this to stderr — this aligns the two adapters.
262
275
  parts.push(hint);
276
+ if (isDebugEnabled()) {
277
+ process.stderr.write(`${hint}\n`);
278
+ }
263
279
  }
264
280
  }
265
281
  }
@@ -269,7 +285,11 @@ function buildAfterToolContext(input) {
269
285
 
270
286
  function runAugment(gitNexusDir, cwd, pattern) {
271
287
  if (hasGitNexusServerOwner(gitNexusDir)) {
272
- process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
288
+ // Normal skip path: the MCP server owns the DB. Stay silent for strict
289
+ // hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
290
+ if (isDebugEnabled()) {
291
+ process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
292
+ }
273
293
  return '';
274
294
  }
275
295
  const release = acquireHookSlot(gitNexusDir);
@@ -338,7 +358,7 @@ function main() {
338
358
  const handler = handlers[input.hook_event_name || ''];
339
359
  if (handler) handler(input);
340
360
  } catch (err) {
341
- if (process.env.GITNEXUS_DEBUG) {
361
+ if (isDebugEnabled()) {
342
362
  console.error('GitNexus antigravity hook error:', (err.message || '').slice(0, 200));
343
363
  }
344
364
  }