bosun 0.42.0 → 0.42.2
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/.env.example +12 -0
- package/README.md +2 -0
- package/agent/agent-pool.mjs +34 -1
- package/agent/agent-work-report.mjs +89 -3
- package/agent/analyze-agent-work-helpers.mjs +14 -0
- package/agent/analyze-agent-work.mjs +23 -3
- package/agent/primary-agent.mjs +23 -1
- package/bosun-tui.mjs +4 -3
- package/bosun.schema.json +1 -1
- package/config/config.mjs +58 -0
- package/config/workspace-health.mjs +36 -6
- package/git/diff-stats.mjs +550 -124
- package/github/github-app-auth.mjs +9 -5
- package/infra/maintenance.mjs +13 -6
- package/infra/monitor.mjs +398 -10
- package/infra/runtime-accumulator.mjs +9 -1
- package/infra/session-tracker.mjs +163 -1
- package/infra/tui-bridge.mjs +415 -0
- package/infra/worktree-recovery-state.mjs +159 -0
- package/kanban/kanban-adapter.mjs +41 -8
- package/lib/repo-map.mjs +411 -0
- package/package.json +140 -137
- package/server/ui-server.mjs +953 -59
- package/shell/codex-config.mjs +34 -8
- package/task/task-cli.mjs +93 -19
- package/task/task-executor.mjs +397 -8
- package/task/task-store.mjs +194 -1
- package/telegram/telegram-bot.mjs +267 -18
- package/tools/vitest-runner.mjs +108 -0
- package/tui/app.mjs +252 -148
- package/tui/components/status-header.mjs +88 -131
- package/tui/lib/ws-bridge.mjs +125 -35
- package/tui/screens/agents-screen-helpers.mjs +219 -0
- package/tui/screens/agents.mjs +287 -270
- package/tui/screens/status.mjs +51 -189
- package/tui/screens/tasks.mjs +41 -253
- package/ui/app.js +52 -23
- package/ui/components/chat-view.js +263 -84
- package/ui/components/diff-viewer.js +324 -140
- package/ui/components/kanban-board.js +13 -9
- package/ui/components/session-list.js +111 -41
- package/ui/demo-defaults.js +481 -59
- package/ui/demo.html +32 -0
- package/ui/modules/session-api.js +320 -5
- package/ui/modules/stream-timeline.js +356 -0
- package/ui/modules/telegram.js +5 -2
- package/ui/modules/worktree-recovery.js +85 -0
- package/ui/styles.css +44 -0
- package/ui/tabs/chat.js +19 -4
- package/ui/tabs/dashboard.js +22 -0
- package/ui/tabs/infra.js +25 -0
- package/ui/tabs/tasks.js +119 -11
- package/voice/voice-auth-manager.mjs +10 -5
- package/workflow/workflow-engine.mjs +179 -1
- package/workflow/workflow-nodes.mjs +872 -16
- package/workflow/workflow-templates.mjs +4 -0
- package/workflow-templates/github.mjs +2 -1
- package/workflow-templates/planning.mjs +2 -1
- package/workflow-templates/sub-workflows.mjs +10 -0
- package/workflow-templates/task-batch.mjs +9 -8
- package/workflow-templates/task-execution.mjs +30 -12
- package/workflow-templates/task-lifecycle.mjs +59 -4
- package/workspace/shared-knowledge.mjs +409 -155
package/git/diff-stats.mjs
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* diff-stats.mjs — Collects git diff statistics for review
|
|
3
|
-
*
|
|
4
|
-
* Produces a tree of file changes with +/- line counts, like:
|
|
5
|
-
* monitor.mjs +890 -750
|
|
6
|
-
* task-executor.mjs +320 -180
|
|
7
|
-
* review-agent.mjs +680 -528
|
|
2
|
+
* diff-stats.mjs — Collects git diff statistics and patch hunks for review UIs.
|
|
8
3
|
*
|
|
9
4
|
* @module diff-stats
|
|
10
5
|
*/
|
|
@@ -13,12 +8,38 @@ import { spawnSync } from "node:child_process";
|
|
|
13
8
|
|
|
14
9
|
const TAG = "[diff-stats]";
|
|
15
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} DiffLine
|
|
13
|
+
* @property {"context"|"addition"|"deletion"|"meta"} type
|
|
14
|
+
* @property {number|null} oldNumber
|
|
15
|
+
* @property {number|null} newNumber
|
|
16
|
+
* @property {string} marker
|
|
17
|
+
* @property {string} content
|
|
18
|
+
* @property {string} raw
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} DiffHunk
|
|
23
|
+
* @property {string} header
|
|
24
|
+
* @property {number} oldStart
|
|
25
|
+
* @property {number} oldLines
|
|
26
|
+
* @property {number} newStart
|
|
27
|
+
* @property {number} newLines
|
|
28
|
+
* @property {DiffLine[]} lines
|
|
29
|
+
*/
|
|
30
|
+
|
|
16
31
|
/**
|
|
17
32
|
* @typedef {Object} FileChangeStats
|
|
18
|
-
* @property {string}
|
|
19
|
-
* @property {
|
|
20
|
-
* @property {
|
|
21
|
-
* @property {
|
|
33
|
+
* @property {string} file
|
|
34
|
+
* @property {string} filename
|
|
35
|
+
* @property {string} oldFilename
|
|
36
|
+
* @property {string} newFilename
|
|
37
|
+
* @property {string} status
|
|
38
|
+
* @property {number} additions
|
|
39
|
+
* @property {number} deletions
|
|
40
|
+
* @property {boolean} binary
|
|
41
|
+
* @property {string} patch
|
|
42
|
+
* @property {DiffHunk[]} hunks
|
|
22
43
|
*/
|
|
23
44
|
|
|
24
45
|
/**
|
|
@@ -27,70 +48,50 @@ const TAG = "[diff-stats]";
|
|
|
27
48
|
* @property {number} totalFiles
|
|
28
49
|
* @property {number} totalAdditions
|
|
29
50
|
* @property {number} totalDeletions
|
|
30
|
-
* @property {string} formatted
|
|
51
|
+
* @property {string} formatted
|
|
52
|
+
* @property {string} [sourceRange]
|
|
31
53
|
*/
|
|
32
54
|
|
|
33
|
-
// ── Main Collectors ─────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
55
|
/**
|
|
36
|
-
* Collect diff stats for a worktree
|
|
56
|
+
* Collect diff stats for a worktree and optionally include parsed hunks.
|
|
37
57
|
*
|
|
38
|
-
*
|
|
39
|
-
* 1. `git diff --numstat origin/main...HEAD`
|
|
40
|
-
* 2. `git diff --numstat HEAD~10...HEAD` (last 10 commits)
|
|
41
|
-
* 3. `git diff --stat origin/main...HEAD` (parsed stat output)
|
|
42
|
-
*
|
|
43
|
-
* @param {string} worktreePath - Path to the git worktree
|
|
58
|
+
* @param {string} worktreePath
|
|
44
59
|
* @param {Object} [options]
|
|
45
|
-
* @param {string} [options.baseBranch="origin/main"]
|
|
60
|
+
* @param {string} [options.baseBranch="origin/main"]
|
|
61
|
+
* @param {string} [options.targetRef="HEAD"]
|
|
62
|
+
* @param {string} [options.range]
|
|
46
63
|
* @param {number} [options.timeoutMs=30000]
|
|
64
|
+
* @param {boolean} [options.includePatch=false]
|
|
65
|
+
* @param {number} [options.contextLines=3]
|
|
47
66
|
* @returns {DiffStats}
|
|
48
67
|
*/
|
|
49
68
|
export function collectDiffStats(worktreePath, options = {}) {
|
|
50
69
|
const {
|
|
51
70
|
baseBranch = "origin/main",
|
|
71
|
+
targetRef = "HEAD",
|
|
72
|
+
range = "",
|
|
52
73
|
timeoutMs = 30_000,
|
|
74
|
+
includePatch = false,
|
|
75
|
+
contextLines = 3,
|
|
53
76
|
} = options;
|
|
54
77
|
|
|
55
|
-
|
|
56
|
-
const numstat = tryNumstat(worktreePath, `${baseBranch}...HEAD`, timeoutMs);
|
|
57
|
-
if (numstat) return buildResult(numstat);
|
|
78
|
+
const candidates = buildRangeCandidates({ baseBranch, targetRef, range });
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// Strategy 4: staged + unstaged changes
|
|
68
|
-
const working = tryNumstat(worktreePath, "HEAD", timeoutMs);
|
|
69
|
-
if (working) return buildResult(working);
|
|
80
|
+
for (const candidate of candidates) {
|
|
81
|
+
const result = collectDiffForRange(worktreePath, candidate, {
|
|
82
|
+
timeoutMs,
|
|
83
|
+
includePatch,
|
|
84
|
+
contextLines,
|
|
85
|
+
});
|
|
86
|
+
if (result) return result;
|
|
87
|
+
}
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
files: [],
|
|
74
|
-
totalFiles: 0,
|
|
75
|
-
totalAdditions: 0,
|
|
76
|
-
totalDeletions: 0,
|
|
77
|
-
formatted: "(no diff stats available)",
|
|
78
|
-
};
|
|
89
|
+
return emptyDiffStats("(no diff stats available)");
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
/**
|
|
82
93
|
* Get a compact string summary of diff stats.
|
|
83
94
|
*
|
|
84
|
-
* Example output:
|
|
85
|
-
* ```
|
|
86
|
-
* 5 files changed, +1250 -890
|
|
87
|
-
* review-agent.mjs +680 -528
|
|
88
|
-
* task-executor.mjs +320 -180
|
|
89
|
-
* monitor.mjs +150 -82
|
|
90
|
-
* session-tracker.mjs +370 -0
|
|
91
|
-
* diff-stats.mjs +380 -0
|
|
92
|
-
* ```
|
|
93
|
-
*
|
|
94
95
|
* @param {string} worktreePath
|
|
95
96
|
* @param {Object} [options]
|
|
96
97
|
* @returns {string}
|
|
@@ -101,15 +102,14 @@ export function getCompactDiffSummary(worktreePath, options = {}) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
/**
|
|
104
|
-
* Get the recent commits on the current branch (vs origin/main).
|
|
105
|
+
* Get the recent commits on the current branch (vs origin/main when available).
|
|
105
106
|
*
|
|
106
107
|
* @param {string} worktreePath
|
|
107
108
|
* @param {number} [maxCommits=10]
|
|
108
|
-
* @returns {string[]}
|
|
109
|
+
* @returns {string[]}
|
|
109
110
|
*/
|
|
110
111
|
export function getRecentCommits(worktreePath, maxCommits = 10) {
|
|
111
112
|
try {
|
|
112
|
-
// Try vs origin/main first
|
|
113
113
|
const result = spawnSync(
|
|
114
114
|
"git",
|
|
115
115
|
["log", "--oneline", `--max-count=${maxCommits}`, "origin/main..HEAD"],
|
|
@@ -120,7 +120,6 @@ export function getRecentCommits(worktreePath, maxCommits = 10) {
|
|
|
120
120
|
return result.stdout.trim().split("\n").filter(Boolean);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
// Fallback: last N commits on current branch
|
|
124
123
|
const fallback = spawnSync(
|
|
125
124
|
"git",
|
|
126
125
|
["log", "--oneline", `--max-count=${maxCommits}`],
|
|
@@ -137,20 +136,267 @@ export function getRecentCommits(worktreePath, maxCommits = 10) {
|
|
|
137
136
|
return [];
|
|
138
137
|
}
|
|
139
138
|
|
|
140
|
-
// ── Internal Strategies ─────────────────────────────────────────────────────
|
|
141
|
-
|
|
142
139
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* @param {string}
|
|
146
|
-
* @
|
|
147
|
-
* @returns {FileChangeStats[]|null}
|
|
140
|
+
* Parse a unified diff string into file + hunk structures suitable for review UIs.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} rawDiff
|
|
143
|
+
* @returns {FileChangeStats[]}
|
|
148
144
|
*/
|
|
145
|
+
export function parseUnifiedDiff(rawDiff) {
|
|
146
|
+
if (!rawDiff || !String(rawDiff).trim()) return [];
|
|
147
|
+
const text = String(rawDiff).replace(/\r\n/g, "\n");
|
|
148
|
+
const lines = text.split("\n");
|
|
149
|
+
|
|
150
|
+
const files = [];
|
|
151
|
+
let currentFile = null;
|
|
152
|
+
let currentHunk = null;
|
|
153
|
+
|
|
154
|
+
const pushCurrentFile = () => {
|
|
155
|
+
if (!currentFile) return;
|
|
156
|
+
finalizeParsedFile(currentFile);
|
|
157
|
+
files.push(currentFile);
|
|
158
|
+
currentFile = null;
|
|
159
|
+
currentHunk = null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
if (line.startsWith("diff --git ")) {
|
|
164
|
+
pushCurrentFile();
|
|
165
|
+
const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
166
|
+
currentFile = {
|
|
167
|
+
file: "",
|
|
168
|
+
filename: "",
|
|
169
|
+
oldFilename: match?.[1] || "",
|
|
170
|
+
newFilename: match?.[2] || "",
|
|
171
|
+
status: "modified",
|
|
172
|
+
additions: 0,
|
|
173
|
+
deletions: 0,
|
|
174
|
+
binary: false,
|
|
175
|
+
patch: "",
|
|
176
|
+
hunks: [],
|
|
177
|
+
headers: [line],
|
|
178
|
+
patchLines: [line],
|
|
179
|
+
};
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!currentFile) continue;
|
|
184
|
+
currentFile.patchLines.push(line);
|
|
185
|
+
|
|
186
|
+
if (line.startsWith("new file mode ")) {
|
|
187
|
+
currentFile.status = "added";
|
|
188
|
+
currentFile.headers.push(line);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (line.startsWith("deleted file mode ")) {
|
|
192
|
+
currentFile.status = "deleted";
|
|
193
|
+
currentFile.headers.push(line);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (line.startsWith("rename from ")) {
|
|
197
|
+
currentFile.status = "renamed";
|
|
198
|
+
currentFile.oldFilename = line.slice("rename from ".length).trim();
|
|
199
|
+
currentFile.headers.push(line);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (line.startsWith("rename to ")) {
|
|
203
|
+
currentFile.status = "renamed";
|
|
204
|
+
currentFile.newFilename = line.slice("rename to ".length).trim();
|
|
205
|
+
currentFile.headers.push(line);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (line.startsWith("copy from ")) {
|
|
209
|
+
currentFile.status = "copied";
|
|
210
|
+
currentFile.oldFilename = line.slice("copy from ".length).trim();
|
|
211
|
+
currentFile.headers.push(line);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (line.startsWith("copy to ")) {
|
|
215
|
+
currentFile.status = "copied";
|
|
216
|
+
currentFile.newFilename = line.slice("copy to ".length).trim();
|
|
217
|
+
currentFile.headers.push(line);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (line.startsWith("Binary files ") || line === "GIT binary patch") {
|
|
221
|
+
currentFile.binary = true;
|
|
222
|
+
currentFile.headers.push(line);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (line.startsWith("index ") || line.startsWith("similarity index ") || line.startsWith("dissimilarity index ")) {
|
|
226
|
+
currentFile.headers.push(line);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (line.startsWith("--- ")) {
|
|
230
|
+
currentFile.oldFilename = normalizeDiffPath(parsePatchFilename(line.slice(4)));
|
|
231
|
+
currentFile.headers.push(line);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (line.startsWith("+++ ")) {
|
|
235
|
+
currentFile.newFilename = normalizeDiffPath(parsePatchFilename(line.slice(4)));
|
|
236
|
+
currentFile.headers.push(line);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (line.startsWith("@@ ")) {
|
|
240
|
+
currentHunk = createHunk(line);
|
|
241
|
+
currentFile.hunks.push(currentHunk);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!currentHunk) {
|
|
246
|
+
currentFile.headers.push(line);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
251
|
+
currentFile.additions += 1;
|
|
252
|
+
currentHunk.lines.push({
|
|
253
|
+
type: "addition",
|
|
254
|
+
oldNumber: null,
|
|
255
|
+
newNumber: currentHunk.nextNewNumber,
|
|
256
|
+
marker: "+",
|
|
257
|
+
content: line.slice(1),
|
|
258
|
+
raw: line,
|
|
259
|
+
});
|
|
260
|
+
currentHunk.nextNewNumber += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
264
|
+
currentFile.deletions += 1;
|
|
265
|
+
currentHunk.lines.push({
|
|
266
|
+
type: "deletion",
|
|
267
|
+
oldNumber: currentHunk.nextOldNumber,
|
|
268
|
+
newNumber: null,
|
|
269
|
+
marker: "-",
|
|
270
|
+
content: line.slice(1),
|
|
271
|
+
raw: line,
|
|
272
|
+
});
|
|
273
|
+
currentHunk.nextOldNumber += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (line.startsWith("\")) {
|
|
277
|
+
currentHunk.lines.push({
|
|
278
|
+
type: "meta",
|
|
279
|
+
oldNumber: null,
|
|
280
|
+
newNumber: null,
|
|
281
|
+
marker: "\\",
|
|
282
|
+
content: line,
|
|
283
|
+
raw: line,
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const content = line.startsWith(" ") ? line.slice(1) : line;
|
|
289
|
+
currentHunk.lines.push({
|
|
290
|
+
type: "context",
|
|
291
|
+
oldNumber: currentHunk.nextOldNumber,
|
|
292
|
+
newNumber: currentHunk.nextNewNumber,
|
|
293
|
+
marker: " ",
|
|
294
|
+
content,
|
|
295
|
+
raw: line,
|
|
296
|
+
});
|
|
297
|
+
currentHunk.nextOldNumber += 1;
|
|
298
|
+
currentHunk.nextNewNumber += 1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
pushCurrentFile();
|
|
302
|
+
return files;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function emptyDiffStats(formatted) {
|
|
306
|
+
return {
|
|
307
|
+
files: [],
|
|
308
|
+
totalFiles: 0,
|
|
309
|
+
totalAdditions: 0,
|
|
310
|
+
totalDeletions: 0,
|
|
311
|
+
formatted,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildRangeCandidates({ baseBranch, targetRef, range }) {
|
|
316
|
+
if (String(range || "").trim()) {
|
|
317
|
+
return [{ range: String(range).trim() }];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const target = String(targetRef || "HEAD").trim() || "HEAD";
|
|
321
|
+
const targets = buildTargetCandidates(target);
|
|
322
|
+
const bases = buildBaseCandidates(baseBranch);
|
|
323
|
+
|
|
324
|
+
const candidates = [];
|
|
325
|
+
for (const base of bases) {
|
|
326
|
+
for (const candidateTarget of targets) {
|
|
327
|
+
candidates.push({ range: `${base}...${candidateTarget}` });
|
|
328
|
+
if (candidateTarget !== "HEAD") {
|
|
329
|
+
candidates.push({ range: `${base}..${candidateTarget}` });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (target === "HEAD") {
|
|
335
|
+
candidates.push({ range: "HEAD~10...HEAD" });
|
|
336
|
+
candidates.push({ range: "HEAD" });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return dedupeByRange(candidates);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function buildBaseCandidates(baseBranch) {
|
|
343
|
+
const raw = String(baseBranch || "").trim() || "origin/main";
|
|
344
|
+
const out = [raw];
|
|
345
|
+
if (raw.startsWith("origin/")) {
|
|
346
|
+
out.push(raw.slice("origin/".length));
|
|
347
|
+
} else if (!raw.includes("/")) {
|
|
348
|
+
out.push(`origin/${raw}`);
|
|
349
|
+
}
|
|
350
|
+
return dedupeStrings(out);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function buildTargetCandidates(targetRef) {
|
|
354
|
+
const raw = String(targetRef || "").trim() || "HEAD";
|
|
355
|
+
if (raw === "HEAD") return ["HEAD"];
|
|
356
|
+
const out = [raw];
|
|
357
|
+
if (!raw.includes("/")) out.push(`origin/${raw}`);
|
|
358
|
+
return dedupeStrings(out);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function dedupeStrings(values) {
|
|
362
|
+
return [...new Set(values.filter(Boolean))];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function dedupeByRange(items) {
|
|
366
|
+
const seen = new Set();
|
|
367
|
+
return items.filter((item) => {
|
|
368
|
+
const key = String(item?.range || "").trim();
|
|
369
|
+
if (!key || seen.has(key)) return false;
|
|
370
|
+
seen.add(key);
|
|
371
|
+
return true;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function collectDiffForRange(worktreePath, candidate, options = {}) {
|
|
376
|
+
const range = String(candidate?.range || "").trim();
|
|
377
|
+
if (!range) return null;
|
|
378
|
+
|
|
379
|
+
const patchFiles = options.includePatch
|
|
380
|
+
? tryPatch(worktreePath, range, options.timeoutMs, options.contextLines)
|
|
381
|
+
: null;
|
|
382
|
+
const statFiles =
|
|
383
|
+
tryNumstat(worktreePath, range, options.timeoutMs) ||
|
|
384
|
+
tryStat(worktreePath, range, options.timeoutMs) ||
|
|
385
|
+
[];
|
|
386
|
+
|
|
387
|
+
const combined = buildCombinedFiles(statFiles, patchFiles?.files || []);
|
|
388
|
+
if (!combined.length) return null;
|
|
389
|
+
|
|
390
|
+
return buildResult(combined, {
|
|
391
|
+
sourceRange: range,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
149
395
|
function tryNumstat(cwd, range, timeoutMs) {
|
|
150
396
|
try {
|
|
151
397
|
const result = spawnSync(
|
|
152
398
|
"git",
|
|
153
|
-
["diff", "--numstat", range],
|
|
399
|
+
["diff", "--find-renames", "--find-copies", "--numstat", range],
|
|
154
400
|
{ cwd, encoding: "utf8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"] },
|
|
155
401
|
);
|
|
156
402
|
|
|
@@ -159,46 +405,50 @@ function tryNumstat(cwd, range, timeoutMs) {
|
|
|
159
405
|
const files = [];
|
|
160
406
|
for (const line of result.stdout.trim().split("\n")) {
|
|
161
407
|
if (!line.trim()) continue;
|
|
162
|
-
|
|
163
408
|
const parts = line.split("\t");
|
|
164
409
|
if (parts.length < 3) continue;
|
|
165
|
-
|
|
166
410
|
const [addStr, delStr, ...fileParts] = parts;
|
|
167
|
-
const file = fileParts.join("\t");
|
|
168
|
-
|
|
411
|
+
const file = fileParts.join("\t");
|
|
169
412
|
if (addStr === "-" && delStr === "-") {
|
|
170
|
-
|
|
171
|
-
|
|
413
|
+
files.push({
|
|
414
|
+
file,
|
|
415
|
+
filename: file,
|
|
416
|
+
oldFilename: file,
|
|
417
|
+
newFilename: file,
|
|
418
|
+
status: "modified",
|
|
419
|
+
additions: 0,
|
|
420
|
+
deletions: 0,
|
|
421
|
+
binary: true,
|
|
422
|
+
patch: "",
|
|
423
|
+
hunks: [],
|
|
424
|
+
});
|
|
172
425
|
} else {
|
|
173
426
|
files.push({
|
|
174
427
|
file,
|
|
428
|
+
filename: file,
|
|
429
|
+
oldFilename: file,
|
|
430
|
+
newFilename: file,
|
|
431
|
+
status: "modified",
|
|
175
432
|
additions: parseInt(addStr, 10) || 0,
|
|
176
433
|
deletions: parseInt(delStr, 10) || 0,
|
|
177
434
|
binary: false,
|
|
435
|
+
patch: "",
|
|
436
|
+
hunks: [],
|
|
178
437
|
});
|
|
179
438
|
}
|
|
180
439
|
}
|
|
181
440
|
|
|
182
|
-
return files.length
|
|
441
|
+
return files.length ? files : null;
|
|
183
442
|
} catch {
|
|
184
443
|
return null;
|
|
185
444
|
}
|
|
186
445
|
}
|
|
187
446
|
|
|
188
|
-
/**
|
|
189
|
-
* Try `git diff --stat` and parse the output.
|
|
190
|
-
* Fallback when --numstat fails.
|
|
191
|
-
*
|
|
192
|
-
* @param {string} cwd
|
|
193
|
-
* @param {string} range
|
|
194
|
-
* @param {number} timeoutMs
|
|
195
|
-
* @returns {FileChangeStats[]|null}
|
|
196
|
-
*/
|
|
197
447
|
function tryStat(cwd, range, timeoutMs) {
|
|
198
448
|
try {
|
|
199
449
|
const result = spawnSync(
|
|
200
450
|
"git",
|
|
201
|
-
["diff", "--stat", range],
|
|
451
|
+
["diff", "--find-renames", "--find-copies", "--stat", range],
|
|
202
452
|
{ cwd, encoding: "utf8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"] },
|
|
203
453
|
);
|
|
204
454
|
|
|
@@ -206,77 +456,253 @@ function tryStat(cwd, range, timeoutMs) {
|
|
|
206
456
|
|
|
207
457
|
const files = [];
|
|
208
458
|
const lines = result.stdout.trim().split("\n");
|
|
209
|
-
|
|
210
|
-
// Last line is the summary — skip it
|
|
211
459
|
for (let i = 0; i < lines.length - 1; i++) {
|
|
212
460
|
const line = lines[i].trim();
|
|
213
461
|
if (!line) continue;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const file = line.slice(0, pipeIdx).trim();
|
|
220
|
-
const statsStr = line.slice(pipeIdx + 1).trim();
|
|
221
|
-
|
|
462
|
+
const pipeIndex = line.lastIndexOf("|");
|
|
463
|
+
if (pipeIndex === -1) continue;
|
|
464
|
+
const file = line.slice(0, pipeIndex).trim();
|
|
465
|
+
const statsStr = line.slice(pipeIndex + 1).trim();
|
|
222
466
|
if (statsStr.startsWith("Bin")) {
|
|
223
|
-
files.push({
|
|
467
|
+
files.push({
|
|
468
|
+
file,
|
|
469
|
+
filename: file,
|
|
470
|
+
oldFilename: file,
|
|
471
|
+
newFilename: file,
|
|
472
|
+
status: "modified",
|
|
473
|
+
additions: 0,
|
|
474
|
+
deletions: 0,
|
|
475
|
+
binary: true,
|
|
476
|
+
patch: "",
|
|
477
|
+
hunks: [],
|
|
478
|
+
});
|
|
224
479
|
} else {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
480
|
+
files.push({
|
|
481
|
+
file,
|
|
482
|
+
filename: file,
|
|
483
|
+
oldFilename: file,
|
|
484
|
+
newFilename: file,
|
|
485
|
+
status: "modified",
|
|
486
|
+
additions: (statsStr.match(/\+/g) || []).length,
|
|
487
|
+
deletions: (statsStr.match(/-/g) || []).length,
|
|
488
|
+
binary: false,
|
|
489
|
+
patch: "",
|
|
490
|
+
hunks: [],
|
|
491
|
+
});
|
|
229
492
|
}
|
|
230
493
|
}
|
|
231
494
|
|
|
232
|
-
return files.length
|
|
495
|
+
return files.length ? files : null;
|
|
233
496
|
} catch {
|
|
234
497
|
return null;
|
|
235
498
|
}
|
|
236
499
|
}
|
|
237
500
|
|
|
238
|
-
|
|
501
|
+
function tryPatch(cwd, range, timeoutMs, contextLines = 3) {
|
|
502
|
+
try {
|
|
503
|
+
const result = spawnSync(
|
|
504
|
+
"git",
|
|
505
|
+
[
|
|
506
|
+
"diff",
|
|
507
|
+
"--find-renames",
|
|
508
|
+
"--find-copies",
|
|
509
|
+
"--no-ext-diff",
|
|
510
|
+
"--no-color",
|
|
511
|
+
`--unified=${Math.max(0, Number(contextLines) || 3)}`,
|
|
512
|
+
range,
|
|
513
|
+
],
|
|
514
|
+
{ cwd, encoding: "utf8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"] },
|
|
515
|
+
);
|
|
239
516
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
517
|
+
if (result.status !== 0 || !(result.stdout || "").trim()) return null;
|
|
518
|
+
const rawPatch = result.stdout.replace(/\r\n/g, "\n");
|
|
519
|
+
return {
|
|
520
|
+
rawPatch,
|
|
521
|
+
files: parseUnifiedDiff(rawPatch),
|
|
522
|
+
};
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.warn(`${TAG} tryPatch error: ${err.message}`);
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function buildCombinedFiles(statFiles = [], patchFiles = []) {
|
|
530
|
+
const normalizedStatFiles = Array.isArray(statFiles) ? statFiles : [];
|
|
531
|
+
const normalizedPatchFiles = Array.isArray(patchFiles) ? patchFiles : [];
|
|
532
|
+
const byKey = new Map();
|
|
533
|
+
|
|
534
|
+
const upsert = (file) => {
|
|
535
|
+
if (!file) return;
|
|
536
|
+
const keys = buildFileLookupKeys(file);
|
|
537
|
+
let target = null;
|
|
538
|
+
for (const key of keys) {
|
|
539
|
+
if (byKey.has(key)) {
|
|
540
|
+
target = byKey.get(key);
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (!target) {
|
|
545
|
+
target = {
|
|
546
|
+
file: "",
|
|
547
|
+
filename: "",
|
|
548
|
+
oldFilename: "",
|
|
549
|
+
newFilename: "",
|
|
550
|
+
status: "modified",
|
|
551
|
+
additions: 0,
|
|
552
|
+
deletions: 0,
|
|
553
|
+
binary: false,
|
|
554
|
+
patch: "",
|
|
555
|
+
hunks: [],
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
mergeFileData(target, file);
|
|
559
|
+
for (const key of keys) {
|
|
560
|
+
byKey.set(key, target);
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
for (const patchFile of normalizedPatchFiles) upsert(patchFile);
|
|
565
|
+
for (const statFile of normalizedStatFiles) upsert(statFile);
|
|
566
|
+
|
|
567
|
+
const unique = [];
|
|
568
|
+
const seen = new Set();
|
|
569
|
+
for (const file of byKey.values()) {
|
|
570
|
+
finalizeParsedFile(file);
|
|
571
|
+
const key = buildStableFileKey(file);
|
|
572
|
+
if (seen.has(key)) continue;
|
|
573
|
+
seen.add(key);
|
|
574
|
+
unique.push(file);
|
|
575
|
+
}
|
|
576
|
+
return unique;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function mergeFileData(target, source) {
|
|
580
|
+
if (source.file) target.file = source.file;
|
|
581
|
+
if (source.filename) target.filename = source.filename;
|
|
582
|
+
if (source.oldFilename) target.oldFilename = source.oldFilename;
|
|
583
|
+
if (source.newFilename) target.newFilename = source.newFilename;
|
|
584
|
+
if (source.status && (target.status === "modified" || target.status === "unknown")) {
|
|
585
|
+
target.status = source.status;
|
|
586
|
+
}
|
|
587
|
+
if (source.status && source.status !== "modified") {
|
|
588
|
+
target.status = source.status;
|
|
589
|
+
}
|
|
590
|
+
if (typeof source.additions === "number") target.additions = Math.max(target.additions, source.additions);
|
|
591
|
+
if (typeof source.deletions === "number") target.deletions = Math.max(target.deletions, source.deletions);
|
|
592
|
+
target.binary = Boolean(target.binary || source.binary);
|
|
593
|
+
if (source.patch) target.patch = source.patch;
|
|
594
|
+
if (Array.isArray(source.hunks) && source.hunks.length) target.hunks = source.hunks;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function buildFileLookupKeys(file) {
|
|
598
|
+
const values = [
|
|
599
|
+
file?.filename,
|
|
600
|
+
file?.file,
|
|
601
|
+
file?.newFilename,
|
|
602
|
+
file?.oldFilename,
|
|
603
|
+
].map((value) => normalizeDiffPath(value)).filter(Boolean);
|
|
604
|
+
return values.length ? values : [buildStableFileKey(file)];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function buildStableFileKey(file) {
|
|
608
|
+
return normalizeDiffPath(file?.filename || file?.file || file?.newFilename || file?.oldFilename || "unknown");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function finalizeParsedFile(file) {
|
|
612
|
+
if (!file || typeof file !== "object") return file;
|
|
613
|
+
file.oldFilename = normalizeDiffPath(file.oldFilename);
|
|
614
|
+
file.newFilename = normalizeDiffPath(file.newFilename);
|
|
615
|
+
|
|
616
|
+
if (!file.filename) {
|
|
617
|
+
if (file.status === "deleted") file.filename = file.oldFilename || file.newFilename;
|
|
618
|
+
else file.filename = file.newFilename || file.oldFilename;
|
|
619
|
+
}
|
|
620
|
+
file.filename = normalizeDiffPath(file.filename);
|
|
621
|
+
if (!file.file) file.file = file.filename;
|
|
622
|
+
|
|
623
|
+
if (!file.status || file.status === "modified") {
|
|
624
|
+
if (file.oldFilename === "/dev/null" || (file.oldFilename === "" && file.newFilename)) file.status = "added";
|
|
625
|
+
else if (file.newFilename === "/dev/null" || (file.newFilename === "" && file.oldFilename)) file.status = "deleted";
|
|
626
|
+
else if (file.oldFilename && file.newFilename && file.oldFilename !== file.newFilename) file.status = "renamed";
|
|
627
|
+
else file.status = "modified";
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!file.patch) {
|
|
631
|
+
const patchLines = Array.isArray(file.patchLines) ? file.patchLines : [];
|
|
632
|
+
file.patch = patchLines.join("\n").trim();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (!Array.isArray(file.hunks)) file.hunks = [];
|
|
636
|
+
for (const hunk of file.hunks) {
|
|
637
|
+
delete hunk.nextOldNumber;
|
|
638
|
+
delete hunk.nextNewNumber;
|
|
639
|
+
}
|
|
640
|
+
delete file.headers;
|
|
641
|
+
delete file.patchLines;
|
|
642
|
+
return file;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function createHunk(header) {
|
|
646
|
+
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(header);
|
|
647
|
+
const oldStart = Number(match?.[1] || 0);
|
|
648
|
+
const oldLines = Number(match?.[2] || 1);
|
|
649
|
+
const newStart = Number(match?.[3] || 0);
|
|
650
|
+
const newLines = Number(match?.[4] || 1);
|
|
651
|
+
return {
|
|
652
|
+
header,
|
|
653
|
+
oldStart,
|
|
654
|
+
oldLines,
|
|
655
|
+
newStart,
|
|
656
|
+
newLines,
|
|
657
|
+
nextOldNumber: oldStart,
|
|
658
|
+
nextNewNumber: newStart,
|
|
659
|
+
lines: [],
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function parsePatchFilename(value) {
|
|
664
|
+
const trimmed = String(value || "").trim();
|
|
665
|
+
if (!trimmed || trimmed === "/dev/null") return trimmed;
|
|
666
|
+
return trimmed.replace(/^[ab]\//, "");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function normalizeDiffPath(value) {
|
|
670
|
+
const trimmed = String(value || "").trim();
|
|
671
|
+
if (!trimmed) return "";
|
|
672
|
+
if (trimmed === "/dev/null") return trimmed;
|
|
673
|
+
return trimmed.replace(/^[ab]\//, "");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function buildResult(files, extra = {}) {
|
|
246
677
|
let totalAdditions = 0;
|
|
247
678
|
let totalDeletions = 0;
|
|
248
679
|
|
|
249
|
-
for (const
|
|
250
|
-
totalAdditions +=
|
|
251
|
-
totalDeletions +=
|
|
680
|
+
for (const file of files) {
|
|
681
|
+
totalAdditions += Number(file.additions || 0);
|
|
682
|
+
totalDeletions += Number(file.deletions || 0);
|
|
252
683
|
}
|
|
253
684
|
|
|
254
|
-
// Sort by total changes (largest first)
|
|
255
685
|
const sorted = [...files].sort(
|
|
256
|
-
(a, b) => (b.additions + b.deletions) - (a.additions + a.deletions),
|
|
686
|
+
(a, b) => (Number(b.additions || 0) + Number(b.deletions || 0)) - (Number(a.additions || 0) + Number(a.deletions || 0)),
|
|
257
687
|
);
|
|
258
688
|
|
|
259
|
-
|
|
260
|
-
const maxNameLen = Math.max(...sorted.map((f) => f.file.length), 10);
|
|
689
|
+
const maxNameLen = Math.max(...sorted.map((file) => (file.filename || file.file || "").length), 10);
|
|
261
690
|
|
|
262
|
-
const lines = sorted.map((
|
|
263
|
-
const name =
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
const add = `+${f.additions}`.padStart(6);
|
|
268
|
-
const del = `-${f.deletions}`.padStart(6);
|
|
691
|
+
const lines = sorted.map((file) => {
|
|
692
|
+
const name = String(file.filename || file.file || "").padEnd(maxNameLen);
|
|
693
|
+
if (file.binary) return ` ${name} (binary)`;
|
|
694
|
+
const add = `+${Number(file.additions || 0)}`.padStart(6);
|
|
695
|
+
const del = `-${Number(file.deletions || 0)}`.padStart(6);
|
|
269
696
|
return ` ${name} ${add} ${del}`;
|
|
270
697
|
});
|
|
271
698
|
|
|
272
|
-
const header = `${
|
|
273
|
-
const formatted = `${header}\n${lines.join("\n")}`;
|
|
274
|
-
|
|
699
|
+
const header = `${sorted.length} file(s) changed, +${totalAdditions} -${totalDeletions}`;
|
|
275
700
|
return {
|
|
276
701
|
files: sorted,
|
|
277
|
-
totalFiles:
|
|
702
|
+
totalFiles: sorted.length,
|
|
278
703
|
totalAdditions,
|
|
279
704
|
totalDeletions,
|
|
280
|
-
formatted
|
|
705
|
+
formatted: `${header}\n${lines.join("\n")}`,
|
|
706
|
+
...(extra?.sourceRange ? { sourceRange: extra.sourceRange } : {}),
|
|
281
707
|
};
|
|
282
708
|
}
|