mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* feat(analyze): add opt-in --self-commit flag for AGENTS.md/CLAUDE.md churn Adds a new `--self-commit` flag to `gitnexus analyze`. When passed, any AGENTS.md/CLAUDE.md changes the run makes (including first-time creation) are auto-committed, scoped to only those two files (never `git add -A`). No-ops silently if neither exists, neither changed, or the repo has no git identity configured — never fails the surrounding analyze run. Complements #1478 (--no-stats): that flag removes the volatile counts entirely, this one keeps them but eliminates the dangling working-tree diff they otherwise leave behind on every run. Closes #2639. * fix(analyze): log a warning when --self-commit fails to commit Addresses review feedback on #2640: the commit step's catch block was silently swallowing failures (e.g. missing git identity) with no signal to the user. Logs via the existing pino logger (matching the rest of the codebase's convention) with the error and the file list, while still never throwing — analyze must not fail over this. New test forces a real commit failure (missing identity, with useConfigOnly + isolated HOME/XDG_CONFIG_HOME/GIT_CONFIG_NOSYSTEM so no ambient global git config on the CI runner can mask it) and asserts the warning is captured via logger's _captureLogger test hook. * fix(analyze): refuse to sweep pre-existing edits into --self-commit Addresses both state-safety blockers from review round 2 on #2640: 1. selfCommitContextFiles could not distinguish a pre-existing unstaged user edit in AGENTS.md/CLAUDE.md from this run's generated stats refresh — both just showed up as "the file is dirty" — so a user edit sitting in either file got silently swept into the generated commit. Fixed by snapshotting each candidate's cleanliness via the new snapshotSelfCommitSafety() BEFORE analyze writes to it; only files confirmed safe (nonexistent pre-run, i.e. first-time creation, or clean pre-run) are ever added/committed. A file already dirty pre-run is skipped and logged, never touched. 2. On a failed `git commit` (e.g. missing identity), the preceding `git add` had already staged the safe files, and analyze reported nothing happened while silently leaving them staged. Fixed with a `git reset -- <safe files>` in the commit-failure catch, restoring the index to its pre-add state for exactly the files this helper staged. Wired analyze.ts to call snapshotSelfCommitSafety() once before runFullAnalysis (which is where the actual AGENTS.md/CLAUDE.md write happens, on both the fast path and the primary run), threading the result through both existing selfCommitContextFiles() call sites. New tests: a pre-dirty AGENTS.md is skipped while a clean CLAUDE.md still commits normally, and a post-add commit failure leaves nothing staged. Updated all existing selfCommitContextFiles() call sites for the new required safety-map parameter. * i18n(cli): add zh-CN translation for --self-commit help text Addresses magyargergo's follow-up on #2640: --self-commit was missing from the analyze command's OPTION_DESCRIPTION_KEYS map, so its help text never went through localizeCliHelp and always rendered in English regardless of locale. Adds the help.option.analyze.selfCommit key to both en.ts and zh-CN.ts and wires it into help-i18n.ts, matching the existing --no-stats/--skills entries. --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
203 lines
6.3 KiB
TypeScript
203 lines
6.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const {
|
|
runFullAnalysisMock,
|
|
generateAIContextFilesMock,
|
|
generateSkillFilesMock,
|
|
cliErrorMock,
|
|
selfCommitContextFilesMock,
|
|
snapshotSelfCommitSafetyMock,
|
|
} = vi.hoisted(() => {
|
|
const runFullAnalysisMock = vi.fn();
|
|
const generateAIContextFilesMock = vi.fn(async () => ({ files: [] as string[] }));
|
|
const generateSkillFilesMock = vi.fn(async () => ({
|
|
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
|
|
outputPath: '/repo/.claude/skills',
|
|
}));
|
|
const cliErrorMock = vi.fn();
|
|
const selfCommitContextFilesMock = vi.fn();
|
|
const snapshotSelfCommitSafetyMock = vi.fn(
|
|
() =>
|
|
new Map([
|
|
['AGENTS.md', true],
|
|
['CLAUDE.md', true],
|
|
]),
|
|
);
|
|
return {
|
|
runFullAnalysisMock,
|
|
generateAIContextFilesMock,
|
|
generateSkillFilesMock,
|
|
cliErrorMock,
|
|
selfCommitContextFilesMock,
|
|
snapshotSelfCommitSafetyMock,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/core/run-analyze.js', () => ({
|
|
runFullAnalysis: runFullAnalysisMock,
|
|
}));
|
|
|
|
vi.mock('../../src/cli/ai-context.js', () => ({
|
|
generateAIContextFiles: generateAIContextFilesMock,
|
|
}));
|
|
|
|
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),
|
|
closeLbugBeforeExit: vi.fn(async () => undefined),
|
|
isLbugReady: vi.fn(() => false),
|
|
}));
|
|
|
|
vi.mock('../../src/storage/repo-manager.js', () => ({
|
|
getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })),
|
|
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
|
|
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
|
|
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
|
|
assertAnalysisFinalized: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
vi.mock('../../src/storage/git.js', () => ({
|
|
getGitRoot: vi.fn(() => '/repo'),
|
|
hasGitDir: vi.fn(() => true),
|
|
getDefaultBranch: vi.fn(() => null),
|
|
selfCommitContextFiles: selfCommitContextFilesMock,
|
|
snapshotSelfCommitSafety: snapshotSelfCommitSafetyMock,
|
|
}));
|
|
|
|
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
|
|
getMaxFileSizeBannerMessage: vi.fn(() => null),
|
|
}));
|
|
|
|
describe('analyzeCommand --self-commit bridge (#2639)', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
runFullAnalysisMock.mockReset();
|
|
runFullAnalysisMock.mockResolvedValue({
|
|
repoName: 'repo',
|
|
repoPath: '/repo',
|
|
stats: {},
|
|
alreadyUpToDate: true,
|
|
});
|
|
generateAIContextFilesMock.mockReset();
|
|
generateAIContextFilesMock.mockResolvedValue({ files: [] });
|
|
generateSkillFilesMock.mockReset();
|
|
generateSkillFilesMock.mockResolvedValue({
|
|
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
|
|
outputPath: '/repo/.claude/skills',
|
|
});
|
|
cliErrorMock.mockReset();
|
|
selfCommitContextFilesMock.mockReset();
|
|
snapshotSelfCommitSafetyMock.mockClear();
|
|
process.exitCode = undefined;
|
|
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
|
|
});
|
|
|
|
it('does not call selfCommitContextFiles when --self-commit is omitted (default off)', async () => {
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(undefined, {});
|
|
|
|
expect(selfCommitContextFilesMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not call selfCommitContextFiles when --self-commit is explicitly false', async () => {
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(undefined, { selfCommit: false });
|
|
|
|
expect(selfCommitContextFilesMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('calls selfCommitContextFiles scoped to AGENTS.md/CLAUDE.md on the already-up-to-date fast path', async () => {
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(undefined, { selfCommit: true });
|
|
|
|
expect(selfCommitContextFilesMock).toHaveBeenCalledTimes(1);
|
|
expect(selfCommitContextFilesMock).toHaveBeenCalledWith(
|
|
'/repo',
|
|
['AGENTS.md', 'CLAUDE.md'],
|
|
expect.any(Map),
|
|
);
|
|
});
|
|
|
|
it('calls selfCommitContextFiles on the primary (non-fast-path) analyze run', async () => {
|
|
runFullAnalysisMock.mockResolvedValueOnce({
|
|
repoName: 'repo',
|
|
repoPath: '/repo',
|
|
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(undefined, { selfCommit: true });
|
|
|
|
expect(selfCommitContextFilesMock).toHaveBeenCalledTimes(1);
|
|
expect(selfCommitContextFilesMock).toHaveBeenCalledWith(
|
|
'/repo',
|
|
['AGENTS.md', 'CLAUDE.md'],
|
|
expect.any(Map),
|
|
);
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('does not call selfCommitContextFiles on the primary run when --self-commit is omitted', async () => {
|
|
runFullAnalysisMock.mockResolvedValueOnce({
|
|
repoName: 'repo',
|
|
repoPath: '/repo',
|
|
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(undefined, {});
|
|
|
|
expect(selfCommitContextFilesMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('composes with --no-stats (both flags threaded independently)', async () => {
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(undefined, { selfCommit: true, stats: false });
|
|
|
|
const opts = runFullAnalysisMock.mock.calls[0][1];
|
|
expect(opts.noStats).toBe(true);
|
|
expect(selfCommitContextFilesMock).toHaveBeenCalledWith(
|
|
'/repo',
|
|
['AGENTS.md', 'CLAUDE.md'],
|
|
expect.any(Map),
|
|
);
|
|
});
|
|
});
|