feat(cli): add .gitnexusrc config and --default-branch for analyze (#243) (#1996)

* feat(cli): add .gitnexusrc config and --default-branch for analyze (#243)

Let a repo preconfigure recurring `gitnexus analyze` options via a
project-local `.gitnexusrc` (JSON) plus a new `--default-branch` flag, so
projects on `develop`/`master` no longer get the generated regression
example rewritten to `base_ref: "main"` on every analyze run.

- New `cli/analyze-config.ts`: locate/parse/validate `.gitnexusrc` (flat +
  nested `analyze` form, alias mapping, fail-closed on unknown keys / bad
  types / hidden chars), merge with CLI (CLI overrides config), and resolve
  the default branch (CLI > config defaultBranch/branch > auto-detected
  origin/HEAD > "main").
- `getDefaultBranch()` in storage/git.ts (best-effort, local-only, no network).
- Thread `defaultBranch` through analyze -> run-analyze -> ai-context so the
  generated regression-compare example uses the configured branch,
  JSON-escaped; the --skills re-generation path uses the same branch.
- `skipContextFiles`/`skipAiContext` alias `skipAgentsMd` (block only, does
  not imply skipSkills); `indexOnly` stays the stronger "skip all injection".
- README + CLI help; unit tests for the config module and end-to-end wiring
  tests that fail if config is parsed but not threaded into analyze/context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): harden .gitnexusrc against Markdown injection and stale base_ref (#243)

Addresses the tri-review findings on PR #1996.

- P1 (Markdown injection into generated AGENTS.md/CLAUDE.md): reject the
  backtick in validateBranchName (covers --default-branch, .gitnexusrc, and the
  origin/HEAD auto-detect via sanitizeDetectedBranch) and strip it at the
  ai-context sink (markdownSafeBranch); reject Markdown-significant chars
  (` * [ ] < >) in the config `name` (it lands in generated bold/code-spans),
  while still allowing `_ . - /`. Corrected the false "can't break the code
  span" comment.
- P2 (configured defaultBranch silently no-ops on an up-to-date repo): on the
  alreadyUpToDate fast path, surgically refresh only the `base_ref:` line in
  AGENTS.md/CLAUDE.md (refreshBaseRefLine), preserving the rest of the block
  incl. --skills community rows; no-op when unchanged.
- P3: gate the .gitnexusrc key lookup with Object.hasOwn so inherited keys
  (__proto__, constructor, …) hit the actionable "Unknown key" error.
- Cleanups: strip a leading UTF-8 BOM before JSON.parse; give --default-branch
  CLI validation its own `default-branch-invalid` recovery hint; drop the dead
  `options.defaultBranch` write and the now-redundant `options?.` chaining.
- Tests: backtick rejection + even-backtick generated output, 255-char branch
  bound, config `name` Markdown rejection, __proto__ → Unknown key, BOM,
  mergeAnalyzeOptions omits defaultBranch, willGenerateContext suppression, and
  the fast-path base_ref refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-06-04 06:48:55 +01:00 • committed by GitHub
parent 295b75d12d
commit fdea46729e
13 changed files with 1458 additions and 68 deletions

View file

@ -226,6 +226,8 @@ gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
gitnexus analyze --skip-skills # Skip installing .claude/skills/gitnexus/ skill files
gitnexus analyze --default-branch develop # Branch used in the generated regression-compare example (base_ref)
gitnexus analyze --skip-git # Index folders that are not Git repositories
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
@ -273,6 +275,36 @@ gitnexus analyze --embeddings 100000
If embeddings are skipped on a large repository, the indexed graph likely exceeds the default safety cap. Re-run with `gitnexus analyze --embeddings 0` to remove the cap, or `gitnexus analyze --embeddings <n>` to choose a higher limit while still keeping memory bounded.
#### Project config (`.gitnexusrc`)
Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `analyze` options per project, instead of re-passing the same flags every run. It is read from the resolved repo root (not `.gitnexus/`, which is gitignored index storage). **CLI flags always override `.gitnexusrc`.**
```jsonc
{
// Default branch used in the generated regression-compare example (base_ref).
// Use this so a project on `develop`/`master` doesn't get "main" rewritten
// over its fix on every analyze. (Alias: "branch".)
"defaultBranch": "develop",
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
"skipSkills": true, // don't install .claude/skills/gitnexus/
"embeddings": true, // generate embeddings by default
"workerTimeout": 60
}
```
A nested `analyze` block is also accepted (and overrides flat keys for the same option):
```json
{ "analyze": { "defaultBranch": "develop", "skipSkills": true } }
```
Notes:
- The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`.
- `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection.
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
#### Environment variables
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.

View file

@ -29,6 +29,12 @@ export interface AIContextOptions {
skipAgentsMd?: boolean;
noStats?: boolean;
skipSkills?: boolean;
/**
* Default branch used by the generated regression-compare example (#243).
* Resolved by the CLI (CLI flag > `.gitnexusrc` > auto-detect > "main"); a
* plain caller that omits it gets "main", preserving prior behavior.
*/
defaultBranch?: string;
}
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
@ -89,6 +95,16 @@ async function findGroupsContainingRegistryName(registryName: string): Promise<s
return hits;
}
/**
* Strip backticks from a branch name before it is embedded in a Markdown
* inline-code span (#1996 tri-review P1). validateBranchName already rejects
* backticks for CLI/config/auto-detect inputs; this is the last-line defense at
* the generation sink so the embedding is provably safe regardless of caller.
*/
export function markdownSafeBranch(branch: string): string {
return branch.replace(/`/g, '');
}
export function generateGitNexusContent(
projectName: string,
stats: RepoStats,
@ -100,6 +116,14 @@ export function generateGitNexusContent(
// index (#1945). Referenced by docs so a single CLI-neutral command resolves
// the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time.
runnerPath: string = '.gitnexus/run.cjs',
// Default branch for the regression-compare example (#243). Configurable so
// projects on `develop`/`master`/etc. don't get `base_ref: "main"` rewritten
// back over their fix on every analyze. The value is embedded inside a
// Markdown inline-code span: validateBranchName rejects backticks upstream,
// and `markdownSafeBranch` strips any remaining backtick here as defense in
// depth, so JSON.stringify's quote/escape handling is sufficient and the
// branch cannot break out of the span (#1996 tri-review P1).
defaultBranch: string = 'main',
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
@ -151,7 +175,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
## Always Do
- **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.
- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows.
- **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))}})\`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
@ -430,6 +454,7 @@ export async function generateAIContextFiles(
options?.noStats,
options?.skipSkills,
runnerPath,
options?.defaultBranch ?? 'main',
);
const createdFiles: string[] = [];
@ -472,3 +497,56 @@ export async function generateAIContextFiles(
return { files: createdFiles };
}
/**
* Refresh only the `base_ref: "..."` value inside the GitNexus block of an
* already-generated AGENTS.md / CLAUDE.md, in place (#1996 tri-review P2).
*
* The `alreadyUpToDate` analyze fast path returns before the normal
* {@link generateAIContextFiles} call, so a changed `.gitnexusrc` defaultBranch
* (or `--default-branch`) would otherwise not take effect until the next
* re-index. This does a surgical line update that preserves the rest of the
* block — including community-skill rows written by a prior `--skills` run —
* rather than regenerating (which would drop those rows on a no-`--skills` run).
*
* Best-effort: missing files, a missing/blank block, or a block with no
* `base_ref` line (e.g. a user-trimmed keep block) are silently skipped. Writes
* only when the value actually changes, so a routine up-to-date run is a no-op.
*/
export async function refreshBaseRefLine(
repoPath: string,
defaultBranch: string,
options?: { skipAgentsMd?: boolean },
): Promise<{ files: string[] }> {
if (options?.skipAgentsMd) return { files: [] };
const replacement = `base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}`;
const updated: string[] = [];
for (const name of ['AGENTS.md', 'CLAUDE.md']) {
const filePath = path.join(repoPath, name);
if (!(await fileExists(filePath))) continue;
let content: string;
try {
content = await fs.readFile(filePath, 'utf-8');
} catch {
continue;
}
const startIdx = findSectionMarkerIndex(content, GITNEXUS_START_MARKER);
if (startIdx === -1) continue;
const endIdx = findSectionMarkerIndex(content, GITNEXUS_END_MARKER, startIdx);
if (endIdx === -1 || endIdx <= startIdx) continue;
const blockEnd = endIdx + GITNEXUS_END_MARKER.length;
const block = content.substring(startIdx, blockEnd);
// Only the generated regression example carries a base_ref line, and only
// one per block; replace its quoted value while leaving the rest untouched.
const newBlock = block.replace(/base_ref: "(?:[^"\\]|\\.)*"/, replacement);
if (newBlock === block) continue; // no base_ref line present, or already current
const newContent = content.substring(0, startIdx) + newBlock + content.substring(blockEnd);
try {
await fs.writeFile(filePath, newContent, 'utf-8');
updated.push(name);
} catch {
// best-effort — never fail analyze over a context refresh
}
}
return { files: updated };
}

View file

@ -0,0 +1,426 @@
/**
* Project-local `.gitnexusrc` support for `gitnexus analyze` (#243).
*
* Lets a repository commit recurring `analyze` defaults — default branch for the
* generated regression example, AI-context / skills opt-outs, embedding knobs —
* so contributors don't re-pass the same flags on every run. Design rules:
*
* - Config is repo-local (`.gitnexusrc` at the resolved repo root). It is NOT
* read from `.gitnexus/` because that directory is index storage and is
* commonly gitignored.
* - JSON only. No YAML, no `package.json` field, no global `~/.gitnexus` file
* in this pass.
* - CLI flags always override config (see {@link mergeAnalyzeOptions}).
* - Fail closed: unknown keys, wrong value types, conflicting aliases, and
* invalid JSON all throw {@link GitNexusRcError} so a typo never silently
* no-ops. Errors are actionable and surface before any expensive analysis.
* - Config values never reach a shell. Strings are validated against control
* and hidden/bidirectional characters so they cannot inject markdown,
* JSON, or hidden controls into generated context files.
*
* Both a flat shape and a nested `analyze` block are accepted:
*
* { "defaultBranch": "develop", "skipContextFiles": true }
* { "analyze": { "defaultBranch": "develop", "skipSkills": true } }
*
* When both set the same option, the nested `analyze` block wins (deterministic,
* documented precedence). Conflicting *aliases at the same level* (e.g. both
* `defaultBranch` and `branch`) are rejected.
*/
import fs from 'node:fs';
import path from 'node:path';
import type { AnalyzeOptions } from './analyze.js';
export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
/** Final fallback when no branch is configured or detectable. */
export const DEFAULT_BRANCH_FALLBACK = 'main';
/** Git refs longer than this are almost certainly a mistake / injection attempt. */
const BRANCH_MAX_LENGTH = 255;
/**
* Thrown for any `.gitnexusrc` problem (missing-file is NOT an error — it
* returns `undefined`). The message is user-facing and names the file so the
* CLI can print it verbatim before starting the progress bar.
*/
export class GitNexusRcError extends Error {
constructor(message: string) {
super(message);
this.name = 'GitNexusRcError';
}
}
type ValueKind =
| 'boolean'
| 'boolean-negate'
| 'string'
| 'numeric-string'
| 'embeddings'
| 'branch';
interface KeySpec {
/** The `AnalyzeOptions` field this config key normalizes into. */
target: keyof AnalyzeOptions;
kind: ValueKind;
}
/**
* Allowed `.gitnexusrc` keys and how each maps onto `AnalyzeOptions`.
*
* Aliases intentionally collapse onto a shared target:
* - `branch` is the legacy alias for `defaultBranch` (issue-comment shape).
* - `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they
* suppress the AGENTS.md / CLAUDE.md block ONLY. They do not imply
* `skipSkills`, and they are weaker than `indexOnly` (which skips all
* file injection). This matches the existing CLI semantics exactly.
* - `noStats` is the negation of `stats`.
*/
const KEY_SPECS: Record<string, KeySpec> = {
defaultBranch: { target: 'defaultBranch', kind: 'branch' },
branch: { target: 'defaultBranch', kind: 'branch' },
skipAgentsMd: { target: 'skipAgentsMd', kind: 'boolean' },
skipContextFiles: { target: 'skipAgentsMd', kind: 'boolean' },
skipAiContext: { target: 'skipAgentsMd', kind: 'boolean' },
skipSkills: { target: 'skipSkills', kind: 'boolean' },
indexOnly: { target: 'indexOnly', kind: 'boolean' },
stats: { target: 'stats', kind: 'boolean' },
noStats: { target: 'stats', kind: 'boolean-negate' },
embeddings: { target: 'embeddings', kind: 'embeddings' },
dropEmbeddings: { target: 'dropEmbeddings', kind: 'boolean' },
name: { target: 'name', kind: 'string' },
allowDuplicateName: { target: 'allowDuplicateName', kind: 'boolean' },
maxFileSize: { target: 'maxFileSize', kind: 'numeric-string' },
workerTimeout: { target: 'workerTimeout', kind: 'numeric-string' },
walCheckpointThreshold: { target: 'walCheckpointThreshold', kind: 'numeric-string' },
workers: { target: 'workers', kind: 'numeric-string' },
embeddingThreads: { target: 'embeddingThreads', kind: 'numeric-string' },
embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
};
/** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
const NESTED_KEY = 'analyze';
const ALLOWED_KEYS_HINT = `Allowed keys: ${Object.keys(KEY_SPECS).join(', ')} (or a nested "${NESTED_KEY}" object).`;
/**
* Reject control characters and hidden / bidirectional Unicode in a string
* value. These have no legitimate place in a branch name, registry name, or
* device string, and would otherwise let a committed config smuggle invisible
* controls into generated AGENTS.md / CLAUDE.md content.
*/
const isHiddenOrControl = (codePoint: number): boolean =>
codePoint < 0x20 ||
codePoint === 0x7f ||
(codePoint >= 0x200b && codePoint <= 0x200f) || // zero-width + LRM/RLM
(codePoint >= 0x202a && codePoint <= 0x202e) || // bidi embeddings/overrides
(codePoint >= 0x2060 && codePoint <= 0x2064) || // word-joiner + invisible math
(codePoint >= 0x2066 && codePoint <= 0x206f) || // bidi isolates + deprecated
codePoint === 0xfeff; // BOM / zero-width no-break space
const assertNoHiddenChars = (value: string, source: string): void => {
for (const ch of value) {
const cp = ch.codePointAt(0);
if (cp !== undefined && isHiddenOrControl(cp)) {
throw new GitNexusRcError(
`${source}: value contains control or hidden/bidirectional characters, which are not allowed.`,
);
}
}
};
/**
* Validate a user-supplied branch name (from CLI or `.gitnexusrc`). Returns the
* trimmed name or throws {@link GitNexusRcError}. Conservative but accepts the
* shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
*/
export function validateBranchName(value: string, source: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new GitNexusRcError(`${source}: branch name must not be empty.`);
}
if (trimmed.length > BRANCH_MAX_LENGTH) {
throw new GitNexusRcError(`${source}: branch name is too long (max ${BRANCH_MAX_LENGTH}).`);
}
assertNoHiddenChars(trimmed, source);
if (/\s/.test(trimmed)) {
throw new GitNexusRcError(`${source}: branch name must not contain whitespace.`);
}
// git ref-name rules (subset): reject characters git itself forbids in refs.
if (/[~^:?*[\\]/.test(trimmed)) {
throw new GitNexusRcError(
`${source}: branch name contains characters not allowed in a git ref (~ ^ : ? * [ \\).`,
);
}
if (trimmed.startsWith('-')) {
throw new GitNexusRcError(`${source}: branch name must not start with "-".`);
}
if (trimmed.includes('..')) {
throw new GitNexusRcError(`${source}: branch name must not contain "..".`);
}
// Git permits a backtick in a ref, but the branch is embedded inside a
// Markdown inline-code span in the generated AGENTS.md/CLAUDE.md regression
// example, where a backtick would close the span early and let the rest of
// the template render as instruction text. Reject it at this single
// chokepoint so all three tiers (CLI flag, .gitnexusrc, auto-detect via
// sanitizeDetectedBranch) are covered (#1996 tri-review P1).
if (trimmed.includes('`')) {
throw new GitNexusRcError(
`${source}: branch name must not contain a backtick (it would break the generated Markdown).`,
);
}
return trimmed;
}
/**
* Best-effort validation for an auto-detected branch (from git). Never throws —
* returns `undefined` for anything unusable so the resolver falls back to the
* next precedence tier.
*/
export function sanitizeDetectedBranch(value: string | null | undefined): string | undefined {
if (!value) return undefined;
try {
return validateBranchName(value, 'detected branch');
} catch {
return undefined;
}
}
const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown => {
const source = `${GITNEXUS_RC_FILENAME} "${key}"`;
switch (kind) {
case 'boolean':
if (typeof value !== 'boolean') {
throw new GitNexusRcError(`${source} must be a boolean (true/false).`);
}
return value;
case 'boolean-negate':
if (typeof value !== 'boolean') {
throw new GitNexusRcError(`${source} must be a boolean (true/false).`);
}
return !value;
case 'branch':
if (typeof value !== 'string') {
throw new GitNexusRcError(`${source} must be a string branch name.`);
}
return validateBranchName(value, source);
case 'string': {
if (typeof value !== 'string') {
throw new GitNexusRcError(`${source} must be a string.`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new GitNexusRcError(`${source} must not be empty.`);
}
assertNoHiddenChars(trimmed, source);
// `name` flows into the generated AGENTS.md/CLAUDE.md as `**${name}**` and
// inside `gitnexus://repo/${name}/…` code spans, so a Markdown-significant
// character would break those spans or inject emphasis/links/HTML into
// agent-instruction content (#1996 tri-review P1). `_` is intentionally
// allowed (legitimate in repo names; intraword `_` is not emphasis).
// embeddingDevice (the other `string`-kind option) only ever holds a
// fixed device token, so this guard never rejects a valid value there.
if (/[`*[\]<>]/.test(trimmed)) {
throw new GitNexusRcError(
`${source} must not contain Markdown-significant characters (\` * [ ] < >).`,
);
}
return trimmed;
}
case 'numeric-string': {
// Mirror Commander's contract: these options reach the existing CLI
// validation as strings. Accept a JSON number or a string; normalize to a
// string and let the downstream per-flag validation enforce ranges so the
// error messages stay in one place.
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new GitNexusRcError(`${source} must be a finite number.`);
}
return String(value);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
throw new GitNexusRcError(`${source} must not be empty.`);
}
return trimmed;
}
throw new GitNexusRcError(`${source} must be a number or numeric string.`);
}
case 'embeddings': {
// Mirror `--embeddings [limit]`: boolean toggles, a non-negative integer
// sets the node cap (normalized to a string, as Commander would supply).
if (typeof value === 'boolean') return value;
if (typeof value === 'number') {
if (!Number.isInteger(value) || value < 0) {
throw new GitNexusRcError(
`${source} must be true/false or a non-negative integer (node cap; 0 disables the cap).`,
);
}
return String(value);
}
throw new GitNexusRcError(
`${source} must be a boolean or a non-negative integer (node cap; 0 disables the cap).`,
);
}
default:
// Exhaustive — kept for forward-compat if a new kind is added.
throw new GitNexusRcError(`${source}: unsupported config value kind.`);
}
};
/**
* Normalize one level (flat top-level or the nested `analyze` block) into a
* partial `AnalyzeOptions`. Rejects unknown keys and two aliases that configure
* the same option at the same level.
*/
const normalizeLevel = (
obj: Record<string, unknown>,
{ allowNestedKey }: { allowNestedKey: boolean },
): Partial<AnalyzeOptions> => {
const out: Partial<AnalyzeOptions> = {};
const setBy = new Map<keyof AnalyzeOptions, string>();
for (const [key, value] of Object.entries(obj)) {
if (allowNestedKey && key === NESTED_KEY) continue; // handled separately
// `Object.hasOwn`, not a truthiness check: a plain-object lookup like
// `KEY_SPECS["__proto__"]` returns an inherited member (Object.prototype,
// truthy) and would slip past `if (!spec)`, hitting the wrong error branch
// instead of the documented "Unknown key" message (#1996 tri-review P3).
if (!Object.hasOwn(KEY_SPECS, key)) {
throw new GitNexusRcError(
`Unknown key "${key}" in ${GITNEXUS_RC_FILENAME}. ${ALLOWED_KEYS_HINT}`,
);
}
const spec = KEY_SPECS[key];
const prev = setBy.get(spec.target);
if (prev && prev !== key) {
throw new GitNexusRcError(
`${GITNEXUS_RC_FILENAME}: "${prev}" and "${key}" both configure the same option; set only one.`,
);
}
setBy.set(spec.target, key);
(out as Record<string, unknown>)[spec.target] = normalizeValue(spec.kind, value, key);
}
return out;
};
/**
* Locate, read, parse, validate, and normalize `.gitnexusrc` at `repoRoot`.
*
* @returns the normalized config defaults, or `undefined` when no file exists
* (the normal case). Throws {@link GitNexusRcError} on any problem.
*/
export function loadAnalyzeConfig(repoRoot: string): Partial<AnalyzeOptions> | undefined {
const filePath = path.join(repoRoot, GITNEXUS_RC_FILENAME);
let raw: string;
try {
raw = fs.readFileSync(filePath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined;
throw new GitNexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${(err as Error).message}`);
}
// Strip a leading UTF-8 BOM: Node's 'utf-8' decode keeps it, and JSON.parse
// then fails with a confusing "Unexpected token" on an otherwise-valid file
// (#1996 tri-review). Only one leading BOM is stripped; in-string control
// rejection still applies to the parsed values.
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new GitNexusRcError(
`${GITNEXUS_RC_FILENAME} is not valid JSON: ${(err as Error).message}. ` +
`Expected a JSON object such as {"defaultBranch": "develop", "skipContextFiles": true}.`,
);
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} must contain a JSON object.`);
}
const obj = parsed as Record<string, unknown>;
const flat = normalizeLevel(obj, { allowNestedKey: true });
let nested: Partial<AnalyzeOptions> = {};
if (Object.prototype.hasOwnProperty.call(obj, NESTED_KEY)) {
const nestedRaw = obj[NESTED_KEY];
if (nestedRaw === null || typeof nestedRaw !== 'object' || Array.isArray(nestedRaw)) {
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} "${NESTED_KEY}" must be a JSON object.`);
}
nested = normalizeLevel(nestedRaw as Record<string, unknown>, { allowNestedKey: false });
}
// Nested `analyze` block overrides flat top-level keys for the same option.
return { ...flat, ...nested };
}
/**
* Merge CLI options over `.gitnexusrc` defaults. CLI wins whenever it provides a
* value — including an explicit `false` (so an explicit CLI off-switch beats a
* config `true`). Config fills only the genuinely-unset (`undefined`) options.
*
* `stats` is special: Commander always materializes it (`true` by default,
* `false` only when `--no-stats` is passed), so plain `??` can't tell "default
* on" from "explicitly on". The rule is: a passed `--no-stats` (`stats: false`)
* always wins; otherwise the config value applies. There is intentionally no
* `--stats` counter-flag, so config can only turn stats off, not force it back
* on against a `--no-stats`.
*
* `defaultBranch` is NOT resolved here — see {@link resolveDefaultBranch}, which
* applies the CLI > config > auto-detect > "main" precedence chain.
*/
export function mergeAnalyzeOptions(
cli: AnalyzeOptions,
config: Partial<AnalyzeOptions> | undefined,
): AnalyzeOptions {
if (!config) return cli;
const merged: AnalyzeOptions = { ...cli };
for (const key of Object.keys(config) as (keyof AnalyzeOptions)[]) {
if (key === 'stats' || key === 'defaultBranch') continue; // handled below / by resolver
if (merged[key] === undefined) {
(merged as Record<string, unknown>)[key] = config[key];
}
}
if (config.stats !== undefined && cli.stats !== false) {
merged.stats = config.stats;
}
return merged;
}
/**
* Resolve the default branch threaded into generated context, applying the
* precedence chain:
*
* CLI `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch`
* > auto-detected `origin/HEAD` > {@link DEFAULT_BRANCH_FALLBACK} ("main").
*
* User-supplied values (CLI, config) are validated strictly and throw on bad
* input. The auto-detected value is best-effort and silently ignored if
* unusable.
*/
export function resolveDefaultBranch(input: {
cliBranch?: string;
configBranch?: string;
detectedBranch?: string | null;
}): string {
if (input.cliBranch !== undefined) {
return validateBranchName(input.cliBranch, '--default-branch');
}
if (input.configBranch !== undefined) {
return validateBranchName(input.configBranch, `${GITNEXUS_RC_FILENAME} "defaultBranch"`);
}
const detected = sanitizeDetectedBranch(input.detectedBranch);
if (detected) return detected;
return DEFAULT_BRANCH_FALLBACK;
}

View file

@ -26,7 +26,14 @@ import {
AnalysisNotFinalizedError,
assertAnalysisFinalized,
} from '../storage/repo-manager.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import { getGitRoot, hasGitDir, getDefaultBranch } from '../storage/git.js';
import {
loadAnalyzeConfig,
mergeAnalyzeOptions,
resolveDefaultBranch,
validateBranchName,
GitNexusRcError,
} from './analyze-config.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import { warnMissingOptionalGrammars } from './optional-grammars.js';
@ -555,6 +562,13 @@ export interface AnalyzeOptions {
stats?: boolean;
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
skipSkills?: boolean;
/**
* Default branch for the generated regression-compare example (#243). From
* `--default-branch`; may also be supplied via `.gitnexusrc`. Resolved to a
* concrete branch (CLI > `.gitnexusrc` > auto-detected origin/HEAD > "main")
* before being threaded into the generated AGENTS.md / CLAUDE.md content.
*/
defaultBranch?: string;
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
indexOnly?: boolean;
/** Index the folder even when no .git directory is present. */
@ -639,16 +653,116 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
}
};
const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): Promise<void> => {
if (options?.verbose) {
const analyzeCommandImpl = async (
inputPath?: string,
cliOptions?: AnalyzeOptions,
): Promise<void> => {
console.log('\n GitNexus Analyzer\n');
// ── Resolve the target repo root ──────────────────────────────────
// Resolved FIRST because `.gitnexusrc` is read from the repo root (not the
// caller's cwd), and config can set defaults that the validation below
// consumes. `--skip-git` is a CLI-only flag (never a config key), so the raw
// CLI options are authoritative for repo-root resolution.
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
} else if (cliOptions?.skipGit) {
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
repoPath = path.resolve(process.cwd());
} else {
const gitRoot = getGitRoot(process.cwd());
if (!gitRoot) {
console.log(
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
);
process.exitCode = 1;
return;
}
repoPath = gitRoot;
}
const repoHasGit = hasGitDir(repoPath);
if (!repoHasGit && !cliOptions?.skipGit) {
console.log(
' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
);
process.exitCode = 1;
return;
}
if (!repoHasGit) {
console.log(
' Warning: no .git directory found — commit-tracking and incremental updates disabled.\n',
);
}
// Validate an explicit `--default-branch` up front so its errors are
// attributed to the flag (with a CLI-specific recovery hint) rather than to
// `.gitnexusrc`, which the user may not even have (#1996 tri-review).
if (cliOptions?.defaultBranch !== undefined) {
try {
validateBranchName(cliOptions.defaultBranch, '--default-branch');
} catch (err) {
cliError(` ${err instanceof Error ? err.message : String(err)}\n`, {
recoveryHint: 'default-branch-invalid',
});
process.exitCode = 1;
return;
}
}
// ── Load .gitnexusrc and merge: CLI flags override config (#243) ───
// Parse/validate before the progress bar so a malformed config produces an
// actionable error and exits before any expensive analysis starts.
let options: AnalyzeOptions;
let resolvedDefaultBranch: string;
try {
const fileConfig = loadAnalyzeConfig(repoPath);
options = mergeAnalyzeOptions(cliOptions ?? {}, fileConfig);
// Resolve the default branch threaded into generated context:
// CLI --default-branch > .gitnexusrc defaultBranch/branch
// > auto-detected origin/HEAD > "main".
// Only shell out to git when no branch was configured AND the generated
// context will actually use it, keeping the common path free of an extra
// git call. Detection is best-effort and never blocks analyze.
const cliBranch = cliOptions?.defaultBranch;
const configBranch = fileConfig?.defaultBranch;
const willGenerateContext = !options.indexOnly && !options.skipAgentsMd;
let detectedBranch: string | null = null;
if (
cliBranch === undefined &&
configBranch === undefined &&
repoHasGit &&
!cliOptions?.skipGit &&
willGenerateContext
) {
try {
detectedBranch = getDefaultBranch(repoPath);
} catch {
detectedBranch = null;
}
}
resolvedDefaultBranch = resolveDefaultBranch({ cliBranch, configBranch, detectedBranch });
} catch (err) {
const msg =
err instanceof GitNexusRcError
? err.message
: `Invalid .gitnexusrc: ${err instanceof Error ? err.message : String(err)}`;
cliError(` ${msg}\n`, { recoveryHint: 'gitnexusrc-invalid' });
process.exitCode = 1;
return;
}
if (options.verbose) {
process.env.GITNEXUS_VERBOSE = '1';
}
if (options?.maxFileSize) {
if (options.maxFileSize) {
process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
}
if (options?.workerTimeout) {
if (options.workerTimeout) {
const workerTimeoutSeconds = Number(options.workerTimeout);
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
cliError(' --worker-timeout must be at least 1 second.\n');
@ -660,7 +774,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
);
}
if (options?.walCheckpointThreshold !== undefined) {
if (options.walCheckpointThreshold !== undefined) {
const parsed = parseWalCheckpointThreshold(options.walCheckpointThreshold);
if (parsed === undefined) {
cliError(' --wal-checkpoint-threshold must be an integer >= -1.\n');
@ -677,7 +791,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
// values back-to-back and observe the value they passed, not whatever the
// previous call leaked.
let workerPoolSize: number | undefined;
if (options?.workers !== undefined) {
if (options.workers !== undefined) {
const parsedWorkers = Number(options.workers);
if (!Number.isInteger(parsedWorkers) || parsedWorkers < 0) {
cliError(
@ -695,7 +809,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
// sibling-validation pattern (exit before bar.start() — otherwise
// process.exit() leaves the progress bar's hidden cursor uncleared).
let embeddingsNodeLimit: number | undefined;
if (typeof options?.embeddings === 'string') {
if (typeof options.embeddings === 'string') {
const parsed = Number(options.embeddings);
if (!Number.isInteger(parsed) || parsed < 0) {
cliError(
@ -707,7 +821,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
}
embeddingsNodeLimit = parsed;
}
const embeddingsEnabled = !!options?.embeddings;
const embeddingsEnabled = !!options.embeddings;
const setPositiveEnv = (
optionName: string,
@ -729,23 +843,23 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
!setPositiveEnv(
'--embedding-threads',
'GITNEXUS_EMBEDDING_THREADS',
options?.embeddingThreads,
options.embeddingThreads,
) ||
!setPositiveEnv(
'--embedding-batch-size',
'GITNEXUS_EMBEDDING_BATCH_SIZE',
options?.embeddingBatchSize,
options.embeddingBatchSize,
) ||
!setPositiveEnv(
'--embedding-sub-batch-size',
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
options?.embeddingSubBatchSize,
options.embeddingSubBatchSize,
)
) {
return;
}
if (options?.embeddingDevice) {
if (options.embeddingDevice) {
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
if (!allowed.has(options.embeddingDevice)) {
cliError(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
@ -755,7 +869,7 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
}
if (options?.repairFts && options?.force) {
if (options.repairFts && options.force) {
cliError(
' Cannot combine `--repair-fts` with `--force`. ' +
'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n',
@ -764,52 +878,18 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
return;
}
console.log('\n GitNexus Analyzer\n');
// `--index-only` is the stronger contract — it suppresses every form of file
// injection, including community skill writes that `--skills` would normally
// produce. Surface the override explicitly so users don't wonder why a
// pipeline re-index ran but no skill files appeared. The pipeline still
// re-runs (see `force: options?.force || options?.skills` below); the warning
// re-runs (see `force: options.force || options.skills` below); the warning
// is purely about the dropped post-index write step.
if (options?.indexOnly && options?.skills) {
if (options.indexOnly && options.skills) {
console.log(
' Note: --index-only overrides --skills; community skill files will not be written.\n',
);
}
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
} else if (options?.skipGit) {
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
repoPath = path.resolve(process.cwd());
} else {
const gitRoot = getGitRoot(process.cwd());
if (!gitRoot) {
console.log(
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
);
process.exitCode = 1;
return;
}
repoPath = gitRoot;
}
const repoHasGit = hasGitDir(repoPath);
if (!repoHasGit && !options?.skipGit) {
console.log(
' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
);
process.exitCode = 1;
return;
}
if (!repoHasGit) {
console.log(
' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n',
);
}
// If the target repo contains files an optional grammar would parse but
// that grammar's native binding is absent, warn before analysis so users
// learn why those files end up unparsed instead of silently getting a
@ -939,36 +1019,39 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
// ── Run shared analysis orchestrator ───────────────────────────────
try {
const skipAll = options?.indexOnly;
const skipAgentsMd = skipAll || options?.skipAgentsMd;
const skipSkills = skipAll || options?.skipSkills;
const skipAll = options.indexOnly;
const skipAgentsMd = skipAll || options.skipAgentsMd;
const skipSkills = skipAll || options.skipSkills;
const result = await runFullAnalysis(
repoPath,
{
// Pipeline re-index — OR'd with --skills because skill generation
// needs a fresh pipelineResult. Has no bearing on the registry
// collision guard (see allowDuplicateName below).
force: options?.force || options?.skills,
repairFts: options?.repairFts,
force: options.force || options.skills,
repairFts: options.repairFts,
embeddings: embeddingsEnabled,
embeddingsNodeLimit,
dropEmbeddings: options?.dropEmbeddings,
verbose: options?.verbose,
skipGit: options?.skipGit,
dropEmbeddings: options.dropEmbeddings,
verbose: options.verbose,
skipGit: options.skipGit,
skipAgentsMd,
skipSkills,
// Resolved default branch (CLI > .gitnexusrc > auto-detect > "main")
// threaded into the generated regression-compare example (#243).
defaultBranch: resolvedDefaultBranch,
// commander.js `.option('--no-stats', …)` registers the flag as
// `options.stats` (boolean, default true; `false` when the user
// passed --no-stats). Reading `options?.noStats` here returns
// passed --no-stats). Reading `options.noStats` here returns
// undefined every time, so the flag was a no-op on the markdown
// rewrite path before this fix. See #1477.
noStats: options?.stats === false,
registryName: options?.name,
noStats: options.stats === false,
registryName: options.name,
// Registry-collision bypass — its own CLI flag, intentionally NOT
// overloading --force. A user who hits the collision guard should
// be able to accept the duplicate name without also paying the
// cost of a full pipeline re-index. See #829 review round 2.
allowDuplicateName: options?.allowDuplicateName,
allowDuplicateName: options.allowDuplicateName,
// Worker pool size threaded from --workers, replacing the previous
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
// env / auto-formula fallback inside the pipeline.
@ -988,6 +1071,21 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
// that half-finalized state, runFullAnalysis returns alreadyUpToDate
// on the next invocation unless we check the registry here too.
await assertAnalysisFinalized(repoPath);
// The fast path skips context regeneration, but a changed `.gitnexusrc`
// defaultBranch / `--default-branch` must still take effect. Surgically
// refresh just the `base_ref` line in AGENTS.md/CLAUDE.md in place,
// preserving the rest of the block (incl. --skills community rows). No-op
// when the value already matches, so a routine up-to-date run is silent
// (#1996 tri-review P2).
let baseRefRefreshed: string[] = [];
try {
const { refreshBaseRefLine } = await import('./ai-context.js');
baseRefRefreshed = (
await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })
).files;
} catch {
/* best-effort — never fail the fast path over a context refresh */
}
clearInterval(elapsedTimer);
process.removeListener('SIGINT', sigintHandler);
console.log = origLog;
@ -997,6 +1095,11 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
console.error = origError;
bar.stop();
console.log(' Already up to date\n');
if (baseRefRefreshed.length > 0) {
console.log(
` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`,
);
}
// Safe to return without process.exit(0) — the early-return path in
// runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
return;
@ -1070,9 +1173,13 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
{
skipAgentsMd,
skipSkills,
// Same resolved branch as the main run (#243) so the --skills
// re-generation of AGENTS.md/CLAUDE.md does not revert base_ref
// to "main".
defaultBranch: resolvedDefaultBranch,
// Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
// exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
noStats: options?.stats === false,
noStats: options.stats === false,
},
);
}

View file

@ -52,7 +52,9 @@ export type RecoveryHint =
| 'local-embedding-unsupported'
| 'large-repo'
| 'npm-resolution'
| 'module-not-found';
| 'module-not-found'
| 'gitnexusrc-invalid'
| 'default-branch-invalid';
/**
* Common shape for the optional structured-field bag passed to

View file

@ -44,6 +44,11 @@ program
'(no-op when --index-only is also set).',
)
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option(
'--default-branch <branch>',
'Default branch used in the generated regression-compare example (base_ref). ' +
'Falls back to .gitnexusrc, then auto-detected origin/HEAD, then "main".',
)
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option(
'--skip-skills',

View file

@ -106,6 +106,13 @@ export interface AnalyzeOptions {
noStats?: boolean;
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
skipSkills?: boolean;
/**
* Default branch threaded into generated AGENTS.md / CLAUDE.md so the
* regression-compare example uses the configured branch instead of a
* hardcoded "main" (#243). Resolved by the CLI; `undefined` here keeps the
* "main" fallback for non-CLI callers (e.g. the server analyze worker).
*/
defaultBranch?: string;
/**
* User-provided alias for the registry `name` (#829). When set,
* forwarded to `registerRepo` so the indexed repo is stored under
@ -1027,6 +1034,7 @@ export async function runFullAnalysis(
skipAgentsMd: options.skipAgentsMd,
skipSkills: options.skipSkills,
noStats: options.noStats,
defaultBranch: options.defaultBranch,
},
);
} catch {

View file

@ -263,6 +263,35 @@ export const getRemoteOriginUrl = (repoPath: string): string | null => {
}
};
/**
* Best-effort detection of the repository's default branch (#243).
*
* Reads `git symbolic-ref --short refs/remotes/origin/HEAD`, which resolves to
* the short ref `origin/<branch>` that the local `origin/HEAD` points at, and
* strips the `origin/` prefix. This is a purely local lookup — it never makes a
* network call. Returns `null` when there is no git repo, no `origin` remote, no
* `origin/HEAD` (e.g. it was never set by clone, or the repo is detached), or
* git is unavailable, so callers can fall back to a configured/default branch.
*/
export const getDefaultBranch = (repoPath: string): string | null => {
try {
const ref = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
cwd: repoPath,
// Suppress stderr -- see getCurrentCommit comment and #1172. Without it,
// git prints "fatal: ref refs/remotes/origin/HEAD is not a symbolic ref"
// to the user's terminal on repos that never set origin/HEAD.
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
})
.toString()
.trim();
if (!ref) return null;
return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref;
} catch {
return null;
}
};
/**
* Sanitize a repository name to prevent argument injection and ensure
* cross-platform filesystem compatibility.

View file

@ -2,7 +2,12 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { generateAIContextFiles, generateGitNexusContent } from '../../src/cli/ai-context.js';
import {
generateAIContextFiles,
generateGitNexusContent,
refreshBaseRefLine,
markdownSafeBranch,
} from '../../src/cli/ai-context.js';
describe('generateAIContextFiles', () => {
let tmpDir: string;
@ -210,9 +215,14 @@ describe('generateAIContextFiles', () => {
it('keeps the CLAUDE.md GitNexus block under the token-cost budget (#856)', async () => {
// The pre-trim block was ~5465 chars. After #856 it's ~2580 — about a
// 52% reduction. 2700 is a soft ceiling that still leaves headroom for
// 52% reduction. The ceiling is a soft cap that still leaves headroom for
// legitimate future additions but will fail loudly if the trim is
// reverted or someone pads the block back out toward the original size.
//
// Raised 2700 → 2900 for #243: the regression-compare example (one
// load-bearing per-repo `base_ref` line on the detect_changes bullet) is a
// legitimate addition, not a revert of the trim — the block stays roughly
// half the original size.
const stats = { nodes: 50, edges: 100, processes: 5 };
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
@ -221,7 +231,7 @@ describe('generateAIContextFiles', () => {
content.indexOf('<!-- gitnexus:start -->'),
content.indexOf('<!-- gitnexus:end -->'),
);
expect(block.length).toBeLessThan(2700);
expect(block.length).toBeLessThan(2900);
});
it('handles empty stats', async () => {
@ -876,4 +886,151 @@ Indexed as **placeholder** (1 symbols, 1 relationships, 1 execution flows). Cust
await fs.rm(dir, { recursive: true, force: true });
}
});
// ──────────────────────────────────────────────────────────────────
// Configurable default branch in the regression example (#243)
// ──────────────────────────────────────────────────────────────────
it('generated regression-compare example uses the configured default branch (#243)', () => {
const stats = { nodes: 50, edges: 100, processes: 5 };
const develop = generateGitNexusContent(
'P',
stats,
undefined,
undefined,
undefined,
undefined,
undefined,
'develop',
);
expect(develop).toContain('base_ref: "develop"');
expect(develop).not.toContain('base_ref: "main"');
});
it('defaults the regression-compare example to "main" when no branch is configured (#243)', () => {
const content = generateGitNexusContent('P', { nodes: 50, edges: 100, processes: 5 });
expect(content).toContain('base_ref: "main"');
});
it('JSON-escapes a markdown/quote-bearing branch so it cannot break the code span (#243)', () => {
// A branch name with a double-quote must be JSON-escaped, not concatenated
// raw, so it stays inside the inline code span.
const content = generateGitNexusContent(
'P',
{ nodes: 1 },
undefined,
undefined,
undefined,
undefined,
undefined,
'we"ird',
);
expect(content).toContain('base_ref: "we\\"ird"');
});
it('a backtick branch cannot break the generated Markdown code span (#1996 P1)', () => {
// The branch is embedded inside a backtick inline-code span; a stray
// backtick would close it early. markdownSafeBranch strips it at the sink.
const content = generateGitNexusContent(
'P',
{ nodes: 1 },
undefined,
undefined,
undefined,
undefined,
undefined,
'main`evil',
);
const line = content.split('\n').find((l) => l.includes('base_ref'))!;
// Even backtick count ⇒ every span is balanced (the regression line opens
// and closes exactly one).
expect((line.match(/`/g) || []).length % 2).toBe(0);
expect(line).not.toContain('main`evil');
expect(markdownSafeBranch('a`b`c')).toBe('abc');
});
it('refreshBaseRefLine updates base_ref in place, preserving the rest of the block (#1996 P2)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-baseref-'));
try {
// Seed a realistic block: a configured base_ref "main" plus a community
// skill row that a prior --skills run would have written.
const seed = `# Project
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
- run \`gitnexus_detect_changes({scope: "compare", base_ref: "main"})\`.
| Task | Read this skill file |
|------|---------------------|
| Work in the Auth area (40 symbols) | \`.claude/skills/generated/auth/SKILL.md\` |
<!-- gitnexus:end -->
`;
for (const f of ['AGENTS.md', 'CLAUDE.md']) {
await fs.writeFile(path.join(dir, f), seed, 'utf-8');
}
const res = await refreshBaseRefLine(dir, 'develop');
expect(res.files.sort()).toEqual(['AGENTS.md', 'CLAUDE.md']);
for (const f of ['AGENTS.md', 'CLAUDE.md']) {
const after = await fs.readFile(path.join(dir, f), 'utf-8');
expect(after).toContain('base_ref: "develop"');
expect(after).not.toContain('base_ref: "main"');
// The community-skill row (and everything else) is preserved.
expect(after).toContain('.claude/skills/generated/auth/SKILL.md');
}
// Idempotent: a second run with the same branch writes nothing.
const again = await refreshBaseRefLine(dir, 'develop');
expect(again.files).toEqual([]);
// skipAgentsMd short-circuits entirely.
const skipped = await refreshBaseRefLine(dir, 'master', { skipAgentsMd: true });
expect(skipped.files).toEqual([]);
expect(await fs.readFile(path.join(dir, 'AGENTS.md'), 'utf-8')).toContain(
'base_ref: "develop"',
);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('refreshBaseRefLine is a no-op when there is no base_ref line or no file (#1996 P2)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-baseref-noop-'));
try {
// No AGENTS.md/CLAUDE.md at all → no files updated, no throw.
expect((await refreshBaseRefLine(dir, 'develop')).files).toEqual([]);
// A keep-style block with no base_ref line is left untouched.
const seed = `<!-- gitnexus:start -->
<!-- gitnexus:keep -->
Indexed as **P**. Custom.
<!-- gitnexus:end -->
`;
await fs.writeFile(path.join(dir, 'CLAUDE.md'), seed, 'utf-8');
expect((await refreshBaseRefLine(dir, 'develop')).files).toEqual([]);
expect(await fs.readFile(path.join(dir, 'CLAUDE.md'), 'utf-8')).toBe(seed);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('threads defaultBranch through generateAIContextFiles into AGENTS.md and CLAUDE.md (#243)', async () => {
const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-default-branch-'));
const subStorage = path.join(subDir, '.gitnexus');
await fs.mkdir(subStorage, { recursive: true });
try {
const stats = { nodes: 50, edges: 100, processes: 5 };
await generateAIContextFiles(subDir, subStorage, 'P', stats, undefined, {
defaultBranch: 'release/1.0',
});
for (const f of ['CLAUDE.md', 'AGENTS.md']) {
const content = await fs.readFile(path.join(subDir, f), 'utf-8');
expect(content).toContain('base_ref: "release/1.0"');
expect(content).not.toContain('base_ref: "main"');
}
} finally {
await fs.rm(subDir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,276 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import {
loadAnalyzeConfig,
mergeAnalyzeOptions,
resolveDefaultBranch,
validateBranchName,
sanitizeDetectedBranch,
GitNexusRcError,
GITNEXUS_RC_FILENAME,
DEFAULT_BRANCH_FALLBACK,
} from '../../src/cli/analyze-config.js';
import type { AnalyzeOptions } from '../../src/cli/analyze.js';
describe('analyze-config (.gitnexusrc support, #243)', () => {
let dir: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-'));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
const writeRc = (contents: string) =>
fs.writeFile(path.join(dir, GITNEXUS_RC_FILENAME), contents);
// ── loadAnalyzeConfig ──────────────────────────────────────────────
it('returns undefined when no .gitnexusrc exists (the normal case)', () => {
expect(loadAnalyzeConfig(dir)).toBeUndefined();
});
it('throws an actionable error on invalid JSON, naming the file', async () => {
await writeRc('{ not valid json ');
expect(() => loadAnalyzeConfig(dir)).toThrow(GitNexusRcError);
try {
loadAnalyzeConfig(dir);
} catch (err) {
expect((err as Error).message).toContain(GITNEXUS_RC_FILENAME);
expect((err as Error).message).toMatch(/not valid JSON/i);
}
});
it('rejects a non-object top-level value', async () => {
await writeRc('["develop"]');
expect(() => loadAnalyzeConfig(dir)).toThrow(/must contain a JSON object/);
});
it('fails closed on an unknown key (typo protection)', async () => {
await writeRc(JSON.stringify({ defalutBranch: 'develop' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/Unknown key "defalutBranch"/);
});
it('parses the flat form and maps aliases onto AnalyzeOptions', async () => {
await writeRc(
JSON.stringify({
defaultBranch: 'develop',
skipContextFiles: true,
skipSkills: true,
embeddings: true,
workerTimeout: 60,
}),
);
const cfg = loadAnalyzeConfig(dir);
expect(cfg).toEqual({
defaultBranch: 'develop',
skipAgentsMd: true, // skipContextFiles → skipAgentsMd
skipSkills: true,
embeddings: true,
workerTimeout: '60', // numeric → string (Commander contract)
});
});
it('parses the nested analyze form', async () => {
await writeRc(JSON.stringify({ analyze: { defaultBranch: 'master', skipSkills: true } }));
expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'master', skipSkills: true });
});
it('lets the nested analyze block override flat keys for the same option', async () => {
await writeRc(
JSON.stringify({
defaultBranch: 'flat-branch',
skipSkills: false,
analyze: { defaultBranch: 'nested-branch', skipSkills: true },
}),
);
expect(loadAnalyzeConfig(dir)).toEqual({
defaultBranch: 'nested-branch',
skipSkills: true,
});
});
it('supports the legacy "branch" alias and the issue-comment shape', async () => {
await writeRc(JSON.stringify({ branch: 'develop', skipAiContext: true, embeddings: false }));
expect(loadAnalyzeConfig(dir)).toEqual({
defaultBranch: 'develop',
skipAgentsMd: true, // skipAiContext → skipAgentsMd
embeddings: false,
});
});
it('maps noStats onto stats (negated)', async () => {
await writeRc(JSON.stringify({ noStats: true }));
expect(loadAnalyzeConfig(dir)).toEqual({ stats: false });
});
it('rejects two aliases that configure the same option at the same level', async () => {
await writeRc(JSON.stringify({ skipContextFiles: true, skipAgentsMd: false }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/both configure the same option/);
});
it('requires booleans to be booleans', async () => {
await writeRc(JSON.stringify({ skipSkills: 'yes' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a boolean/);
});
it('requires the nested analyze value to be an object', async () => {
await writeRc(JSON.stringify({ analyze: 'develop' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/"analyze" must be a JSON object/);
});
it('normalizes numeric embeddings cap to a string and validates it', async () => {
await writeRc(JSON.stringify({ embeddings: 1000 }));
expect(loadAnalyzeConfig(dir)).toEqual({ embeddings: '1000' });
await writeRc(JSON.stringify({ embeddings: -1 }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/non-negative integer/);
});
it('rejects an invalid branch string in config (control characters)', async () => {
// Build the JSON manually so the control char survives.
await writeRc('{"defaultBranch": "de\\u0007velop"}');
expect(() => loadAnalyzeConfig(dir)).toThrow(/control or hidden/);
});
// ── validateBranchName ─────────────────────────────────────────────
it('validateBranchName trims and accepts normal branch names', () => {
expect(validateBranchName(' develop ', 'src')).toBe('develop');
expect(validateBranchName('feature/foo-bar', 'src')).toBe('feature/foo-bar');
expect(validateBranchName('release/1.2', 'src')).toBe('release/1.2');
});
it('validateBranchName rejects empty, whitespace, ref-special, leading dash, and "."', () => {
expect(() => validateBranchName(' ', 'src')).toThrow(/must not be empty/);
expect(() => validateBranchName('foo bar', 'src')).toThrow(/whitespace/);
expect(() => validateBranchName('foo~1', 'src')).toThrow(/not allowed in a git ref/);
expect(() => validateBranchName('foo:bar', 'src')).toThrow(/not allowed in a git ref/);
expect(() => validateBranchName('-foo', 'src')).toThrow(/must not start with "-"/);
expect(() => validateBranchName('foo..bar', 'src')).toThrow(/must not contain ".."/);
});
it('validateBranchName rejects a newline / control character', () => {
expect(() => validateBranchName('main\nrm -rf', 'src')).toThrow(/control or hidden|whitespace/);
});
it('sanitizeDetectedBranch returns undefined for junk, the name otherwise', () => {
expect(sanitizeDetectedBranch('develop')).toBe('develop');
expect(sanitizeDetectedBranch('with space')).toBeUndefined();
expect(sanitizeDetectedBranch(null)).toBeUndefined();
expect(sanitizeDetectedBranch('')).toBeUndefined();
});
// ── resolveDefaultBranch ───────────────────────────────────────────
it('resolveDefaultBranch: CLI wins over config and detection', () => {
expect(
resolveDefaultBranch({ cliBranch: 'cli', configBranch: 'cfg', detectedBranch: 'det' }),
).toBe('cli');
});
it('resolveDefaultBranch: config wins over detection', () => {
expect(resolveDefaultBranch({ configBranch: 'develop', detectedBranch: 'main' })).toBe(
'develop',
);
});
it('resolveDefaultBranch: auto-detected branch used when no CLI/config', () => {
expect(resolveDefaultBranch({ detectedBranch: 'trunk' })).toBe('trunk');
});
it('resolveDefaultBranch: falls back to "main" with nothing available', () => {
expect(resolveDefaultBranch({})).toBe(DEFAULT_BRANCH_FALLBACK);
expect(resolveDefaultBranch({ detectedBranch: null })).toBe('main');
// An unusable detected branch is ignored, not surfaced as an error.
expect(resolveDefaultBranch({ detectedBranch: 'bad branch' })).toBe('main');
});
it('resolveDefaultBranch: invalid CLI branch throws (user error)', () => {
expect(() => resolveDefaultBranch({ cliBranch: 'bad branch' })).toThrow(GitNexusRcError);
expect(() => resolveDefaultBranch({ cliBranch: 'bad branch' })).toThrow(/--default-branch/);
});
// ── mergeAnalyzeOptions ────────────────────────────────────────────
it('mergeAnalyzeOptions: returns CLI unchanged when there is no config', () => {
const cli: AnalyzeOptions = { force: true };
expect(mergeAnalyzeOptions(cli, undefined)).toBe(cli);
});
it('mergeAnalyzeOptions: config fills options the CLI left unset', () => {
const merged = mergeAnalyzeOptions({}, { skipAgentsMd: true, workerTimeout: '60' });
expect(merged.skipAgentsMd).toBe(true);
expect(merged.workerTimeout).toBe('60');
});
it('mergeAnalyzeOptions: CLI value wins over config', () => {
const merged = mergeAnalyzeOptions({ workerTimeout: '5' }, { workerTimeout: '60' });
expect(merged.workerTimeout).toBe('5');
});
it('mergeAnalyzeOptions: an explicit CLI false overrides a config true', () => {
const merged = mergeAnalyzeOptions({ skipSkills: false }, { skipSkills: true });
expect(merged.skipSkills).toBe(false);
});
it('mergeAnalyzeOptions: config stats applies unless --no-stats was passed', () => {
// Commander default (stats:true) with config stats:false → config wins (off).
expect(mergeAnalyzeOptions({ stats: true }, { stats: false }).stats).toBe(false);
// Explicit --no-stats (stats:false) is never overridden back on by config.
expect(mergeAnalyzeOptions({ stats: false }, { stats: true }).stats).toBe(false);
// No config stats → CLI value preserved.
expect(mergeAnalyzeOptions({ stats: true }, { skipSkills: true }).stats).toBe(true);
});
it('mergeAnalyzeOptions: does NOT forward defaultBranch (resolver owns it) (#1996)', () => {
// Pins the deliberate exclusion: defaultBranch is resolved via
// resolveDefaultBranch (CLI > config > detect > main), not the generic merge.
const merged = mergeAnalyzeOptions({}, { defaultBranch: 'develop', skipSkills: true });
expect(merged.skipSkills).toBe(true);
expect(merged.defaultBranch).toBeUndefined();
});
// ── #1996 tri-review hardening ─────────────────────────────────────
it('validateBranchName rejects a backtick (breaks generated Markdown) (#1996)', () => {
expect(() => validateBranchName('main`evil', 'src')).toThrow(/backtick/);
expect(() => validateBranchName('a`b', 'src')).toThrow(GitNexusRcError);
// sanitizeDetectedBranch swallows it → falls back via the resolver chain.
expect(sanitizeDetectedBranch('main`evil')).toBeUndefined();
});
it('validateBranchName enforces the 255-char max (#1996)', () => {
expect(validateBranchName('a'.repeat(255), 'src')).toBe('a'.repeat(255));
expect(() => validateBranchName('a'.repeat(256), 'src')).toThrow(/too long/);
});
it('rejects Markdown-significant characters in a config name, allows real names (#1996)', async () => {
await writeRc(JSON.stringify({ name: '**evil**' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/Markdown-significant/);
await writeRc(JSON.stringify({ name: 'repo`x' }));
expect(() => loadAnalyzeConfig(dir)).toThrow(/Markdown-significant/);
// Underscores, dots, dashes, slashes are legitimate in repo names.
await writeRc(JSON.stringify({ name: 'my_org/my-repo.v2' }));
expect(loadAnalyzeConfig(dir)).toEqual({ name: 'my_org/my-repo.v2' });
});
it('reports inherited keys (__proto__, constructor) as Unknown key, not a kind error (#1996)', async () => {
for (const key of ['__proto__', 'constructor', 'toString']) {
await writeRc(`{"${key}": true}`);
expect(() => loadAnalyzeConfig(dir), key).toThrow(/Unknown key/);
}
// And no prototype pollution leaked from the attempt.
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it('strips a leading UTF-8 BOM before parsing (#1996)', async () => {
const BOM = String.fromCharCode(0xfeff);
await writeRc(BOM + JSON.stringify({ defaultBranch: 'develop' }));
expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'develop' });
});
});

View file

@ -0,0 +1,236 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
/**
* End-to-end wiring tests for project-local `.gitnexusrc` (#243).
*
* Unlike analyze-config.test.ts (which unit-tests the pure config module), these
* drive the REAL `analyzeCommand` with a REAL `.gitnexusrc` on disk and a real
* `analyze-config` module — only the heavy pipeline (`runFullAnalysis`,
* `generateAIContextFiles`, skill-gen, LadybugDB) and git are mocked. They fail
* if config is parsed but not threaded into the analyze/context path.
*/
const {
runFullAnalysisMock,
generateAIContextFilesMock,
refreshBaseRefLineMock,
generateSkillFilesMock,
cliErrorMock,
getDefaultBranchMock,
} = vi.hoisted(() => ({
runFullAnalysisMock: vi.fn(),
generateAIContextFilesMock: vi.fn(async () => ({ files: [] as string[] })),
refreshBaseRefLineMock: vi.fn(async () => ({ files: [] as string[] })),
generateSkillFilesMock: vi.fn(async () => ({
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
outputPath: '/repo/.claude/skills/generated',
})),
cliErrorMock: vi.fn(),
getDefaultBranchMock: vi.fn<(p: string) => string | null>(() => null),
}));
vi.mock('../../src/core/run-analyze.js', () => ({ runFullAnalysis: runFullAnalysisMock }));
vi.mock('../../src/cli/ai-context.js', () => ({
generateAIContextFiles: generateAIContextFilesMock,
refreshBaseRefLine: refreshBaseRefLineMock,
}));
vi.mock('../../src/cli/skill-gen.js', () => ({ generateSkillFiles: generateSkillFilesMock }));
vi.mock('../../src/cli/cli-message.js', () => ({ cliError: cliErrorMock }));
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ closeLbug: vi.fn(async () => undefined) }));
vi.mock('../../src/storage/repo-manager.js', () => ({
getStoragePaths: vi.fn((repoPath: string) => ({
storagePath: path.join(repoPath, '.gitnexus'),
lbugPath: path.join(repoPath, '.gitnexus', 'lbug'),
})),
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
assertAnalysisFinalized: vi.fn(async () => undefined),
}));
// hasGitDir true; getGitRoot is unused because tests pass an explicit path.
vi.mock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn((p: string) => p),
hasGitDir: vi.fn(() => true),
getDefaultBranch: getDefaultBranchMock,
}));
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
getMaxFileSizeBannerMessage: vi.fn(() => null),
}));
const upToDate = {
repoName: 'repo',
repoPath: '/repo',
stats: {},
alreadyUpToDate: true,
};
describe('analyzeCommand .gitnexusrc wiring (#243)', () => {
let dir: string;
beforeEach(async () => {
vi.resetModules();
runFullAnalysisMock.mockReset();
runFullAnalysisMock.mockResolvedValue(upToDate);
generateAIContextFilesMock.mockReset();
generateAIContextFilesMock.mockResolvedValue({ files: [] });
refreshBaseRefLineMock.mockReset();
refreshBaseRefLineMock.mockResolvedValue({ files: [] });
generateSkillFilesMock.mockReset();
generateSkillFilesMock.mockResolvedValue({
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
outputPath: '/repo/.claude/skills/generated',
});
cliErrorMock.mockReset();
getDefaultBranchMock.mockReset();
getDefaultBranchMock.mockReturnValue(null);
process.exitCode = undefined;
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-wire-'));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
const writeRc = (obj: unknown) =>
fs.writeFile(path.join(dir, '.gitnexusrc'), JSON.stringify(obj));
it('maps .gitnexusrc skipContextFiles to skipAgentsMd without implying skipSkills', async () => {
await writeRc({ skipContextFiles: true });
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, {});
expect(runFullAnalysisMock).toHaveBeenCalledTimes(1);
const opts = runFullAnalysisMock.mock.calls[0][1];
expect(opts.skipAgentsMd).toBe(true);
expect(opts.skipSkills).toBeFalsy();
});
it('indexOnly from config remains stronger than context/skills options', async () => {
await writeRc({ indexOnly: true });
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, {});
const opts = runFullAnalysisMock.mock.calls[0][1];
expect(opts.skipAgentsMd).toBe(true);
expect(opts.skipSkills).toBe(true);
});
it('uses .gitnexusrc defaultBranch for generated context', async () => {
await writeRc({ defaultBranch: 'develop' });
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, {});
const opts = runFullAnalysisMock.mock.calls[0][1];
expect(opts.defaultBranch).toBe('develop');
// A configured branch must short-circuit auto-detection.
expect(getDefaultBranchMock).not.toHaveBeenCalled();
});
it('lets --default-branch override .gitnexusrc defaultBranch', async () => {
await writeRc({ defaultBranch: 'develop' });
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, { defaultBranch: 'cli-branch' });
const opts = runFullAnalysisMock.mock.calls[0][1];
expect(opts.defaultBranch).toBe('cli-branch');
});
it('auto-detects the default branch when neither CLI nor config set it', async () => {
// No .gitnexusrc on disk.
getDefaultBranchMock.mockReturnValue('trunk');
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, {});
const opts = runFullAnalysisMock.mock.calls[0][1];
expect(getDefaultBranchMock).toHaveBeenCalledTimes(1);
expect(opts.defaultBranch).toBe('trunk');
});
it('fails before analysis on an invalid .gitnexusrc, with an actionable error', async () => {
await fs.writeFile(path.join(dir, '.gitnexusrc'), '{ broken json ');
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, {});
expect(process.exitCode).toBe(1);
expect(runFullAnalysisMock).not.toHaveBeenCalled();
expect(cliErrorMock).toHaveBeenCalledWith(
expect.stringMatching(/\.gitnexusrc/),
expect.objectContaining({ recoveryHint: 'gitnexusrc-invalid' }),
);
});
it('threads the resolved branch into the --skills re-generation (does not revert to main)', async () => {
await writeRc({ defaultBranch: 'develop' });
runFullAnalysisMock.mockResolvedValueOnce({
repoName: 'repo',
repoPath: dir,
stats: { files: 1, nodes: 10, edges: 20, communities: 0, processes: 5 },
alreadyUpToDate: false,
pipelineResult: { communityResult: undefined },
});
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
try {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, { skills: true });
expect(generateSkillFilesMock).toHaveBeenCalledTimes(1);
expect(generateAIContextFilesMock).toHaveBeenCalledTimes(1);
const aiCtxOpts = generateAIContextFilesMock.mock.calls[0]![5];
expect(aiCtxOpts).toMatchObject({ defaultBranch: 'develop' });
} finally {
exitSpy.mockRestore();
}
});
// ── #1996 tri-review hardening ─────────────────────────────────────
it('rejects an invalid --default-branch up front with a CLI-specific hint (#1996)', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, { defaultBranch: 'bad branch' });
expect(process.exitCode).toBe(1);
expect(runFullAnalysisMock).not.toHaveBeenCalled();
expect(cliErrorMock).toHaveBeenCalledWith(
expect.stringMatching(/--default-branch/),
expect.objectContaining({ recoveryHint: 'default-branch-invalid' }),
);
});
it('does not auto-detect the branch when config skips context generation (#1996)', async () => {
await writeRc({ skipAgentsMd: true });
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, {});
// willGenerateContext=false ⇒ no git call for the (unused) branch.
expect(getDefaultBranchMock).not.toHaveBeenCalled();
expect(runFullAnalysisMock.mock.calls[0][1].skipAgentsMd).toBe(true);
});
it('refreshes base_ref in place on the alreadyUpToDate fast path (#1996 P2)', async () => {
await writeRc({ defaultBranch: 'develop' });
// Default mock returns alreadyUpToDate:true.
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(dir, {});
expect(refreshBaseRefLineMock).toHaveBeenCalledTimes(1);
expect(refreshBaseRefLineMock).toHaveBeenCalledWith(dir, 'develop', expect.any(Object));
});
});

View file

@ -48,6 +48,9 @@ vi.mock('../../src/storage/repo-manager.js', () => ({
vi.mock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn(() => '/repo'),
hasGitDir: vi.fn(() => true),
// #243: default-branch auto-detection. Return null so the resolver falls back
// to "main" deterministically in this mocked environment.
getDefaultBranch: vi.fn(() => null),
}));
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
@ -162,6 +165,8 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)',
expect(aiCtxOpts).toEqual({
skipAgentsMd: undefined,
skipSkills: undefined,
// #243: resolved default branch threaded into the --skills regen path.
defaultBranch: 'main',
noStats: true,
});
} finally {

View file

@ -10,6 +10,7 @@ import {
findGitRootByDotGit,
parseRepoNameFromUrl,
sanitizeRepoName,
getDefaultBranch,
} from '../../src/storage/git.js';
// Mock child_process.execSync
@ -71,6 +72,34 @@ describe('git utilities', () => {
});
});
describe('getDefaultBranch (#243)', () => {
it('strips the origin/ prefix from the symbolic ref', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('origin/develop\n'));
expect(getDefaultBranch('/project')).toBe('develop');
expect(mockExecSync).toHaveBeenCalledWith(
'git symbolic-ref --short refs/remotes/origin/HEAD',
expect.objectContaining({ cwd: '/project', windowsHide: true }),
);
});
it('handles a branch name that itself contains a slash', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('origin/release/1.2\n'));
expect(getDefaultBranch('/project')).toBe('release/1.2');
});
it('returns null when origin/HEAD is not set (git throws)', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('fatal: ref refs/remotes/origin/HEAD is not a symbolic ref');
});
expect(getDefaultBranch('/no-origin-head')).toBeNull();
});
it('returns null on empty output', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('\n'));
expect(getDefaultBranch('/project')).toBeNull();
});
});
describe('getGitRoot', () => {
it('returns resolved path on success', () => {
mockExecSync.mockReturnValueOnce(Buffer.from('/d/Projects/MyRepo\n'));