diff --git a/README.md b/README.md index 60f15861cd..6753464c61 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 34de638b48..6470c64306 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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 = ''; @@ -89,6 +95,16 @@ async function findGroupsContainingRegistryName(registryName: string): Promise 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 }; +} diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts new file mode 100644 index 0000000000..8fbe40feb1 --- /dev/null +++ b/gitnexus/src/cli/analyze-config.ts @@ -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 = { + 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, + { allowNestedKey }: { allowNestedKey: boolean }, +): Partial => { + const out: Partial = {}; + const setBy = new Map(); + + 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)[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 | 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; + const flat = normalizeLevel(obj, { allowNestedKey: true }); + + let nested: Partial = {}; + 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, { 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 | 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)[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; +} diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 227b929a1d..6801f3f773 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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 => { - if (options?.verbose) { +const analyzeCommandImpl = async ( + inputPath?: string, + cliOptions?: AnalyzeOptions, +): Promise => { + 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, }, ); } diff --git a/gitnexus/src/cli/cli-message.ts b/gitnexus/src/cli/cli-message.ts index d21bb536ef..da5f0b28e6 100644 --- a/gitnexus/src/cli/cli-message.ts +++ b/gitnexus/src/cli/cli-message.ts @@ -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 diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index b599f7e9ea..f64b3a24da 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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 ', + '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', diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 70cb5ecb7a..52d0a17ee6 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 { diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 16ebe039ab..e90081dbe8 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -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/` 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. diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index b0f48e1680..6c3e960961 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -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(''), content.indexOf(''), ); - 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 — 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\` | + +`; + 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 = ` + +Indexed as **P**. Custom. + +`; + 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 }); + } + }); }); diff --git a/gitnexus/test/unit/analyze-config.test.ts b/gitnexus/test/unit/analyze-config.test.ts new file mode 100644 index 0000000000..07bbdbea18 --- /dev/null +++ b/gitnexus/test/unit/analyze-config.test.ts @@ -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).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' }); + }); +}); diff --git a/gitnexus/test/unit/analyze-gitnexusrc.test.ts b/gitnexus/test/unit/analyze-gitnexusrc.test.ts new file mode 100644 index 0000000000..cd299f8709 --- /dev/null +++ b/gitnexus/test/unit/analyze-gitnexusrc.test.ts @@ -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)); + }); +}); diff --git a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts index b2d368c2f5..22e58a0616 100644 --- a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts +++ b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts @@ -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 { diff --git a/gitnexus/test/unit/git.test.ts b/gitnexus/test/unit/git.test.ts index 9d3a424c93..2df74d2105 100644 --- a/gitnexus/test/unit/git.test.ts +++ b/gitnexus/test/unit/git.test.ts @@ -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'));