GitNexus/gitnexus/test/unit/analyze-self-commit-bridge.test.ts
jecanore a500f70d6f
feat(analyze): add opt-in --self-commit flag for AGENTS.md/CLAUDE.md churn (#2640)
* 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>
2026-07-25 06:23:26 +01:00

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),
);
});
});