mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-16 23:43:12 +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>
256 lines
9.1 KiB
TypeScript
256 lines
9.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { execSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import {
|
|
isGitRepo,
|
|
getCurrentCommit,
|
|
getGitRoot,
|
|
findGitRootByDotGit,
|
|
parseRepoNameFromUrl,
|
|
sanitizeRepoName,
|
|
getDefaultBranch,
|
|
} from '../../src/storage/git.js';
|
|
|
|
// Mock child_process.execSync
|
|
vi.mock('child_process', () => ({
|
|
execSync: vi.fn(),
|
|
}));
|
|
|
|
const mockExecSync = vi.mocked(execSync);
|
|
|
|
describe('git utilities', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('isGitRepo', () => {
|
|
it('returns true when inside a git work tree', () => {
|
|
mockExecSync.mockReturnValueOnce(Buffer.from(''));
|
|
expect(isGitRepo('/project')).toBe(true);
|
|
expect(mockExecSync).toHaveBeenCalledWith('git rev-parse --is-inside-work-tree', {
|
|
cwd: '/project',
|
|
stdio: 'ignore',
|
|
windowsHide: true,
|
|
});
|
|
});
|
|
|
|
it('returns false when not a git repo', () => {
|
|
mockExecSync.mockImplementationOnce(() => {
|
|
throw new Error('not a git repo');
|
|
});
|
|
expect(isGitRepo('/not-a-repo')).toBe(false);
|
|
});
|
|
|
|
it('passes the correct cwd', () => {
|
|
mockExecSync.mockReturnValueOnce(Buffer.from(''));
|
|
isGitRepo('/some/path');
|
|
expect(mockExecSync).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({ cwd: '/some/path' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getCurrentCommit', () => {
|
|
it('returns trimmed commit hash', () => {
|
|
mockExecSync.mockReturnValueOnce(Buffer.from('abc123def\n'));
|
|
expect(getCurrentCommit('/project')).toBe('abc123def');
|
|
});
|
|
|
|
it('returns empty string on error', () => {
|
|
mockExecSync.mockImplementationOnce(() => {
|
|
throw new Error('not a git repo');
|
|
});
|
|
expect(getCurrentCommit('/not-a-repo')).toBe('');
|
|
});
|
|
|
|
it('trims whitespace from output', () => {
|
|
mockExecSync.mockReturnValueOnce(Buffer.from(' sha256hash \n'));
|
|
expect(getCurrentCommit('/project')).toBe('sha256hash');
|
|
});
|
|
});
|
|
|
|
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'));
|
|
const result = getGitRoot('/d/Projects/MyRepo/src');
|
|
expect(result).toBeTruthy();
|
|
// path.resolve normalizes the git output
|
|
expect(typeof result).toBe('string');
|
|
});
|
|
|
|
it('returns null when not in a git repo', () => {
|
|
mockExecSync.mockImplementationOnce(() => {
|
|
throw new Error('not a git repo');
|
|
});
|
|
expect(getGitRoot('/not-a-repo')).toBeNull();
|
|
});
|
|
|
|
it('calls git rev-parse --show-toplevel', () => {
|
|
mockExecSync.mockReturnValueOnce(Buffer.from('/repo\n'));
|
|
getGitRoot('/repo/src');
|
|
expect(mockExecSync).toHaveBeenCalledWith(
|
|
'git rev-parse --show-toplevel',
|
|
expect.objectContaining({ cwd: '/repo/src' }),
|
|
);
|
|
});
|
|
|
|
it('trims output before resolving path', () => {
|
|
mockExecSync.mockReturnValueOnce(Buffer.from(' /repo \n'));
|
|
const result = getGitRoot('/repo/src');
|
|
expect(result).not.toBeNull();
|
|
expect(result!.trim()).toBe(result);
|
|
});
|
|
});
|
|
|
|
describe('findGitRootByDotGit', () => {
|
|
it('finds an ancestor .git directory without spawning git', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-dotgit-'));
|
|
try {
|
|
fs.mkdirSync(path.join(tmpDir, '.git'));
|
|
const nested = path.join(tmpDir, 'packages', 'app');
|
|
fs.mkdirSync(nested, { recursive: true });
|
|
|
|
expect(findGitRootByDotGit(nested)).toBe(path.resolve(tmpDir));
|
|
expect(mockExecSync).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns null outside a git worktree without spawning git', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-nonrepo-'));
|
|
try {
|
|
expect(findGitRootByDotGit(tmpDir)).toBeNull();
|
|
expect(mockExecSync).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Linked worktrees and submodules use a `.git` file (not directory) that
|
|
// points at the real gitdir. statSync succeeds for both, so the ancestor
|
|
// walk should treat such roots identically to ordinary repos.
|
|
it('treats a .git file (linked worktree) as a valid root', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
|
|
try {
|
|
fs.writeFileSync(path.join(tmpDir, '.git'), 'gitdir: /fake/worktrees/wt\n');
|
|
const nested = path.join(tmpDir, 'src', 'pkg');
|
|
fs.mkdirSync(nested, { recursive: true });
|
|
|
|
expect(findGitRootByDotGit(nested)).toBe(path.resolve(tmpDir));
|
|
expect(mockExecSync).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns null when the input path does not exist', () => {
|
|
const missing = path.join(os.tmpdir(), `gitnexus-missing-${Date.now()}-${Math.random()}`);
|
|
expect(findGitRootByDotGit(missing)).toBeNull();
|
|
expect(mockExecSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('walks from a file input by starting at its parent directory', () => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-fileinput-'));
|
|
try {
|
|
fs.mkdirSync(path.join(tmpDir, '.git'));
|
|
const filePath = path.join(tmpDir, 'pkg', 'index.ts');
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, 'export {};\n');
|
|
|
|
expect(findGitRootByDotGit(filePath)).toBe(path.resolve(tmpDir));
|
|
expect(mockExecSync).not.toHaveBeenCalled();
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('sanitizeRepoName', () => {
|
|
it('strips leading dashes', () => {
|
|
expect(sanitizeRepoName('--repo')).toBe('repo');
|
|
});
|
|
|
|
it('replaces unsafe characters with underscores', () => {
|
|
expect(sanitizeRepoName('repo<tag>')).toBe('repo_tag_');
|
|
expect(sanitizeRepoName('repo:name')).toBe('repo_name');
|
|
expect(sanitizeRepoName('repo"quoted"')).toBe('repo_quoted_');
|
|
});
|
|
|
|
it('blocks path traversal segments', () => {
|
|
expect(sanitizeRepoName('.')).toBe('unknown');
|
|
expect(sanitizeRepoName('..')).toBe('unknown');
|
|
});
|
|
|
|
it('blocks Windows reserved names', () => {
|
|
expect(sanitizeRepoName('CON')).toBe('unknown');
|
|
expect(sanitizeRepoName('prn')).toBe('unknown');
|
|
expect(sanitizeRepoName('AUX')).toBe('unknown');
|
|
expect(sanitizeRepoName('NUL')).toBe('unknown');
|
|
expect(sanitizeRepoName('COM1')).toBe('unknown');
|
|
expect(sanitizeRepoName('LPT9')).toBe('unknown');
|
|
|
|
// Reserved names with extensions
|
|
expect(sanitizeRepoName('CON.txt')).toBe('unknown');
|
|
expect(sanitizeRepoName('NUL.tar.gz')).toBe('unknown');
|
|
expect(sanitizeRepoName('AUX.local')).toBe('unknown');
|
|
});
|
|
|
|
it('returns unknown for empty or invalid input', () => {
|
|
expect(sanitizeRepoName('')).toBe('unknown');
|
|
expect(sanitizeRepoName('---')).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
describe('parseRepoNameFromUrl', () => {
|
|
it('extracts and sanitizes name from HTTPS URL', () => {
|
|
expect(parseRepoNameFromUrl('https://github.com/user/my-repo.git')).toBe('my-repo');
|
|
expect(parseRepoNameFromUrl('https://github.com/user/--payload.git')).toBe('payload');
|
|
});
|
|
|
|
it('extracts and sanitizes name from SSH URL', () => {
|
|
expect(parseRepoNameFromUrl('git@github.com:user/my-repo.git')).toBe('my-repo');
|
|
expect(parseRepoNameFromUrl('git@github.com:--payload.git')).toBe('payload');
|
|
});
|
|
|
|
it('returns null for all-dash inputs (prevents registry collision)', () => {
|
|
expect(parseRepoNameFromUrl('https://github.com/user/---.git')).toBeNull();
|
|
});
|
|
|
|
it('returns null for empty URL', () => {
|
|
expect(parseRepoNameFromUrl('')).toBeNull();
|
|
expect(parseRepoNameFromUrl(null)).toBeNull();
|
|
});
|
|
});
|
|
});
|