codex-review-mcp 1.3.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,18 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { performCodeReview } from './performCodeReview.js';
3
- import * as collectDiffModule from '../review/collectDiff.js';
4
3
  import * as gatherContextModule from '../review/gatherContext.js';
5
4
  import * as invokeAgentModule from '../review/invokeAgent.js';
5
+ import * as buildPromptModule from '../review/buildPrompt.js';
6
6
  // Mock the dependencies
7
- vi.mock('../review/collectDiff');
8
7
  vi.mock('../review/gatherContext');
9
8
  vi.mock('../review/invokeAgent');
9
+ vi.mock('../review/buildPrompt');
10
10
  vi.mock('../util/debug', () => ({
11
11
  debugLog: vi.fn(),
12
12
  }));
13
+ vi.mock('node:fs', () => ({
14
+ readFileSync: vi.fn(() => JSON.stringify({ version: '2.0.0' })),
15
+ }));
13
16
  describe('performCodeReview', () => {
14
17
  const mockDiff = `diff --git a/test.ts b/test.ts
15
18
  +++ b/test.ts
@@ -19,85 +22,202 @@ describe('performCodeReview', () => {
19
22
  beforeEach(() => {
20
23
  vi.clearAllMocks();
21
24
  });
22
- it('should call progress callback with correct sequence', async () => {
23
- // Setup mocks
24
- vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
25
- vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
26
- vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review\n\nLooks good!');
27
- // Track progress calls
28
- const progressCalls = [];
29
- const onProgress = vi.fn(async (message, progress, total) => {
30
- progressCalls.push({ message, progress, total });
31
- });
32
- // Execute
33
- await performCodeReview({ target: 'auto' }, onProgress);
34
- // Verify progress sequence
35
- expect(progressCalls.length).toBeGreaterThan(0);
36
- expect(progressCalls[0]).toEqual({ message: 'Collecting diff…', progress: 10, total: 100 });
37
- // Find the "Calling GPT-5 Codex" message
38
- const codexCall = progressCalls.find(call => call.message.includes('Calling GPT-5 Codex'));
39
- expect(codexCall).toBeDefined();
40
- expect(codexCall?.message).toMatch(/Calling GPT-5 Codex with ~\d+ lines/);
41
- expect(codexCall?.progress).toBe(65);
42
- // Verify final progress
43
- const lastCall = progressCalls[progressCalls.length - 1];
44
- expect(lastCall).toEqual({ message: 'Done', progress: 100, total: 100 });
25
+ describe('Core Functionality', () => {
26
+ it('should perform review with required content parameter', async () => {
27
+ // Setup mocks
28
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
29
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review\n\nLooks good!');
30
+ // Execute
31
+ const result = await performCodeReview({
32
+ content: mockDiff
33
+ });
34
+ // Verify
35
+ expect(result).toContain('# Review');
36
+ expect(result).toContain('Looks good!');
37
+ expect(invokeAgentModule.invokeAgent).toHaveBeenCalled();
38
+ });
39
+ it('should throw error when content is missing', async () => {
40
+ await expect(performCodeReview({ content: '' })).rejects.toThrow('content is required');
41
+ });
42
+ it('should throw error when content is only whitespace', async () => {
43
+ await expect(performCodeReview({ content: ' \n \t ' })).rejects.toThrow('content is required');
44
+ });
45
45
  });
46
- it('should include line count in GPT-5 Codex progress message', async () => {
47
- vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
48
- vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
49
- vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review\n\nLooks good!');
50
- const progressCalls = [];
51
- const onProgress = vi.fn(async (message, progress) => {
52
- progressCalls.push({ message, progress });
53
- });
54
- await performCodeReview({ target: 'auto' }, onProgress);
55
- const codexCall = progressCalls.find(call => call.message.includes('Calling GPT-5 Codex'));
56
- expect(codexCall?.message).toMatch(/\+\d+ -\d+\)/);
46
+ describe('Progress Callbacks', () => {
47
+ it('should call progress callback with correct sequence', async () => {
48
+ // Setup mocks
49
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
50
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review\n\nLooks good!');
51
+ // Track progress calls
52
+ const progressCalls = [];
53
+ const onProgress = vi.fn(async (message, progress, total) => {
54
+ progressCalls.push({ message, progress, total });
55
+ });
56
+ // Execute
57
+ await performCodeReview({ content: mockDiff }, onProgress);
58
+ // Verify progress sequence
59
+ expect(progressCalls.length).toBeGreaterThan(0);
60
+ expect(progressCalls[0]).toMatchObject({ message: 'Preparing review…', progress: 10 });
61
+ expect(progressCalls[progressCalls.length - 1]).toMatchObject({ message: 'Done', progress: 100 });
62
+ });
57
63
  });
58
- it('should throw error when diff is empty', async () => {
59
- vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue('');
60
- await expect(performCodeReview({ target: 'auto' })).rejects.toThrow('No changes to review');
64
+ describe('Context Management', () => {
65
+ it('should use customContext when provided', async () => {
66
+ const customContext = '# Project Rules\n\nUse strict mode';
67
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('should not be called');
68
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
69
+ const buildPromptSpy = vi.spyOn(buildPromptModule, 'buildPrompt').mockReturnValue('prompt');
70
+ await performCodeReview({
71
+ content: mockDiff,
72
+ customContext
73
+ });
74
+ // Verify gatherContext was NOT called
75
+ expect(gatherContextModule.gatherContext).not.toHaveBeenCalled();
76
+ // Verify buildPrompt received customContext
77
+ expect(buildPromptSpy).toHaveBeenCalledWith(expect.objectContaining({ context: customContext }));
78
+ });
79
+ it('should skip context gathering when skipContextGathering is true', async () => {
80
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('should not be called');
81
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
82
+ const buildPromptSpy = vi.spyOn(buildPromptModule, 'buildPrompt').mockReturnValue('prompt');
83
+ await performCodeReview({
84
+ content: mockDiff,
85
+ skipContextGathering: true
86
+ });
87
+ // Verify gatherContext was NOT called
88
+ expect(gatherContextModule.gatherContext).not.toHaveBeenCalled();
89
+ // Verify buildPrompt received empty context
90
+ expect(buildPromptSpy).toHaveBeenCalledWith(expect.objectContaining({ context: '' }));
91
+ });
92
+ it('should auto-gather context when no customContext and not skipped', async () => {
93
+ const autoContext = '# Auto-gathered\n\nConfig files';
94
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue(autoContext);
95
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
96
+ const buildPromptSpy = vi.spyOn(buildPromptModule, 'buildPrompt').mockReturnValue('prompt');
97
+ await performCodeReview({
98
+ content: mockDiff
99
+ });
100
+ // Verify gatherContext WAS called
101
+ expect(gatherContextModule.gatherContext).toHaveBeenCalled();
102
+ // Verify buildPrompt received auto-gathered context
103
+ expect(buildPromptSpy).toHaveBeenCalledWith(expect.objectContaining({ context: autoContext }));
104
+ });
105
+ it('should pass workspaceDir to gatherContext', async () => {
106
+ const workspaceDir = '/custom/workspace';
107
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
108
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
109
+ await performCodeReview({
110
+ content: mockDiff,
111
+ workspaceDir
112
+ });
113
+ expect(gatherContextModule.gatherContext).toHaveBeenCalledWith(workspaceDir);
114
+ });
61
115
  });
62
- it('should propagate errors from invokeAgent', async () => {
63
- vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
64
- vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
65
- vi.spyOn(invokeAgentModule, 'invokeAgent').mockRejectedValue(new Error('OpenAI API error: Rate limit exceeded'));
66
- await expect(performCodeReview({ target: 'auto' })).rejects.toThrow('OpenAI API error');
116
+ describe('Content Types', () => {
117
+ it('should handle diff content type', async () => {
118
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
119
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
120
+ const buildPromptSpy = vi.spyOn(buildPromptModule, 'buildPrompt').mockReturnValue('prompt');
121
+ await performCodeReview({
122
+ content: mockDiff,
123
+ contentType: 'diff'
124
+ });
125
+ expect(buildPromptSpy).toHaveBeenCalledWith(expect.objectContaining({ isStaticReview: false }));
126
+ });
127
+ it('should handle code content type', async () => {
128
+ const codeContent = 'export function test() { return true; }';
129
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
130
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
131
+ const buildPromptSpy = vi.spyOn(buildPromptModule, 'buildPrompt').mockReturnValue('prompt');
132
+ await performCodeReview({
133
+ content: codeContent,
134
+ contentType: 'code'
135
+ });
136
+ expect(buildPromptSpy).toHaveBeenCalledWith(expect.objectContaining({ isStaticReview: true }));
137
+ });
67
138
  });
68
- it('should call collectDiff with correct workspaceDir', async () => {
69
- const collectDiffSpy = vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
70
- vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
71
- vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
72
- await performCodeReview({ target: 'auto', workspaceDir: '/test/path' });
73
- expect(collectDiffSpy).toHaveBeenCalledWith(expect.objectContaining({ target: 'auto', workspaceDir: '/test/path' }), '/test/path');
139
+ describe('Review Parameters', () => {
140
+ it('should pass focus parameter to buildPrompt', async () => {
141
+ const focus = 'security and performance';
142
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
143
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
144
+ const buildPromptSpy = vi.spyOn(buildPromptModule, 'buildPrompt').mockReturnValue('prompt');
145
+ await performCodeReview({
146
+ content: mockDiff,
147
+ focus
148
+ });
149
+ expect(buildPromptSpy).toHaveBeenCalledWith(expect.objectContaining({ focus }));
150
+ });
151
+ it('should pass maxTokens to invokeAgent', async () => {
152
+ const maxTokens = 4000;
153
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
154
+ const invokeAgentSpy = vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
155
+ await performCodeReview({
156
+ content: mockDiff,
157
+ maxTokens
158
+ });
159
+ expect(invokeAgentSpy).toHaveBeenCalledWith(expect.objectContaining({ maxTokens }));
160
+ });
161
+ it('should pass workspaceDir to invokeAgent', async () => {
162
+ const workspaceDir = '/test/workspace';
163
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
164
+ const invokeAgentSpy = vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
165
+ await performCodeReview({
166
+ content: mockDiff,
167
+ workspaceDir
168
+ });
169
+ expect(invokeAgentSpy).toHaveBeenCalledWith(expect.objectContaining({ workspaceDir }));
170
+ });
74
171
  });
75
- it('should handle all target modes', async () => {
76
- vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
77
- vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
78
- vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
79
- const targets = ['auto', 'staged', 'head', 'range'];
80
- for (const target of targets) {
81
- const input = target === 'range'
82
- ? { target, baseRef: 'main', headRef: 'feature' }
83
- : { target };
84
- await performCodeReview(input);
85
- expect(collectDiffModule.collectDiff).toHaveBeenCalled();
86
- }
172
+ describe('Version Tracking', () => {
173
+ it('should include version in buildPrompt', async () => {
174
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
175
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
176
+ const buildPromptSpy = vi.spyOn(buildPromptModule, 'buildPrompt').mockReturnValue('prompt');
177
+ await performCodeReview({
178
+ content: mockDiff
179
+ });
180
+ expect(buildPromptSpy).toHaveBeenCalledWith(expect.objectContaining({ version: '2.0.0' }));
181
+ });
87
182
  });
88
- it('should handle diffs with untracked files', async () => {
89
- const diffWithUntracked = `diff --git a/tracked.ts b/tracked.ts
90
- +++ b/tracked.ts
91
- +modified tracked file
92
- diff --git a/newfile.ts b/newfile.ts
93
- new file mode 100644
94
- +++ b/newfile.ts
95
- +new untracked file`;
96
- vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(diffWithUntracked);
97
- vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
98
- vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
99
- const result = await performCodeReview({ target: 'auto' });
100
- expect(result).toBeTruthy();
101
- expect(collectDiffModule.collectDiff).toHaveBeenCalledWith(expect.objectContaining({ target: 'auto' }), undefined);
183
+ describe('Progress Messages', () => {
184
+ it('should show correct message when using customContext', async () => {
185
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
186
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
187
+ const progressCalls = [];
188
+ const onProgress = vi.fn(async (message) => {
189
+ progressCalls.push(message);
190
+ });
191
+ await performCodeReview({
192
+ content: mockDiff,
193
+ customContext: 'rules'
194
+ }, onProgress);
195
+ expect(progressCalls).toContain('Using provided project context…');
196
+ });
197
+ it('should show correct message when skipping context', async () => {
198
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
199
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
200
+ const progressCalls = [];
201
+ const onProgress = vi.fn(async (message) => {
202
+ progressCalls.push(message);
203
+ });
204
+ await performCodeReview({
205
+ content: mockDiff,
206
+ skipContextGathering: true
207
+ }, onProgress);
208
+ expect(progressCalls).toContain('Skipping context gathering…');
209
+ });
210
+ it('should show correct message when auto-gathering context', async () => {
211
+ vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
212
+ vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
213
+ const progressCalls = [];
214
+ const onProgress = vi.fn(async (message) => {
215
+ progressCalls.push(message);
216
+ });
217
+ await performCodeReview({
218
+ content: mockDiff
219
+ }, onProgress);
220
+ expect(progressCalls).toContain('Gathering project context…');
221
+ });
102
222
  });
103
223
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-review-mcp",
3
- "version": "1.3.1",
3
+ "version": "2.0.0",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "build": "tsc",
@@ -58,6 +58,7 @@
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^24.7.0",
61
+ "@vitest/coverage-v8": "^3.2.4",
61
62
  "@vitest/ui": "^3.2.4",
62
63
  "typescript": "^5.9.3",
63
64
  "vitest": "^3.2.4"
@@ -1,192 +0,0 @@
1
- import { execFile } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- import { promises as fs } from 'node:fs';
4
- import { dirname, join } from 'node:path';
5
- import { minimatch } from 'minimatch';
6
- const exec = promisify(execFile);
7
- const DEFAULT_IGNORES = [
8
- '**/dist/**',
9
- '**/build/**',
10
- '**/*.lock',
11
- ];
12
- function filterPaths(paths) {
13
- if (!paths || paths.length === 0)
14
- return undefined;
15
- return paths.filter((p) => !DEFAULT_IGNORES.some((g) => minimatch(p, g)));
16
- }
17
- export async function collectDiff(input, workspaceDir) {
18
- async function findRepoRoot(startDir) {
19
- let dir = startDir;
20
- // Walk up to filesystem root (max ~25 hops as a safety guard)
21
- for (let i = 0; i < 25; i++) {
22
- try {
23
- // Accept both .git directory and file (worktree) as signal of repo root
24
- await fs.stat(join(dir, '.git'));
25
- return dir;
26
- }
27
- catch {
28
- const parent = dirname(dir);
29
- if (parent === dir)
30
- break;
31
- dir = parent;
32
- }
33
- }
34
- return null;
35
- }
36
- async function detectDefaultBranch(repoRoot) {
37
- // Try to get default branch from remote
38
- try {
39
- const { stdout } = await exec('git', ['remote', 'show', 'origin'], { cwd: repoRoot, encoding: 'utf8' });
40
- const match = stdout.match(/HEAD branch:\s*(\S+)/);
41
- if (match?.[1])
42
- return match[1];
43
- }
44
- catch {
45
- // Remote not available or other error, continue to fallback
46
- }
47
- // Fallback: check if main or master exists locally
48
- for (const branch of ['main', 'master']) {
49
- try {
50
- await exec('git', ['rev-parse', '--verify', branch], { cwd: repoRoot });
51
- return branch;
52
- }
53
- catch {
54
- // Branch doesn't exist, try next
55
- }
56
- }
57
- // Last resort: use HEAD~1 as baseline if it exists
58
- try {
59
- await exec('git', ['rev-parse', '--verify', 'HEAD~1'], { cwd: repoRoot });
60
- return 'HEAD~1';
61
- }
62
- catch {
63
- return null;
64
- }
65
- }
66
- async function hasUncommittedChanges(repoRoot) {
67
- try {
68
- const { stdout } = await exec('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' });
69
- return stdout.trim().length > 0;
70
- }
71
- catch {
72
- return false;
73
- }
74
- }
75
- async function getCurrentBranch(repoRoot) {
76
- try {
77
- const { stdout } = await exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' });
78
- return stdout.trim();
79
- }
80
- catch {
81
- return null;
82
- }
83
- }
84
- async function hasHeadCommit(repoRoot) {
85
- try {
86
- await exec('git', ['rev-parse', '--verify', 'HEAD'], { cwd: repoRoot });
87
- return true;
88
- }
89
- catch {
90
- return false;
91
- }
92
- }
93
- // Priority order: explicit workspaceDir param > env vars > process.cwd()
94
- const preferredStart = workspaceDir || process.env.CODEX_REPO_ROOT || process.env.WORKSPACE_ROOT || process.env.INIT_CWD || process.cwd();
95
- const preferredRoot = await findRepoRoot(preferredStart);
96
- // If workspaceDir was explicitly provided but no repo found, fail immediately
97
- if (workspaceDir && !preferredRoot) {
98
- throw new Error(`Could not locate a Git repository starting from workspaceDir "${workspaceDir}".`);
99
- }
100
- const repoRoot = preferredRoot || (await findRepoRoot(process.cwd())) || process.cwd();
101
- const args = ['diff', '--unified=0'];
102
- if (input.target === 'auto') {
103
- // Auto mode: detect what to review
104
- const hasChanges = await hasUncommittedChanges(repoRoot);
105
- if (hasChanges) {
106
- // Review uncommitted changes vs HEAD (or staged if no commits yet)
107
- if (await hasHeadCommit(repoRoot)) {
108
- args.push('HEAD');
109
- }
110
- else {
111
- args.splice(1, 0, '--staged');
112
- }
113
- }
114
- else {
115
- // No uncommitted changes, review branch vs default
116
- const currentBranch = await getCurrentBranch(repoRoot);
117
- const defaultBranch = await detectDefaultBranch(repoRoot);
118
- if (!defaultBranch) {
119
- // Can't determine default branch - nothing to review
120
- return '';
121
- }
122
- if (currentBranch === defaultBranch) {
123
- // On default branch with no changes - nothing to review
124
- return '';
125
- }
126
- // Review current branch vs default branch
127
- args.push(`${defaultBranch}...HEAD`);
128
- }
129
- }
130
- else {
131
- // Explicit target modes
132
- switch (input.target) {
133
- case 'staged':
134
- args.splice(1, 0, '--staged');
135
- break;
136
- case 'head':
137
- args.push('HEAD');
138
- break;
139
- case 'range':
140
- if (!input.baseRef || !input.headRef) {
141
- throw new Error('range target requires baseRef and headRef');
142
- }
143
- args.push(`${input.baseRef}...${input.headRef}`);
144
- break;
145
- }
146
- }
147
- const filtered = filterPaths(input.paths);
148
- if (filtered && filtered.length)
149
- args.push(...filtered);
150
- try {
151
- const { stdout } = await exec('git', args, {
152
- encoding: 'utf8',
153
- maxBuffer: 10 * 1024 * 1024,
154
- cwd: repoRoot,
155
- });
156
- let diffText = stdout;
157
- // In auto mode with uncommitted changes, also include untracked files
158
- if (input.target === 'auto' && await hasUncommittedChanges(repoRoot)) {
159
- try {
160
- const { stdout: untrackedFiles } = await exec('git', ['ls-files', '--others', '--exclude-standard'], { encoding: 'utf8', cwd: repoRoot });
161
- const files = untrackedFiles.trim().split('\n').filter(f => f);
162
- for (const file of files) {
163
- try {
164
- const { stdout: fileDiff } = await exec('git', ['diff', '--no-index', '--unified=0', '/dev/null', file], { encoding: 'utf8', cwd: repoRoot });
165
- diffText += '\n' + fileDiff;
166
- }
167
- catch (diffErr) {
168
- // git diff --no-index returns exit code 1 when there are differences
169
- if (diffErr.stdout) {
170
- diffText += '\n' + diffErr.stdout;
171
- }
172
- }
173
- }
174
- }
175
- catch {
176
- // If we can't get untracked files, continue with just tracked changes
177
- }
178
- }
179
- // Drop obvious binary diffs
180
- const text = diffText
181
- .split('\n')
182
- .filter((line) => !line.startsWith('Binary files '))
183
- .join('\n');
184
- // Enforce size cap ~300k chars
185
- const cap = 300_000;
186
- return text.length > cap ? text.slice(0, cap) + '\n<!-- diff truncated -->\n' : text;
187
- }
188
- catch (err) {
189
- const msg = err?.stderr || err?.message || String(err);
190
- throw new Error(`Failed to collect git diff: ${msg}`);
191
- }
192
- }