codex-review-mcp 1.4.0 → 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.
- package/dist/mcp-server.js +68 -15
- package/dist/review/buildPrompt.js +6 -2
- package/dist/review/buildPrompt.test.js +113 -0
- package/dist/review/collectDiff.test.js +490 -0
- package/dist/review/formatOutput.test.js +159 -0
- package/dist/review/gatherContext.js +2 -2
- package/dist/review/gatherContext.test.js +334 -0
- package/dist/review/invokeAgent.js +39 -3
- package/dist/review/invokeAgent.test.js +31 -0
- package/dist/tools/performCodeReview.js +52 -15
- package/dist/tools/performCodeReview.test.js +196 -76
- package/package.json +2 -1
- package/dist/review/collectDiff.js +0 -192
@@ -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
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
progressCalls
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
-
|
59
|
-
|
60
|
-
|
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
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
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
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
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
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
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
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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": "
|
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
|
-
}
|