mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
* 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:
parent
295b75d12d
commit
fdea46729e
13 changed files with 1458 additions and 68 deletions
32
README.md
32
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 <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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
426
gitnexus/src/cli/analyze-config.ts
Normal file
426
gitnexus/src/cli/analyze-config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
276
gitnexus/test/unit/analyze-config.test.ts
Normal file
276
gitnexus/test/unit/analyze-config.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
236
gitnexus/test/unit/analyze-gitnexusrc.test.ts
Normal file
236
gitnexus/test/unit/analyze-gitnexusrc.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue