gitnexus 1.6.6-rc.130 → 1.6.6-rc.131

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.
@@ -18,12 +18,45 @@ export interface AIContextOptions {
18
18
  skipAgentsMd?: boolean;
19
19
  noStats?: boolean;
20
20
  skipSkills?: boolean;
21
+ /**
22
+ * Default branch used by the generated regression-compare example (#243).
23
+ * Resolved by the CLI (CLI flag > `.gitnexusrc` > auto-detect > "main"); a
24
+ * plain caller that omits it gets "main", preserving prior behavior.
25
+ */
26
+ defaultBranch?: string;
21
27
  }
22
- export declare function generateGitNexusContent(projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], groupNames?: string[], noStats?: boolean, skipSkills?: boolean, runnerPath?: string): string;
28
+ /**
29
+ * Strip backticks from a branch name before it is embedded in a Markdown
30
+ * inline-code span (#1996 tri-review P1). validateBranchName already rejects
31
+ * backticks for CLI/config/auto-detect inputs; this is the last-line defense at
32
+ * the generation sink so the embedding is provably safe regardless of caller.
33
+ */
34
+ export declare function markdownSafeBranch(branch: string): string;
35
+ export declare function generateGitNexusContent(projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], groupNames?: string[], noStats?: boolean, skipSkills?: boolean, runnerPath?: string, defaultBranch?: string): string;
23
36
  /**
24
37
  * Generate AI context files after indexing
25
38
  */
26
39
  export declare function generateAIContextFiles(repoPath: string, storagePath: string, projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], options?: AIContextOptions): Promise<{
27
40
  files: string[];
28
41
  }>;
42
+ /**
43
+ * Refresh only the `base_ref: "..."` value inside the GitNexus block of an
44
+ * already-generated AGENTS.md / CLAUDE.md, in place (#1996 tri-review P2).
45
+ *
46
+ * The `alreadyUpToDate` analyze fast path returns before the normal
47
+ * {@link generateAIContextFiles} call, so a changed `.gitnexusrc` defaultBranch
48
+ * (or `--default-branch`) would otherwise not take effect until the next
49
+ * re-index. This does a surgical line update that preserves the rest of the
50
+ * block — including community-skill rows written by a prior `--skills` run —
51
+ * rather than regenerating (which would drop those rows on a no-`--skills` run).
52
+ *
53
+ * Best-effort: missing files, a missing/blank block, or a block with no
54
+ * `base_ref` line (e.g. a user-trimmed keep block) are silently skipped. Writes
55
+ * only when the value actually changes, so a routine up-to-date run is a no-op.
56
+ */
57
+ export declare function refreshBaseRefLine(repoPath: string, defaultBranch: string, options?: {
58
+ skipAgentsMd?: boolean;
59
+ }): Promise<{
60
+ files: string[];
61
+ }>;
29
62
  export {};
@@ -68,11 +68,28 @@ async function findGroupsContainingRegistryName(registryName) {
68
68
  }
69
69
  return hits;
70
70
  }
71
+ /**
72
+ * Strip backticks from a branch name before it is embedded in a Markdown
73
+ * inline-code span (#1996 tri-review P1). validateBranchName already rejects
74
+ * backticks for CLI/config/auto-detect inputs; this is the last-line defense at
75
+ * the generation sink so the embedding is provably safe regardless of caller.
76
+ */
77
+ export function markdownSafeBranch(branch) {
78
+ return branch.replace(/`/g, '');
79
+ }
71
80
  export function generateGitNexusContent(projectName, stats, generatedSkills, groupNames, noStats, skipSkills,
72
81
  // Project-relative path to the runner `gitnexus analyze` drops next to the
73
82
  // index (#1945). Referenced by docs so a single CLI-neutral command resolves
74
83
  // the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time.
75
- runnerPath = '.gitnexus/run.cjs') {
84
+ runnerPath = '.gitnexus/run.cjs',
85
+ // Default branch for the regression-compare example (#243). Configurable so
86
+ // projects on `develop`/`master`/etc. don't get `base_ref: "main"` rewritten
87
+ // back over their fix on every analyze. The value is embedded inside a
88
+ // Markdown inline-code span: validateBranchName rejects backticks upstream,
89
+ // and `markdownSafeBranch` strips any remaining backtick here as defense in
90
+ // depth, so JSON.stringify's quote/escape handling is sufficient and the
91
+ // branch cannot break out of the span (#1996 tri-review P1).
92
+ defaultBranch = 'main') {
76
93
  const generatedRows = generatedSkills && generatedSkills.length > 0
77
94
  ? generatedSkills
78
95
  .map((s) => `| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`)
@@ -115,7 +132,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
115
132
  ## Always Do
116
133
 
117
134
  - **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`gitnexus_impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.
118
- - **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows.
135
+ - **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: \`gitnexus_detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\`.
119
136
  - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
120
137
  - When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
121
138
  - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
@@ -331,7 +348,7 @@ export async function generateAIContextFiles(repoPath, storagePath, projectName,
331
348
  catch (err) {
332
349
  logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`);
333
350
  }
334
- const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames, options?.noStats, options?.skipSkills, runnerPath);
351
+ const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames, options?.noStats, options?.skipSkills, runnerPath, options?.defaultBranch ?? 'main');
335
352
  const createdFiles = [];
336
353
  if (!options?.skipAgentsMd) {
337
354
  // Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
@@ -359,3 +376,58 @@ export async function generateAIContextFiles(repoPath, storagePath, projectName,
359
376
  }
360
377
  return { files: createdFiles };
361
378
  }
379
+ /**
380
+ * Refresh only the `base_ref: "..."` value inside the GitNexus block of an
381
+ * already-generated AGENTS.md / CLAUDE.md, in place (#1996 tri-review P2).
382
+ *
383
+ * The `alreadyUpToDate` analyze fast path returns before the normal
384
+ * {@link generateAIContextFiles} call, so a changed `.gitnexusrc` defaultBranch
385
+ * (or `--default-branch`) would otherwise not take effect until the next
386
+ * re-index. This does a surgical line update that preserves the rest of the
387
+ * block — including community-skill rows written by a prior `--skills` run —
388
+ * rather than regenerating (which would drop those rows on a no-`--skills` run).
389
+ *
390
+ * Best-effort: missing files, a missing/blank block, or a block with no
391
+ * `base_ref` line (e.g. a user-trimmed keep block) are silently skipped. Writes
392
+ * only when the value actually changes, so a routine up-to-date run is a no-op.
393
+ */
394
+ export async function refreshBaseRefLine(repoPath, defaultBranch, options) {
395
+ if (options?.skipAgentsMd)
396
+ return { files: [] };
397
+ const replacement = `base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}`;
398
+ const updated = [];
399
+ for (const name of ['AGENTS.md', 'CLAUDE.md']) {
400
+ const filePath = path.join(repoPath, name);
401
+ if (!(await fileExists(filePath)))
402
+ continue;
403
+ let content;
404
+ try {
405
+ content = await fs.readFile(filePath, 'utf-8');
406
+ }
407
+ catch {
408
+ continue;
409
+ }
410
+ const startIdx = findSectionMarkerIndex(content, GITNEXUS_START_MARKER);
411
+ if (startIdx === -1)
412
+ continue;
413
+ const endIdx = findSectionMarkerIndex(content, GITNEXUS_END_MARKER, startIdx);
414
+ if (endIdx === -1 || endIdx <= startIdx)
415
+ continue;
416
+ const blockEnd = endIdx + GITNEXUS_END_MARKER.length;
417
+ const block = content.substring(startIdx, blockEnd);
418
+ // Only the generated regression example carries a base_ref line, and only
419
+ // one per block; replace its quoted value while leaving the rest untouched.
420
+ const newBlock = block.replace(/base_ref: "(?:[^"\\]|\\.)*"/, replacement);
421
+ if (newBlock === block)
422
+ continue; // no base_ref line present, or already current
423
+ const newContent = content.substring(0, startIdx) + newBlock + content.substring(blockEnd);
424
+ try {
425
+ await fs.writeFile(filePath, newContent, 'utf-8');
426
+ updated.push(name);
427
+ }
428
+ catch {
429
+ // best-effort — never fail analyze over a context refresh
430
+ }
431
+ }
432
+ return { files: updated };
433
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Project-local `.gitnexusrc` support for `gitnexus analyze` (#243).
3
+ *
4
+ * Lets a repository commit recurring `analyze` defaults — default branch for the
5
+ * generated regression example, AI-context / skills opt-outs, embedding knobs —
6
+ * so contributors don't re-pass the same flags on every run. Design rules:
7
+ *
8
+ * - Config is repo-local (`.gitnexusrc` at the resolved repo root). It is NOT
9
+ * read from `.gitnexus/` because that directory is index storage and is
10
+ * commonly gitignored.
11
+ * - JSON only. No YAML, no `package.json` field, no global `~/.gitnexus` file
12
+ * in this pass.
13
+ * - CLI flags always override config (see {@link mergeAnalyzeOptions}).
14
+ * - Fail closed: unknown keys, wrong value types, conflicting aliases, and
15
+ * invalid JSON all throw {@link GitNexusRcError} so a typo never silently
16
+ * no-ops. Errors are actionable and surface before any expensive analysis.
17
+ * - Config values never reach a shell. Strings are validated against control
18
+ * and hidden/bidirectional characters so they cannot inject markdown,
19
+ * JSON, or hidden controls into generated context files.
20
+ *
21
+ * Both a flat shape and a nested `analyze` block are accepted:
22
+ *
23
+ * { "defaultBranch": "develop", "skipContextFiles": true }
24
+ * { "analyze": { "defaultBranch": "develop", "skipSkills": true } }
25
+ *
26
+ * When both set the same option, the nested `analyze` block wins (deterministic,
27
+ * documented precedence). Conflicting *aliases at the same level* (e.g. both
28
+ * `defaultBranch` and `branch`) are rejected.
29
+ */
30
+ import type { AnalyzeOptions } from './analyze.js';
31
+ export declare const GITNEXUS_RC_FILENAME = ".gitnexusrc";
32
+ /** Final fallback when no branch is configured or detectable. */
33
+ export declare const DEFAULT_BRANCH_FALLBACK = "main";
34
+ /**
35
+ * Thrown for any `.gitnexusrc` problem (missing-file is NOT an error — it
36
+ * returns `undefined`). The message is user-facing and names the file so the
37
+ * CLI can print it verbatim before starting the progress bar.
38
+ */
39
+ export declare class GitNexusRcError extends Error {
40
+ constructor(message: string);
41
+ }
42
+ /**
43
+ * Validate a user-supplied branch name (from CLI or `.gitnexusrc`). Returns the
44
+ * trimmed name or throws {@link GitNexusRcError}. Conservative but accepts the
45
+ * shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
46
+ */
47
+ export declare function validateBranchName(value: string, source: string): string;
48
+ /**
49
+ * Best-effort validation for an auto-detected branch (from git). Never throws —
50
+ * returns `undefined` for anything unusable so the resolver falls back to the
51
+ * next precedence tier.
52
+ */
53
+ export declare function sanitizeDetectedBranch(value: string | null | undefined): string | undefined;
54
+ /**
55
+ * Locate, read, parse, validate, and normalize `.gitnexusrc` at `repoRoot`.
56
+ *
57
+ * @returns the normalized config defaults, or `undefined` when no file exists
58
+ * (the normal case). Throws {@link GitNexusRcError} on any problem.
59
+ */
60
+ export declare function loadAnalyzeConfig(repoRoot: string): Partial<AnalyzeOptions> | undefined;
61
+ /**
62
+ * Merge CLI options over `.gitnexusrc` defaults. CLI wins whenever it provides a
63
+ * value — including an explicit `false` (so an explicit CLI off-switch beats a
64
+ * config `true`). Config fills only the genuinely-unset (`undefined`) options.
65
+ *
66
+ * `stats` is special: Commander always materializes it (`true` by default,
67
+ * `false` only when `--no-stats` is passed), so plain `??` can't tell "default
68
+ * on" from "explicitly on". The rule is: a passed `--no-stats` (`stats: false`)
69
+ * always wins; otherwise the config value applies. There is intentionally no
70
+ * `--stats` counter-flag, so config can only turn stats off, not force it back
71
+ * on against a `--no-stats`.
72
+ *
73
+ * `defaultBranch` is NOT resolved here — see {@link resolveDefaultBranch}, which
74
+ * applies the CLI > config > auto-detect > "main" precedence chain.
75
+ */
76
+ export declare function mergeAnalyzeOptions(cli: AnalyzeOptions, config: Partial<AnalyzeOptions> | undefined): AnalyzeOptions;
77
+ /**
78
+ * Resolve the default branch threaded into generated context, applying the
79
+ * precedence chain:
80
+ *
81
+ * CLI `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch`
82
+ * > auto-detected `origin/HEAD` > {@link DEFAULT_BRANCH_FALLBACK} ("main").
83
+ *
84
+ * User-supplied values (CLI, config) are validated strictly and throw on bad
85
+ * input. The auto-detected value is best-effort and silently ignored if
86
+ * unusable.
87
+ */
88
+ export declare function resolveDefaultBranch(input: {
89
+ cliBranch?: string;
90
+ configBranch?: string;
91
+ detectedBranch?: string | null;
92
+ }): string;
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Project-local `.gitnexusrc` support for `gitnexus analyze` (#243).
3
+ *
4
+ * Lets a repository commit recurring `analyze` defaults — default branch for the
5
+ * generated regression example, AI-context / skills opt-outs, embedding knobs —
6
+ * so contributors don't re-pass the same flags on every run. Design rules:
7
+ *
8
+ * - Config is repo-local (`.gitnexusrc` at the resolved repo root). It is NOT
9
+ * read from `.gitnexus/` because that directory is index storage and is
10
+ * commonly gitignored.
11
+ * - JSON only. No YAML, no `package.json` field, no global `~/.gitnexus` file
12
+ * in this pass.
13
+ * - CLI flags always override config (see {@link mergeAnalyzeOptions}).
14
+ * - Fail closed: unknown keys, wrong value types, conflicting aliases, and
15
+ * invalid JSON all throw {@link GitNexusRcError} so a typo never silently
16
+ * no-ops. Errors are actionable and surface before any expensive analysis.
17
+ * - Config values never reach a shell. Strings are validated against control
18
+ * and hidden/bidirectional characters so they cannot inject markdown,
19
+ * JSON, or hidden controls into generated context files.
20
+ *
21
+ * Both a flat shape and a nested `analyze` block are accepted:
22
+ *
23
+ * { "defaultBranch": "develop", "skipContextFiles": true }
24
+ * { "analyze": { "defaultBranch": "develop", "skipSkills": true } }
25
+ *
26
+ * When both set the same option, the nested `analyze` block wins (deterministic,
27
+ * documented precedence). Conflicting *aliases at the same level* (e.g. both
28
+ * `defaultBranch` and `branch`) are rejected.
29
+ */
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
33
+ /** Final fallback when no branch is configured or detectable. */
34
+ export const DEFAULT_BRANCH_FALLBACK = 'main';
35
+ /** Git refs longer than this are almost certainly a mistake / injection attempt. */
36
+ const BRANCH_MAX_LENGTH = 255;
37
+ /**
38
+ * Thrown for any `.gitnexusrc` problem (missing-file is NOT an error — it
39
+ * returns `undefined`). The message is user-facing and names the file so the
40
+ * CLI can print it verbatim before starting the progress bar.
41
+ */
42
+ export class GitNexusRcError extends Error {
43
+ constructor(message) {
44
+ super(message);
45
+ this.name = 'GitNexusRcError';
46
+ }
47
+ }
48
+ /**
49
+ * Allowed `.gitnexusrc` keys and how each maps onto `AnalyzeOptions`.
50
+ *
51
+ * Aliases intentionally collapse onto a shared target:
52
+ * - `branch` is the legacy alias for `defaultBranch` (issue-comment shape).
53
+ * - `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they
54
+ * suppress the AGENTS.md / CLAUDE.md block ONLY. They do not imply
55
+ * `skipSkills`, and they are weaker than `indexOnly` (which skips all
56
+ * file injection). This matches the existing CLI semantics exactly.
57
+ * - `noStats` is the negation of `stats`.
58
+ */
59
+ const KEY_SPECS = {
60
+ defaultBranch: { target: 'defaultBranch', kind: 'branch' },
61
+ branch: { target: 'defaultBranch', kind: 'branch' },
62
+ skipAgentsMd: { target: 'skipAgentsMd', kind: 'boolean' },
63
+ skipContextFiles: { target: 'skipAgentsMd', kind: 'boolean' },
64
+ skipAiContext: { target: 'skipAgentsMd', kind: 'boolean' },
65
+ skipSkills: { target: 'skipSkills', kind: 'boolean' },
66
+ indexOnly: { target: 'indexOnly', kind: 'boolean' },
67
+ stats: { target: 'stats', kind: 'boolean' },
68
+ noStats: { target: 'stats', kind: 'boolean-negate' },
69
+ embeddings: { target: 'embeddings', kind: 'embeddings' },
70
+ dropEmbeddings: { target: 'dropEmbeddings', kind: 'boolean' },
71
+ name: { target: 'name', kind: 'string' },
72
+ allowDuplicateName: { target: 'allowDuplicateName', kind: 'boolean' },
73
+ maxFileSize: { target: 'maxFileSize', kind: 'numeric-string' },
74
+ workerTimeout: { target: 'workerTimeout', kind: 'numeric-string' },
75
+ walCheckpointThreshold: { target: 'walCheckpointThreshold', kind: 'numeric-string' },
76
+ workers: { target: 'workers', kind: 'numeric-string' },
77
+ embeddingThreads: { target: 'embeddingThreads', kind: 'numeric-string' },
78
+ embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
79
+ embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
80
+ embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
81
+ };
82
+ /** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
83
+ const NESTED_KEY = 'analyze';
84
+ const ALLOWED_KEYS_HINT = `Allowed keys: ${Object.keys(KEY_SPECS).join(', ')} (or a nested "${NESTED_KEY}" object).`;
85
+ /**
86
+ * Reject control characters and hidden / bidirectional Unicode in a string
87
+ * value. These have no legitimate place in a branch name, registry name, or
88
+ * device string, and would otherwise let a committed config smuggle invisible
89
+ * controls into generated AGENTS.md / CLAUDE.md content.
90
+ */
91
+ const isHiddenOrControl = (codePoint) => codePoint < 0x20 ||
92
+ codePoint === 0x7f ||
93
+ (codePoint >= 0x200b && codePoint <= 0x200f) || // zero-width + LRM/RLM
94
+ (codePoint >= 0x202a && codePoint <= 0x202e) || // bidi embeddings/overrides
95
+ (codePoint >= 0x2060 && codePoint <= 0x2064) || // word-joiner + invisible math
96
+ (codePoint >= 0x2066 && codePoint <= 0x206f) || // bidi isolates + deprecated
97
+ codePoint === 0xfeff; // BOM / zero-width no-break space
98
+ const assertNoHiddenChars = (value, source) => {
99
+ for (const ch of value) {
100
+ const cp = ch.codePointAt(0);
101
+ if (cp !== undefined && isHiddenOrControl(cp)) {
102
+ throw new GitNexusRcError(`${source}: value contains control or hidden/bidirectional characters, which are not allowed.`);
103
+ }
104
+ }
105
+ };
106
+ /**
107
+ * Validate a user-supplied branch name (from CLI or `.gitnexusrc`). Returns the
108
+ * trimmed name or throws {@link GitNexusRcError}. Conservative but accepts the
109
+ * shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
110
+ */
111
+ export function validateBranchName(value, source) {
112
+ const trimmed = value.trim();
113
+ if (!trimmed) {
114
+ throw new GitNexusRcError(`${source}: branch name must not be empty.`);
115
+ }
116
+ if (trimmed.length > BRANCH_MAX_LENGTH) {
117
+ throw new GitNexusRcError(`${source}: branch name is too long (max ${BRANCH_MAX_LENGTH}).`);
118
+ }
119
+ assertNoHiddenChars(trimmed, source);
120
+ if (/\s/.test(trimmed)) {
121
+ throw new GitNexusRcError(`${source}: branch name must not contain whitespace.`);
122
+ }
123
+ // git ref-name rules (subset): reject characters git itself forbids in refs.
124
+ if (/[~^:?*[\\]/.test(trimmed)) {
125
+ throw new GitNexusRcError(`${source}: branch name contains characters not allowed in a git ref (~ ^ : ? * [ \\).`);
126
+ }
127
+ if (trimmed.startsWith('-')) {
128
+ throw new GitNexusRcError(`${source}: branch name must not start with "-".`);
129
+ }
130
+ if (trimmed.includes('..')) {
131
+ throw new GitNexusRcError(`${source}: branch name must not contain "..".`);
132
+ }
133
+ // Git permits a backtick in a ref, but the branch is embedded inside a
134
+ // Markdown inline-code span in the generated AGENTS.md/CLAUDE.md regression
135
+ // example, where a backtick would close the span early and let the rest of
136
+ // the template render as instruction text. Reject it at this single
137
+ // chokepoint so all three tiers (CLI flag, .gitnexusrc, auto-detect via
138
+ // sanitizeDetectedBranch) are covered (#1996 tri-review P1).
139
+ if (trimmed.includes('`')) {
140
+ throw new GitNexusRcError(`${source}: branch name must not contain a backtick (it would break the generated Markdown).`);
141
+ }
142
+ return trimmed;
143
+ }
144
+ /**
145
+ * Best-effort validation for an auto-detected branch (from git). Never throws —
146
+ * returns `undefined` for anything unusable so the resolver falls back to the
147
+ * next precedence tier.
148
+ */
149
+ export function sanitizeDetectedBranch(value) {
150
+ if (!value)
151
+ return undefined;
152
+ try {
153
+ return validateBranchName(value, 'detected branch');
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ }
159
+ const normalizeValue = (kind, value, key) => {
160
+ const source = `${GITNEXUS_RC_FILENAME} "${key}"`;
161
+ switch (kind) {
162
+ case 'boolean':
163
+ if (typeof value !== 'boolean') {
164
+ throw new GitNexusRcError(`${source} must be a boolean (true/false).`);
165
+ }
166
+ return value;
167
+ case 'boolean-negate':
168
+ if (typeof value !== 'boolean') {
169
+ throw new GitNexusRcError(`${source} must be a boolean (true/false).`);
170
+ }
171
+ return !value;
172
+ case 'branch':
173
+ if (typeof value !== 'string') {
174
+ throw new GitNexusRcError(`${source} must be a string branch name.`);
175
+ }
176
+ return validateBranchName(value, source);
177
+ case 'string': {
178
+ if (typeof value !== 'string') {
179
+ throw new GitNexusRcError(`${source} must be a string.`);
180
+ }
181
+ const trimmed = value.trim();
182
+ if (!trimmed) {
183
+ throw new GitNexusRcError(`${source} must not be empty.`);
184
+ }
185
+ assertNoHiddenChars(trimmed, source);
186
+ // `name` flows into the generated AGENTS.md/CLAUDE.md as `**${name}**` and
187
+ // inside `gitnexus://repo/${name}/…` code spans, so a Markdown-significant
188
+ // character would break those spans or inject emphasis/links/HTML into
189
+ // agent-instruction content (#1996 tri-review P1). `_` is intentionally
190
+ // allowed (legitimate in repo names; intraword `_` is not emphasis).
191
+ // embeddingDevice (the other `string`-kind option) only ever holds a
192
+ // fixed device token, so this guard never rejects a valid value there.
193
+ if (/[`*[\]<>]/.test(trimmed)) {
194
+ throw new GitNexusRcError(`${source} must not contain Markdown-significant characters (\` * [ ] < >).`);
195
+ }
196
+ return trimmed;
197
+ }
198
+ case 'numeric-string': {
199
+ // Mirror Commander's contract: these options reach the existing CLI
200
+ // validation as strings. Accept a JSON number or a string; normalize to a
201
+ // string and let the downstream per-flag validation enforce ranges so the
202
+ // error messages stay in one place.
203
+ if (typeof value === 'number') {
204
+ if (!Number.isFinite(value)) {
205
+ throw new GitNexusRcError(`${source} must be a finite number.`);
206
+ }
207
+ return String(value);
208
+ }
209
+ if (typeof value === 'string') {
210
+ const trimmed = value.trim();
211
+ if (!trimmed) {
212
+ throw new GitNexusRcError(`${source} must not be empty.`);
213
+ }
214
+ return trimmed;
215
+ }
216
+ throw new GitNexusRcError(`${source} must be a number or numeric string.`);
217
+ }
218
+ case 'embeddings': {
219
+ // Mirror `--embeddings [limit]`: boolean toggles, a non-negative integer
220
+ // sets the node cap (normalized to a string, as Commander would supply).
221
+ if (typeof value === 'boolean')
222
+ return value;
223
+ if (typeof value === 'number') {
224
+ if (!Number.isInteger(value) || value < 0) {
225
+ throw new GitNexusRcError(`${source} must be true/false or a non-negative integer (node cap; 0 disables the cap).`);
226
+ }
227
+ return String(value);
228
+ }
229
+ throw new GitNexusRcError(`${source} must be a boolean or a non-negative integer (node cap; 0 disables the cap).`);
230
+ }
231
+ default:
232
+ // Exhaustive — kept for forward-compat if a new kind is added.
233
+ throw new GitNexusRcError(`${source}: unsupported config value kind.`);
234
+ }
235
+ };
236
+ /**
237
+ * Normalize one level (flat top-level or the nested `analyze` block) into a
238
+ * partial `AnalyzeOptions`. Rejects unknown keys and two aliases that configure
239
+ * the same option at the same level.
240
+ */
241
+ const normalizeLevel = (obj, { allowNestedKey }) => {
242
+ const out = {};
243
+ const setBy = new Map();
244
+ for (const [key, value] of Object.entries(obj)) {
245
+ if (allowNestedKey && key === NESTED_KEY)
246
+ continue; // handled separately
247
+ // `Object.hasOwn`, not a truthiness check: a plain-object lookup like
248
+ // `KEY_SPECS["__proto__"]` returns an inherited member (Object.prototype,
249
+ // truthy) and would slip past `if (!spec)`, hitting the wrong error branch
250
+ // instead of the documented "Unknown key" message (#1996 tri-review P3).
251
+ if (!Object.hasOwn(KEY_SPECS, key)) {
252
+ throw new GitNexusRcError(`Unknown key "${key}" in ${GITNEXUS_RC_FILENAME}. ${ALLOWED_KEYS_HINT}`);
253
+ }
254
+ const spec = KEY_SPECS[key];
255
+ const prev = setBy.get(spec.target);
256
+ if (prev && prev !== key) {
257
+ throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME}: "${prev}" and "${key}" both configure the same option; set only one.`);
258
+ }
259
+ setBy.set(spec.target, key);
260
+ out[spec.target] = normalizeValue(spec.kind, value, key);
261
+ }
262
+ return out;
263
+ };
264
+ /**
265
+ * Locate, read, parse, validate, and normalize `.gitnexusrc` at `repoRoot`.
266
+ *
267
+ * @returns the normalized config defaults, or `undefined` when no file exists
268
+ * (the normal case). Throws {@link GitNexusRcError} on any problem.
269
+ */
270
+ export function loadAnalyzeConfig(repoRoot) {
271
+ const filePath = path.join(repoRoot, GITNEXUS_RC_FILENAME);
272
+ let raw;
273
+ try {
274
+ raw = fs.readFileSync(filePath, 'utf-8');
275
+ }
276
+ catch (err) {
277
+ if (err?.code === 'ENOENT')
278
+ return undefined;
279
+ throw new GitNexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${err.message}`);
280
+ }
281
+ // Strip a leading UTF-8 BOM: Node's 'utf-8' decode keeps it, and JSON.parse
282
+ // then fails with a confusing "Unexpected token" on an otherwise-valid file
283
+ // (#1996 tri-review). Only one leading BOM is stripped; in-string control
284
+ // rejection still applies to the parsed values.
285
+ if (raw.charCodeAt(0) === 0xfeff)
286
+ raw = raw.slice(1);
287
+ let parsed;
288
+ try {
289
+ parsed = JSON.parse(raw);
290
+ }
291
+ catch (err) {
292
+ throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} is not valid JSON: ${err.message}. ` +
293
+ `Expected a JSON object such as {"defaultBranch": "develop", "skipContextFiles": true}.`);
294
+ }
295
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
296
+ throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} must contain a JSON object.`);
297
+ }
298
+ const obj = parsed;
299
+ const flat = normalizeLevel(obj, { allowNestedKey: true });
300
+ let nested = {};
301
+ if (Object.prototype.hasOwnProperty.call(obj, NESTED_KEY)) {
302
+ const nestedRaw = obj[NESTED_KEY];
303
+ if (nestedRaw === null || typeof nestedRaw !== 'object' || Array.isArray(nestedRaw)) {
304
+ throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} "${NESTED_KEY}" must be a JSON object.`);
305
+ }
306
+ nested = normalizeLevel(nestedRaw, { allowNestedKey: false });
307
+ }
308
+ // Nested `analyze` block overrides flat top-level keys for the same option.
309
+ return { ...flat, ...nested };
310
+ }
311
+ /**
312
+ * Merge CLI options over `.gitnexusrc` defaults. CLI wins whenever it provides a
313
+ * value — including an explicit `false` (so an explicit CLI off-switch beats a
314
+ * config `true`). Config fills only the genuinely-unset (`undefined`) options.
315
+ *
316
+ * `stats` is special: Commander always materializes it (`true` by default,
317
+ * `false` only when `--no-stats` is passed), so plain `??` can't tell "default
318
+ * on" from "explicitly on". The rule is: a passed `--no-stats` (`stats: false`)
319
+ * always wins; otherwise the config value applies. There is intentionally no
320
+ * `--stats` counter-flag, so config can only turn stats off, not force it back
321
+ * on against a `--no-stats`.
322
+ *
323
+ * `defaultBranch` is NOT resolved here — see {@link resolveDefaultBranch}, which
324
+ * applies the CLI > config > auto-detect > "main" precedence chain.
325
+ */
326
+ export function mergeAnalyzeOptions(cli, config) {
327
+ if (!config)
328
+ return cli;
329
+ const merged = { ...cli };
330
+ for (const key of Object.keys(config)) {
331
+ if (key === 'stats' || key === 'defaultBranch')
332
+ continue; // handled below / by resolver
333
+ if (merged[key] === undefined) {
334
+ merged[key] = config[key];
335
+ }
336
+ }
337
+ if (config.stats !== undefined && cli.stats !== false) {
338
+ merged.stats = config.stats;
339
+ }
340
+ return merged;
341
+ }
342
+ /**
343
+ * Resolve the default branch threaded into generated context, applying the
344
+ * precedence chain:
345
+ *
346
+ * CLI `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch`
347
+ * > auto-detected `origin/HEAD` > {@link DEFAULT_BRANCH_FALLBACK} ("main").
348
+ *
349
+ * User-supplied values (CLI, config) are validated strictly and throw on bad
350
+ * input. The auto-detected value is best-effort and silently ignored if
351
+ * unusable.
352
+ */
353
+ export function resolveDefaultBranch(input) {
354
+ if (input.cliBranch !== undefined) {
355
+ return validateBranchName(input.cliBranch, '--default-branch');
356
+ }
357
+ if (input.configBranch !== undefined) {
358
+ return validateBranchName(input.configBranch, `${GITNEXUS_RC_FILENAME} "defaultBranch"`);
359
+ }
360
+ const detected = sanitizeDetectedBranch(input.detectedBranch);
361
+ if (detected)
362
+ return detected;
363
+ return DEFAULT_BRANCH_FALLBACK;
364
+ }
@@ -42,6 +42,13 @@ export interface AnalyzeOptions {
42
42
  stats?: boolean;
43
43
  /** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
44
44
  skipSkills?: boolean;
45
+ /**
46
+ * Default branch for the generated regression-compare example (#243). From
47
+ * `--default-branch`; may also be supplied via `.gitnexusrc`. Resolved to a
48
+ * concrete branch (CLI > `.gitnexusrc` > auto-detected origin/HEAD > "main")
49
+ * before being threaded into the generated AGENTS.md / CLAUDE.md content.
50
+ */
51
+ defaultBranch?: string;
45
52
  /** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
46
53
  indexOnly?: boolean;
47
54
  /** Index the folder even when no .git directory is present. */
@@ -14,7 +14,8 @@ import cliProgress from 'cli-progress';
14
14
  import { closeLbug } from '../core/lbug/lbug-adapter.js';
15
15
  import { isLbugCheckpointIoError, isWalCorruptionError, parseWalCheckpointThreshold, WAL_RECOVERY_SUGGESTION, } from '../core/lbug/lbug-config.js';
16
16
  import { getStoragePaths, getGlobalRegistryPath, RegistryNameCollisionError, AnalysisNotFinalizedError, assertAnalysisFinalized, } from '../storage/repo-manager.js';
17
- import { getGitRoot, hasGitDir } from '../storage/git.js';
17
+ import { getGitRoot, hasGitDir, getDefaultBranch } from '../storage/git.js';
18
+ import { loadAnalyzeConfig, mergeAnalyzeOptions, resolveDefaultBranch, validateBranchName, GitNexusRcError, } from './analyze-config.js';
18
19
  import { runFullAnalysis } from '../core/run-analyze.js';
19
20
  import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
20
21
  import { warnMissingOptionalGrammars } from './optional-grammars.js';
@@ -454,14 +455,101 @@ export const analyzeCommand = async (inputPath, options) => {
454
455
  restoreAnalyzeEnv(envSnap);
455
456
  }
456
457
  };
457
- const analyzeCommandImpl = async (inputPath, options) => {
458
- if (options?.verbose) {
458
+ const analyzeCommandImpl = async (inputPath, cliOptions) => {
459
+ console.log('\n GitNexus Analyzer\n');
460
+ // ── Resolve the target repo root ──────────────────────────────────
461
+ // Resolved FIRST because `.gitnexusrc` is read from the repo root (not the
462
+ // caller's cwd), and config can set defaults that the validation below
463
+ // consumes. `--skip-git` is a CLI-only flag (never a config key), so the raw
464
+ // CLI options are authoritative for repo-root resolution.
465
+ let repoPath;
466
+ if (inputPath) {
467
+ repoPath = path.resolve(inputPath);
468
+ }
469
+ else if (cliOptions?.skipGit) {
470
+ // --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
471
+ repoPath = path.resolve(process.cwd());
472
+ }
473
+ else {
474
+ const gitRoot = getGitRoot(process.cwd());
475
+ if (!gitRoot) {
476
+ console.log(' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
477
+ process.exitCode = 1;
478
+ return;
479
+ }
480
+ repoPath = gitRoot;
481
+ }
482
+ const repoHasGit = hasGitDir(repoPath);
483
+ if (!repoHasGit && !cliOptions?.skipGit) {
484
+ console.log(' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
485
+ process.exitCode = 1;
486
+ return;
487
+ }
488
+ if (!repoHasGit) {
489
+ console.log(' Warning: no .git directory found — commit-tracking and incremental updates disabled.\n');
490
+ }
491
+ // Validate an explicit `--default-branch` up front so its errors are
492
+ // attributed to the flag (with a CLI-specific recovery hint) rather than to
493
+ // `.gitnexusrc`, which the user may not even have (#1996 tri-review).
494
+ if (cliOptions?.defaultBranch !== undefined) {
495
+ try {
496
+ validateBranchName(cliOptions.defaultBranch, '--default-branch');
497
+ }
498
+ catch (err) {
499
+ cliError(` ${err instanceof Error ? err.message : String(err)}\n`, {
500
+ recoveryHint: 'default-branch-invalid',
501
+ });
502
+ process.exitCode = 1;
503
+ return;
504
+ }
505
+ }
506
+ // ── Load .gitnexusrc and merge: CLI flags override config (#243) ───
507
+ // Parse/validate before the progress bar so a malformed config produces an
508
+ // actionable error and exits before any expensive analysis starts.
509
+ let options;
510
+ let resolvedDefaultBranch;
511
+ try {
512
+ const fileConfig = loadAnalyzeConfig(repoPath);
513
+ options = mergeAnalyzeOptions(cliOptions ?? {}, fileConfig);
514
+ // Resolve the default branch threaded into generated context:
515
+ // CLI --default-branch > .gitnexusrc defaultBranch/branch
516
+ // > auto-detected origin/HEAD > "main".
517
+ // Only shell out to git when no branch was configured AND the generated
518
+ // context will actually use it, keeping the common path free of an extra
519
+ // git call. Detection is best-effort and never blocks analyze.
520
+ const cliBranch = cliOptions?.defaultBranch;
521
+ const configBranch = fileConfig?.defaultBranch;
522
+ const willGenerateContext = !options.indexOnly && !options.skipAgentsMd;
523
+ let detectedBranch = null;
524
+ if (cliBranch === undefined &&
525
+ configBranch === undefined &&
526
+ repoHasGit &&
527
+ !cliOptions?.skipGit &&
528
+ willGenerateContext) {
529
+ try {
530
+ detectedBranch = getDefaultBranch(repoPath);
531
+ }
532
+ catch {
533
+ detectedBranch = null;
534
+ }
535
+ }
536
+ resolvedDefaultBranch = resolveDefaultBranch({ cliBranch, configBranch, detectedBranch });
537
+ }
538
+ catch (err) {
539
+ const msg = err instanceof GitNexusRcError
540
+ ? err.message
541
+ : `Invalid .gitnexusrc: ${err instanceof Error ? err.message : String(err)}`;
542
+ cliError(` ${msg}\n`, { recoveryHint: 'gitnexusrc-invalid' });
543
+ process.exitCode = 1;
544
+ return;
545
+ }
546
+ if (options.verbose) {
459
547
  process.env.GITNEXUS_VERBOSE = '1';
460
548
  }
461
- if (options?.maxFileSize) {
549
+ if (options.maxFileSize) {
462
550
  process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
463
551
  }
464
- if (options?.workerTimeout) {
552
+ if (options.workerTimeout) {
465
553
  const workerTimeoutSeconds = Number(options.workerTimeout);
466
554
  if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
467
555
  cliError(' --worker-timeout must be at least 1 second.\n');
@@ -470,7 +558,7 @@ const analyzeCommandImpl = async (inputPath, options) => {
470
558
  }
471
559
  process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(Math.round(workerTimeoutSeconds * 1000));
472
560
  }
473
- if (options?.walCheckpointThreshold !== undefined) {
561
+ if (options.walCheckpointThreshold !== undefined) {
474
562
  const parsed = parseWalCheckpointThreshold(options.walCheckpointThreshold);
475
563
  if (parsed === undefined) {
476
564
  cliError(' --wal-checkpoint-threshold must be an integer >= -1.\n');
@@ -486,7 +574,7 @@ const analyzeCommandImpl = async (inputPath, options) => {
486
574
  // values back-to-back and observe the value they passed, not whatever the
487
575
  // previous call leaked.
488
576
  let workerPoolSize;
489
- if (options?.workers !== undefined) {
577
+ if (options.workers !== undefined) {
490
578
  const parsedWorkers = Number(options.workers);
491
579
  if (!Number.isInteger(parsedWorkers) || parsedWorkers < 0) {
492
580
  cliError(' --workers must be a non-negative integer. ' +
@@ -501,7 +589,7 @@ const analyzeCommandImpl = async (inputPath, options) => {
501
589
  // sibling-validation pattern (exit before bar.start() — otherwise
502
590
  // process.exit() leaves the progress bar's hidden cursor uncleared).
503
591
  let embeddingsNodeLimit;
504
- if (typeof options?.embeddings === 'string') {
592
+ if (typeof options.embeddings === 'string') {
505
593
  const parsed = Number(options.embeddings);
506
594
  if (!Number.isInteger(parsed) || parsed < 0) {
507
595
  cliError(` --embeddings expects a non-negative integer (got "${options.embeddings}"). ` +
@@ -511,7 +599,7 @@ const analyzeCommandImpl = async (inputPath, options) => {
511
599
  }
512
600
  embeddingsNodeLimit = parsed;
513
601
  }
514
- const embeddingsEnabled = !!options?.embeddings;
602
+ const embeddingsEnabled = !!options.embeddings;
515
603
  const setPositiveEnv = (optionName, envName, value) => {
516
604
  if (value === undefined)
517
605
  return true;
@@ -524,12 +612,12 @@ const analyzeCommandImpl = async (inputPath, options) => {
524
612
  process.env[envName] = String(parsed);
525
613
  return true;
526
614
  };
527
- if (!setPositiveEnv('--embedding-threads', 'GITNEXUS_EMBEDDING_THREADS', options?.embeddingThreads) ||
528
- !setPositiveEnv('--embedding-batch-size', 'GITNEXUS_EMBEDDING_BATCH_SIZE', options?.embeddingBatchSize) ||
529
- !setPositiveEnv('--embedding-sub-batch-size', 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', options?.embeddingSubBatchSize)) {
615
+ if (!setPositiveEnv('--embedding-threads', 'GITNEXUS_EMBEDDING_THREADS', options.embeddingThreads) ||
616
+ !setPositiveEnv('--embedding-batch-size', 'GITNEXUS_EMBEDDING_BATCH_SIZE', options.embeddingBatchSize) ||
617
+ !setPositiveEnv('--embedding-sub-batch-size', 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', options.embeddingSubBatchSize)) {
530
618
  return;
531
619
  }
532
- if (options?.embeddingDevice) {
620
+ if (options.embeddingDevice) {
533
621
  const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
534
622
  if (!allowed.has(options.embeddingDevice)) {
535
623
  cliError(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
@@ -538,48 +626,21 @@ const analyzeCommandImpl = async (inputPath, options) => {
538
626
  }
539
627
  process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
540
628
  }
541
- if (options?.repairFts && options?.force) {
629
+ if (options.repairFts && options.force) {
542
630
  cliError(' Cannot combine `--repair-fts` with `--force`. ' +
543
631
  'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n');
544
632
  process.exitCode = 1;
545
633
  return;
546
634
  }
547
- console.log('\n GitNexus Analyzer\n');
548
635
  // `--index-only` is the stronger contract — it suppresses every form of file
549
636
  // injection, including community skill writes that `--skills` would normally
550
637
  // produce. Surface the override explicitly so users don't wonder why a
551
638
  // pipeline re-index ran but no skill files appeared. The pipeline still
552
- // re-runs (see `force: options?.force || options?.skills` below); the warning
639
+ // re-runs (see `force: options.force || options.skills` below); the warning
553
640
  // is purely about the dropped post-index write step.
554
- if (options?.indexOnly && options?.skills) {
641
+ if (options.indexOnly && options.skills) {
555
642
  console.log(' Note: --index-only overrides --skills; community skill files will not be written.\n');
556
643
  }
557
- let repoPath;
558
- if (inputPath) {
559
- repoPath = path.resolve(inputPath);
560
- }
561
- else if (options?.skipGit) {
562
- // --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
563
- repoPath = path.resolve(process.cwd());
564
- }
565
- else {
566
- const gitRoot = getGitRoot(process.cwd());
567
- if (!gitRoot) {
568
- console.log(' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
569
- process.exitCode = 1;
570
- return;
571
- }
572
- repoPath = gitRoot;
573
- }
574
- const repoHasGit = hasGitDir(repoPath);
575
- if (!repoHasGit && !options?.skipGit) {
576
- console.log(' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
577
- process.exitCode = 1;
578
- return;
579
- }
580
- if (!repoHasGit) {
581
- console.log(' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n');
582
- }
583
644
  // If the target repo contains files an optional grammar would parse but
584
645
  // that grammar's native binding is absent, warn before analysis so users
585
646
  // learn why those files end up unparsed instead of silently getting a
@@ -698,34 +759,37 @@ const analyzeCommandImpl = async (inputPath, options) => {
698
759
  const t0 = Date.now();
699
760
  // ── Run shared analysis orchestrator ───────────────────────────────
700
761
  try {
701
- const skipAll = options?.indexOnly;
702
- const skipAgentsMd = skipAll || options?.skipAgentsMd;
703
- const skipSkills = skipAll || options?.skipSkills;
762
+ const skipAll = options.indexOnly;
763
+ const skipAgentsMd = skipAll || options.skipAgentsMd;
764
+ const skipSkills = skipAll || options.skipSkills;
704
765
  const result = await runFullAnalysis(repoPath, {
705
766
  // Pipeline re-index — OR'd with --skills because skill generation
706
767
  // needs a fresh pipelineResult. Has no bearing on the registry
707
768
  // collision guard (see allowDuplicateName below).
708
- force: options?.force || options?.skills,
709
- repairFts: options?.repairFts,
769
+ force: options.force || options.skills,
770
+ repairFts: options.repairFts,
710
771
  embeddings: embeddingsEnabled,
711
772
  embeddingsNodeLimit,
712
- dropEmbeddings: options?.dropEmbeddings,
713
- verbose: options?.verbose,
714
- skipGit: options?.skipGit,
773
+ dropEmbeddings: options.dropEmbeddings,
774
+ verbose: options.verbose,
775
+ skipGit: options.skipGit,
715
776
  skipAgentsMd,
716
777
  skipSkills,
778
+ // Resolved default branch (CLI > .gitnexusrc > auto-detect > "main")
779
+ // threaded into the generated regression-compare example (#243).
780
+ defaultBranch: resolvedDefaultBranch,
717
781
  // commander.js `.option('--no-stats', …)` registers the flag as
718
782
  // `options.stats` (boolean, default true; `false` when the user
719
- // passed --no-stats). Reading `options?.noStats` here returns
783
+ // passed --no-stats). Reading `options.noStats` here returns
720
784
  // undefined every time, so the flag was a no-op on the markdown
721
785
  // rewrite path before this fix. See #1477.
722
- noStats: options?.stats === false,
723
- registryName: options?.name,
786
+ noStats: options.stats === false,
787
+ registryName: options.name,
724
788
  // Registry-collision bypass — its own CLI flag, intentionally NOT
725
789
  // overloading --force. A user who hits the collision guard should
726
790
  // be able to accept the duplicate name without also paying the
727
791
  // cost of a full pipeline re-index. See #829 review round 2.
728
- allowDuplicateName: options?.allowDuplicateName,
792
+ allowDuplicateName: options.allowDuplicateName,
729
793
  // Worker pool size threaded from --workers, replacing the previous
730
794
  // GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
731
795
  // env / auto-formula fallback inside the pipeline.
@@ -742,6 +806,20 @@ const analyzeCommandImpl = async (inputPath, options) => {
742
806
  // that half-finalized state, runFullAnalysis returns alreadyUpToDate
743
807
  // on the next invocation unless we check the registry here too.
744
808
  await assertAnalysisFinalized(repoPath);
809
+ // The fast path skips context regeneration, but a changed `.gitnexusrc`
810
+ // defaultBranch / `--default-branch` must still take effect. Surgically
811
+ // refresh just the `base_ref` line in AGENTS.md/CLAUDE.md in place,
812
+ // preserving the rest of the block (incl. --skills community rows). No-op
813
+ // when the value already matches, so a routine up-to-date run is silent
814
+ // (#1996 tri-review P2).
815
+ let baseRefRefreshed = [];
816
+ try {
817
+ const { refreshBaseRefLine } = await import('./ai-context.js');
818
+ baseRefRefreshed = (await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })).files;
819
+ }
820
+ catch {
821
+ /* best-effort — never fail the fast path over a context refresh */
822
+ }
745
823
  clearInterval(elapsedTimer);
746
824
  process.removeListener('SIGINT', sigintHandler);
747
825
  console.log = origLog;
@@ -751,6 +829,9 @@ const analyzeCommandImpl = async (inputPath, options) => {
751
829
  console.error = origError;
752
830
  bar.stop();
753
831
  console.log(' Already up to date\n');
832
+ if (baseRefRefreshed.length > 0) {
833
+ console.log(` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`);
834
+ }
754
835
  // Safe to return without process.exit(0) — the early-return path in
755
836
  // runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
756
837
  return;
@@ -809,9 +890,13 @@ const analyzeCommandImpl = async (inputPath, options) => {
809
890
  }, skillResult.skills, {
810
891
  skipAgentsMd,
811
892
  skipSkills,
893
+ // Same resolved branch as the main run (#243) so the --skills
894
+ // re-generation of AGENTS.md/CLAUDE.md does not revert base_ref
895
+ // to "main".
896
+ defaultBranch: resolvedDefaultBranch,
812
897
  // Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
813
898
  // exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
814
- noStats: options?.stats === false,
899
+ noStats: options.stats === false,
815
900
  });
816
901
  }
817
902
  }
@@ -12,7 +12,7 @@ import { type CliMessageKey, type CliMessageVars } from './i18n/index.js';
12
12
  * Consumers can import this type to narrow log-record `recoveryHint`
13
13
  * fields without restating the literal list.
14
14
  */
15
- export type RecoveryHint = 'wal-corruption' | 'wal-checkpoint-threshold' | 'heap-oom-respawn' | 'native-worker-abort' | 'hf-endpoint-unreachable' | 'local-embedding-unsupported' | 'large-repo' | 'npm-resolution' | 'module-not-found';
15
+ export type RecoveryHint = 'wal-corruption' | 'wal-checkpoint-threshold' | 'heap-oom-respawn' | 'native-worker-abort' | 'hf-endpoint-unreachable' | 'local-embedding-unsupported' | 'large-repo' | 'npm-resolution' | 'module-not-found' | 'gitnexusrc-invalid' | 'default-branch-invalid';
16
16
  /**
17
17
  * Common shape for the optional structured-field bag passed to
18
18
  * `cliError`/`cliWarn`/`cliInfo`. Typed so the `recoveryHint` slot is
package/dist/cli/index.js CHANGED
@@ -27,6 +27,8 @@ program
27
27
  .option('--skills', 'Generate repo-specific skill files from detected communities ' +
28
28
  '(no-op when --index-only is also set).')
29
29
  .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
30
+ .option('--default-branch <branch>', 'Default branch used in the generated regression-compare example (base_ref). ' +
31
+ 'Falls back to .gitnexusrc, then auto-detected origin/HEAD, then "main".')
30
32
  .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
31
33
  .option('--skip-skills', 'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
32
34
  'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
@@ -48,6 +48,13 @@ export interface AnalyzeOptions {
48
48
  noStats?: boolean;
49
49
  /** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
50
50
  skipSkills?: boolean;
51
+ /**
52
+ * Default branch threaded into generated AGENTS.md / CLAUDE.md so the
53
+ * regression-compare example uses the configured branch instead of a
54
+ * hardcoded "main" (#243). Resolved by the CLI; `undefined` here keeps the
55
+ * "main" fallback for non-CLI callers (e.g. the server analyze worker).
56
+ */
57
+ defaultBranch?: string;
51
58
  /**
52
59
  * User-provided alias for the registry `name` (#829). When set,
53
60
  * forwarded to `registerRepo` so the indexed repo is stored under
@@ -779,6 +779,7 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
779
779
  skipAgentsMd: options.skipAgentsMd,
780
780
  skipSkills: options.skipSkills,
781
781
  noStats: options.noStats,
782
+ defaultBranch: options.defaultBranch,
782
783
  });
783
784
  }
784
785
  catch {
@@ -107,6 +107,17 @@ export declare const hasGitDir: (dirPath: string) => boolean;
107
107
  * `<rig>/refinery/rig/` layouts).
108
108
  */
109
109
  export declare const getRemoteOriginUrl: (repoPath: string) => string | null;
110
+ /**
111
+ * Best-effort detection of the repository's default branch (#243).
112
+ *
113
+ * Reads `git symbolic-ref --short refs/remotes/origin/HEAD`, which resolves to
114
+ * the short ref `origin/<branch>` that the local `origin/HEAD` points at, and
115
+ * strips the `origin/` prefix. This is a purely local lookup — it never makes a
116
+ * network call. Returns `null` when there is no git repo, no `origin` remote, no
117
+ * `origin/HEAD` (e.g. it was never set by clone, or the repo is detached), or
118
+ * git is unavailable, so callers can fall back to a configured/default branch.
119
+ */
120
+ export declare const getDefaultBranch: (repoPath: string) => string | null;
110
121
  /**
111
122
  * Sanitize a repository name to prevent argument injection and ensure
112
123
  * cross-platform filesystem compatibility.
@@ -265,6 +265,36 @@ export const getRemoteOriginUrl = (repoPath) => {
265
265
  return null;
266
266
  }
267
267
  };
268
+ /**
269
+ * Best-effort detection of the repository's default branch (#243).
270
+ *
271
+ * Reads `git symbolic-ref --short refs/remotes/origin/HEAD`, which resolves to
272
+ * the short ref `origin/<branch>` that the local `origin/HEAD` points at, and
273
+ * strips the `origin/` prefix. This is a purely local lookup — it never makes a
274
+ * network call. Returns `null` when there is no git repo, no `origin` remote, no
275
+ * `origin/HEAD` (e.g. it was never set by clone, or the repo is detached), or
276
+ * git is unavailable, so callers can fall back to a configured/default branch.
277
+ */
278
+ export const getDefaultBranch = (repoPath) => {
279
+ try {
280
+ const ref = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
281
+ cwd: repoPath,
282
+ // Suppress stderr -- see getCurrentCommit comment and #1172. Without it,
283
+ // git prints "fatal: ref refs/remotes/origin/HEAD is not a symbolic ref"
284
+ // to the user's terminal on repos that never set origin/HEAD.
285
+ stdio: ['ignore', 'pipe', 'ignore'],
286
+ windowsHide: true,
287
+ })
288
+ .toString()
289
+ .trim();
290
+ if (!ref)
291
+ return null;
292
+ return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref;
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ };
268
298
  /**
269
299
  * Sanitize a repository name to prevent argument injection and ensure
270
300
  * cross-platform filesystem compatibility.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.6-rc.130",
3
+ "version": "1.6.6-rc.131",
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",