@jaydenfyi/diffx 0.0.1
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/LICENSE +21 -0
- package/README.md +226 -0
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +23 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/command-5FPIcmTx.mjs +2434 -0
- package/dist/command-5FPIcmTx.mjs.map +1 -0
- package/dist/index.d.mts +261 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/package.json +62 -0
|
@@ -0,0 +1,2434 @@
|
|
|
1
|
+
import { define } from "gunshi";
|
|
2
|
+
import git from "simple-git";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { minimatch } from "minimatch";
|
|
6
|
+
|
|
7
|
+
//#region src/types.ts
|
|
8
|
+
/** Error types with exit codes */
|
|
9
|
+
const ExitCode = {
|
|
10
|
+
SUCCESS: 0,
|
|
11
|
+
NO_FILES_MATCHED: 1,
|
|
12
|
+
INVALID_INPUT: 2,
|
|
13
|
+
GIT_ERROR: 3
|
|
14
|
+
};
|
|
15
|
+
/** Custom error with exit code */
|
|
16
|
+
var DiffxError = class extends Error {
|
|
17
|
+
_tag = "DiffxError";
|
|
18
|
+
constructor(message, exitCode) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.exitCode = exitCode;
|
|
21
|
+
this.name = "DiffxError";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/parsers/range/github-parser.ts
|
|
27
|
+
function parseGitHubPRUrl(input) {
|
|
28
|
+
const match = input.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(\/.*)?$/i);
|
|
29
|
+
if (!match) return null;
|
|
30
|
+
return {
|
|
31
|
+
owner: match[1],
|
|
32
|
+
repo: match[2],
|
|
33
|
+
prNumber: parseInt(match[3], 10)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function parseGitHubCommitUrl(input) {
|
|
37
|
+
const match = input.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/commit\/([a-f0-9]+)$/i);
|
|
38
|
+
if (!match) return null;
|
|
39
|
+
return {
|
|
40
|
+
owner: match[1],
|
|
41
|
+
repo: match[2],
|
|
42
|
+
commitSha: match[3]
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function parseGitHubPRChangesUrl(input) {
|
|
46
|
+
const match = input.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/changes\/([a-f0-9]+)\.\.([a-f0-9]+)$/i);
|
|
47
|
+
if (!match) return null;
|
|
48
|
+
return {
|
|
49
|
+
owner: match[1],
|
|
50
|
+
repo: match[2],
|
|
51
|
+
prNumber: parseInt(match[3], 10),
|
|
52
|
+
leftCommitSha: match[4],
|
|
53
|
+
rightCommitSha: match[5]
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function parseGitHubCompareUrl(input) {
|
|
57
|
+
const match = input.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/compare\/(.+)\.\.\.(.+)$/i);
|
|
58
|
+
if (!match) return null;
|
|
59
|
+
const owner = match[1];
|
|
60
|
+
const repo = match[2];
|
|
61
|
+
const leftRef = match[3];
|
|
62
|
+
const rightRef = match[4];
|
|
63
|
+
const crossForkMatch = rightRef.match(/^([^:]+):([^:]+):(.+)$/);
|
|
64
|
+
if (crossForkMatch) return {
|
|
65
|
+
owner,
|
|
66
|
+
repo,
|
|
67
|
+
leftRef,
|
|
68
|
+
rightRef: crossForkMatch[3],
|
|
69
|
+
rightOwner: crossForkMatch[1],
|
|
70
|
+
rightRepo: crossForkMatch[2]
|
|
71
|
+
};
|
|
72
|
+
const crossForkSlashMatch = rightRef.match(/^([^:]+):([^/]+)\/(.+)$/);
|
|
73
|
+
if (crossForkSlashMatch) return {
|
|
74
|
+
owner,
|
|
75
|
+
repo,
|
|
76
|
+
leftRef,
|
|
77
|
+
rightRef: crossForkSlashMatch[3],
|
|
78
|
+
rightOwner: crossForkSlashMatch[1],
|
|
79
|
+
rightRepo: crossForkSlashMatch[2]
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
owner,
|
|
83
|
+
repo,
|
|
84
|
+
leftRef,
|
|
85
|
+
rightRef
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function parsePRRef(input) {
|
|
89
|
+
const result = input.match(/^github:([^/]+)\/([^/]+)#(\d+)$/i);
|
|
90
|
+
if (!result) return null;
|
|
91
|
+
return {
|
|
92
|
+
owner: result[1],
|
|
93
|
+
repo: result[2],
|
|
94
|
+
prNumber: parseInt(result[3], 10)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function parseGithubRefRange(input) {
|
|
98
|
+
const result = input.match(/^github:([^/]+)\/([^@]+)@(.+)\.\.(.+)$/i);
|
|
99
|
+
if (!result) return null;
|
|
100
|
+
const owner = result[1];
|
|
101
|
+
const repo = result[2];
|
|
102
|
+
const left = result[3].trim();
|
|
103
|
+
const right = result[4].trim();
|
|
104
|
+
if (!owner || !repo || !left || !right) return null;
|
|
105
|
+
return {
|
|
106
|
+
ownerRepo: `${owner}/${repo}`,
|
|
107
|
+
left,
|
|
108
|
+
right
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function parsePRRange(input) {
|
|
112
|
+
if (!input.includes("..")) return null;
|
|
113
|
+
const parts = input.split("..");
|
|
114
|
+
if (parts.length !== 2) return null;
|
|
115
|
+
const left = parseGitHubPRUrl(parts[0].trim()) ?? parsePRRef(parts[0].trim());
|
|
116
|
+
const right = parseGitHubPRUrl(parts[1].trim()) ?? parsePRRef(parts[1].trim());
|
|
117
|
+
if (!left || !right) return null;
|
|
118
|
+
return {
|
|
119
|
+
left,
|
|
120
|
+
right
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/parsers/range/git-url-parser.ts
|
|
126
|
+
function parseGitUrlRange(input) {
|
|
127
|
+
const isGitUrl = (s) => s.includes("://") || s.includes("@") && s.includes(":");
|
|
128
|
+
const doubleDotIndex = input.indexOf("..");
|
|
129
|
+
if (doubleDotIndex !== -1) {
|
|
130
|
+
const leftPart = input.slice(0, doubleDotIndex);
|
|
131
|
+
const rightPart = input.slice(doubleDotIndex + 2);
|
|
132
|
+
const lastAtLeft = leftPart.lastIndexOf("@");
|
|
133
|
+
const lastAtRight = rightPart.lastIndexOf("@");
|
|
134
|
+
if (lastAtLeft !== -1 && lastAtRight !== -1) {
|
|
135
|
+
const leftUrl = leftPart.slice(0, lastAtLeft);
|
|
136
|
+
const leftRef = leftPart.slice(lastAtLeft + 1);
|
|
137
|
+
const rightUrl = rightPart.slice(0, lastAtRight);
|
|
138
|
+
const rightRef = rightPart.slice(lastAtRight + 1);
|
|
139
|
+
if (isGitUrl(leftUrl) && isGitUrl(rightUrl)) return {
|
|
140
|
+
leftUrl,
|
|
141
|
+
leftRef,
|
|
142
|
+
rightUrl,
|
|
143
|
+
rightRef
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const atBeforeDoubleDot = input.lastIndexOf("@", input.indexOf(".."));
|
|
148
|
+
if (atBeforeDoubleDot !== -1 && input.includes("..")) {
|
|
149
|
+
const url = input.slice(0, atBeforeDoubleDot);
|
|
150
|
+
const parts = input.slice(atBeforeDoubleDot + 1).split("..");
|
|
151
|
+
if (parts.length === 2 && isGitUrl(url)) return {
|
|
152
|
+
leftUrl: url,
|
|
153
|
+
leftRef: parts[0],
|
|
154
|
+
rightUrl: url,
|
|
155
|
+
rightRef: parts[1]
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/parsers/range/gitlab-parser.ts
|
|
163
|
+
function parseMRRef(input) {
|
|
164
|
+
const result = input.match(/^gitlab:([^/]+)\/([^/]+)!(\d+)$/i);
|
|
165
|
+
if (!result) return null;
|
|
166
|
+
return {
|
|
167
|
+
owner: result[1],
|
|
168
|
+
repo: result[2],
|
|
169
|
+
mrNumber: parseInt(result[3], 10)
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function parseGitlabRefRange(input) {
|
|
173
|
+
const result = input.match(/^gitlab:([^/]+)\/([^@]+)@(.+)\.\.(.+)$/i);
|
|
174
|
+
if (!result) return null;
|
|
175
|
+
const owner = result[1];
|
|
176
|
+
const repo = result[2];
|
|
177
|
+
const left = result[3].trim();
|
|
178
|
+
const right = result[4].trim();
|
|
179
|
+
if (!owner || !repo || !left || !right) return null;
|
|
180
|
+
return {
|
|
181
|
+
ownerRepo: `${owner}/${repo}`,
|
|
182
|
+
left,
|
|
183
|
+
right
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/parsers/range/ref-range-parser.ts
|
|
189
|
+
function parseRemoteRefRange(input) {
|
|
190
|
+
const separatorIndex = input.indexOf("..");
|
|
191
|
+
if (separatorIndex === -1) return null;
|
|
192
|
+
const leftPart = input.slice(0, separatorIndex).trim();
|
|
193
|
+
const rightPart = input.slice(separatorIndex + 2).trim();
|
|
194
|
+
if (!leftPart || !rightPart) return null;
|
|
195
|
+
const parseRemoteSide = (value) => {
|
|
196
|
+
const match = value.match(/^([^/]+)\/([^@]+)@(.+)$/);
|
|
197
|
+
if (!match) return null;
|
|
198
|
+
const owner = match[1].trim();
|
|
199
|
+
const repo = match[2].trim();
|
|
200
|
+
const ref = match[3].trim();
|
|
201
|
+
if (!owner || !repo || !ref) return null;
|
|
202
|
+
return {
|
|
203
|
+
owner,
|
|
204
|
+
repo,
|
|
205
|
+
ref
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
const left = parseRemoteSide(leftPart);
|
|
209
|
+
if (!left) return null;
|
|
210
|
+
const rightFull = parseRemoteSide(rightPart);
|
|
211
|
+
if (rightFull) {
|
|
212
|
+
if (rightFull.owner !== left.owner || rightFull.repo !== left.repo) return null;
|
|
213
|
+
return {
|
|
214
|
+
left: `${left.owner}/${left.repo}@${left.ref}`,
|
|
215
|
+
right: `${rightFull.owner}/${rightFull.repo}@${rightFull.ref}`,
|
|
216
|
+
ownerRepo: `${left.owner}/${left.repo}`
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
left: `${left.owner}/${left.repo}@${left.ref}`,
|
|
221
|
+
right: `${left.owner}/${left.repo}@${rightPart}`,
|
|
222
|
+
ownerRepo: `${left.owner}/${left.repo}`
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function parseLocalRefRange(input) {
|
|
226
|
+
const parts = input.split("..");
|
|
227
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
|
228
|
+
return {
|
|
229
|
+
left: parts[0].trim(),
|
|
230
|
+
right: parts[1].trim()
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/parsers/range-parser.ts
|
|
236
|
+
/**
|
|
237
|
+
* Parse a range input string into a RefRange
|
|
238
|
+
*/
|
|
239
|
+
function parseRangeInput(input) {
|
|
240
|
+
const prRange = parsePRRange(input);
|
|
241
|
+
if (prRange) return {
|
|
242
|
+
type: "pr-range",
|
|
243
|
+
left: "",
|
|
244
|
+
right: "",
|
|
245
|
+
leftPr: prRange.left,
|
|
246
|
+
rightPr: prRange.right
|
|
247
|
+
};
|
|
248
|
+
const gitUrlRange = parseGitUrlRange(input);
|
|
249
|
+
if (gitUrlRange) return {
|
|
250
|
+
type: "git-url-range",
|
|
251
|
+
left: gitUrlRange.leftRef,
|
|
252
|
+
right: gitUrlRange.rightRef,
|
|
253
|
+
leftGitUrl: gitUrlRange.leftUrl,
|
|
254
|
+
rightGitUrl: gitUrlRange.rightUrl
|
|
255
|
+
};
|
|
256
|
+
const githubCompare = parseGitHubCompareUrl(input);
|
|
257
|
+
if (githubCompare) return {
|
|
258
|
+
type: "github-compare-url",
|
|
259
|
+
left: "",
|
|
260
|
+
right: "",
|
|
261
|
+
ownerRepo: `${githubCompare.owner}/${githubCompare.repo}`,
|
|
262
|
+
leftRef: githubCompare.leftRef,
|
|
263
|
+
rightRef: githubCompare.rightRef,
|
|
264
|
+
rightOwner: githubCompare.rightOwner,
|
|
265
|
+
rightRepo: githubCompare.rightRepo
|
|
266
|
+
};
|
|
267
|
+
const githubPrChanges = parseGitHubPRChangesUrl(input);
|
|
268
|
+
if (githubPrChanges) return {
|
|
269
|
+
type: "github-pr-changes-url",
|
|
270
|
+
left: "",
|
|
271
|
+
right: "",
|
|
272
|
+
ownerRepo: `${githubPrChanges.owner}/${githubPrChanges.repo}`,
|
|
273
|
+
prNumber: githubPrChanges.prNumber,
|
|
274
|
+
leftCommitSha: githubPrChanges.leftCommitSha,
|
|
275
|
+
rightCommitSha: githubPrChanges.rightCommitSha
|
|
276
|
+
};
|
|
277
|
+
const githubPr = parseGitHubPRUrl(input);
|
|
278
|
+
if (githubPr) return {
|
|
279
|
+
type: "github-url",
|
|
280
|
+
left: "",
|
|
281
|
+
right: "",
|
|
282
|
+
ownerRepo: `${githubPr.owner}/${githubPr.repo}`,
|
|
283
|
+
prNumber: githubPr.prNumber
|
|
284
|
+
};
|
|
285
|
+
const githubCommit = parseGitHubCommitUrl(input);
|
|
286
|
+
if (githubCommit) return {
|
|
287
|
+
type: "github-commit-url",
|
|
288
|
+
left: "",
|
|
289
|
+
right: "",
|
|
290
|
+
ownerRepo: `${githubCommit.owner}/${githubCommit.repo}`,
|
|
291
|
+
commitSha: githubCommit.commitSha
|
|
292
|
+
};
|
|
293
|
+
const githubRefRange = parseGithubRefRange(input);
|
|
294
|
+
if (githubRefRange) {
|
|
295
|
+
const gitUrl = `git@github.com:${githubRefRange.ownerRepo}.git`;
|
|
296
|
+
return {
|
|
297
|
+
type: "git-url-range",
|
|
298
|
+
left: githubRefRange.left,
|
|
299
|
+
right: githubRefRange.right,
|
|
300
|
+
leftGitUrl: gitUrl,
|
|
301
|
+
rightGitUrl: gitUrl
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const gitlabRefRange = parseGitlabRefRange(input);
|
|
305
|
+
if (gitlabRefRange) {
|
|
306
|
+
const gitUrl = `git@gitlab.com:${gitlabRefRange.ownerRepo}.git`;
|
|
307
|
+
return {
|
|
308
|
+
type: "git-url-range",
|
|
309
|
+
left: gitlabRefRange.left,
|
|
310
|
+
right: gitlabRefRange.right,
|
|
311
|
+
leftGitUrl: gitUrl,
|
|
312
|
+
rightGitUrl: gitUrl
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const prRef = parsePRRef(input);
|
|
316
|
+
if (prRef) return {
|
|
317
|
+
type: "pr-ref",
|
|
318
|
+
left: "",
|
|
319
|
+
right: "",
|
|
320
|
+
ownerRepo: `${prRef.owner}/${prRef.repo}`,
|
|
321
|
+
prNumber: prRef.prNumber
|
|
322
|
+
};
|
|
323
|
+
const mrRef = parseMRRef(input);
|
|
324
|
+
if (mrRef) return {
|
|
325
|
+
type: "gitlab-mr-ref",
|
|
326
|
+
left: "",
|
|
327
|
+
right: "",
|
|
328
|
+
ownerRepo: `${mrRef.owner}/${mrRef.repo}`,
|
|
329
|
+
prNumber: mrRef.mrNumber
|
|
330
|
+
};
|
|
331
|
+
const remoteRange = parseRemoteRefRange(input);
|
|
332
|
+
if (remoteRange) return {
|
|
333
|
+
type: "remote-range",
|
|
334
|
+
left: remoteRange.left,
|
|
335
|
+
right: remoteRange.right,
|
|
336
|
+
ownerRepo: remoteRange.ownerRepo
|
|
337
|
+
};
|
|
338
|
+
const localRange = parseLocalRefRange(input);
|
|
339
|
+
if (localRange) return {
|
|
340
|
+
type: "local-range",
|
|
341
|
+
left: localRange.left,
|
|
342
|
+
right: localRange.right
|
|
343
|
+
};
|
|
344
|
+
throw new DiffxError(`Invalid range or URL: ${input}\n\nSupported formats:\n - Local refs: main..feature, abc123..def456\n - Remote refs: owner/repo@main..owner/repo@feature\n - Git URL: git@github.com:owner/repo.git@main..feature\n - Git URL (HTTPS): https://github.com/owner/repo.git@main..feature\n - GitHub refs: github:owner/repo@main..feature\n - GitHub PR ref: github:owner/repo#123\n - GitHub PR range: github:owner/repo#123..github:owner/repo#456\n - GitHub PR URL: https://github.com/owner/repo/pull/123\n - PR URL range: https://github.com/owner/repo/pull/123..https://github.com/owner/repo/pull/456\n - GitHub commit URL: https://github.com/owner/repo/commit/abc123\n - GitHub PR changes URL: https://github.com/owner/repo/pull/123/changes/abc123..def456\n - GitHub compare URL: https://github.com/owner/repo/compare/main...feature\n - Cross-fork compare: https://github.com/owner/repo/compare/main...other:repo:feature\n - GitLab refs: gitlab:owner/repo@main..feature\n - GitLab MR ref: gitlab:owner/repo!123`, ExitCode.INVALID_INPUT);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/git/git-client.ts
|
|
349
|
+
/**
|
|
350
|
+
* Git client wrapper using simple-git
|
|
351
|
+
*/
|
|
352
|
+
/**
|
|
353
|
+
* Git client wrapper for diffx operations
|
|
354
|
+
*/
|
|
355
|
+
var GitClient = class {
|
|
356
|
+
git = git();
|
|
357
|
+
buildColorFlag(color) {
|
|
358
|
+
return color ? [`--color=${color}`] : [];
|
|
359
|
+
}
|
|
360
|
+
buildColorArgs(options) {
|
|
361
|
+
return options?.color ? [`--color=${options.color}`] : [];
|
|
362
|
+
}
|
|
363
|
+
buildExtraArgs(options) {
|
|
364
|
+
return options?.extraArgs ?? [];
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Generate a unified diff between two refs
|
|
368
|
+
*/
|
|
369
|
+
async diff(left, right, options) {
|
|
370
|
+
const args = [
|
|
371
|
+
...this.buildExtraArgs(options),
|
|
372
|
+
...this.buildColorArgs(options),
|
|
373
|
+
left,
|
|
374
|
+
`${right}`
|
|
375
|
+
];
|
|
376
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
377
|
+
else args.push("--");
|
|
378
|
+
return this.git.diff(args);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Generate a unified diff between a ref and the working tree (includes staged + unstaged)
|
|
382
|
+
*/
|
|
383
|
+
async diffAgainstWorktree(ref, options) {
|
|
384
|
+
const args = [
|
|
385
|
+
...this.buildExtraArgs(options),
|
|
386
|
+
...this.buildColorArgs(options),
|
|
387
|
+
ref
|
|
388
|
+
];
|
|
389
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
390
|
+
else args.push("--");
|
|
391
|
+
return this.git.diff(args);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Generate a patch between two refs
|
|
395
|
+
*/
|
|
396
|
+
async formatPatch(left, right, options) {
|
|
397
|
+
const args = [
|
|
398
|
+
"format-patch",
|
|
399
|
+
"--stdout",
|
|
400
|
+
`${left}..${right}`
|
|
401
|
+
];
|
|
402
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
403
|
+
return this.git.raw(args);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Generate diff statistics between two refs
|
|
407
|
+
*/
|
|
408
|
+
async diffStat(left, right, options) {
|
|
409
|
+
const args = [
|
|
410
|
+
...this.buildExtraArgs(options),
|
|
411
|
+
...this.buildColorArgs(options),
|
|
412
|
+
"--stat",
|
|
413
|
+
`${left}..${right}`
|
|
414
|
+
];
|
|
415
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
416
|
+
return this.git.diff(args);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Generate diff statistics between a ref and the working tree
|
|
420
|
+
*/
|
|
421
|
+
async diffStatAgainstWorktree(ref, options) {
|
|
422
|
+
const args = [
|
|
423
|
+
...this.buildExtraArgs(options),
|
|
424
|
+
...this.buildColorArgs(options),
|
|
425
|
+
"--stat",
|
|
426
|
+
ref
|
|
427
|
+
];
|
|
428
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
429
|
+
return this.git.diff(args);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Generate per-file additions/deletions between two refs
|
|
433
|
+
*/
|
|
434
|
+
async diffNumStat(left, right, options) {
|
|
435
|
+
const args = [
|
|
436
|
+
...this.buildExtraArgs(options),
|
|
437
|
+
...this.buildColorArgs(options),
|
|
438
|
+
"--numstat",
|
|
439
|
+
`${left}..${right}`
|
|
440
|
+
];
|
|
441
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
442
|
+
return this.git.diff(args);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Generate per-file additions/deletions between a ref and the working tree
|
|
446
|
+
*/
|
|
447
|
+
async diffNumStatAgainstWorktree(ref, options) {
|
|
448
|
+
const args = [
|
|
449
|
+
...this.buildExtraArgs(options),
|
|
450
|
+
...this.buildColorArgs(options),
|
|
451
|
+
"--numstat",
|
|
452
|
+
ref
|
|
453
|
+
];
|
|
454
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
455
|
+
return this.git.diff(args);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Generate summary statistics between two refs
|
|
459
|
+
*/
|
|
460
|
+
async diffShortStat(left, right, options) {
|
|
461
|
+
const args = [
|
|
462
|
+
...this.buildExtraArgs(options),
|
|
463
|
+
...this.buildColorArgs(options),
|
|
464
|
+
"--shortstat",
|
|
465
|
+
`${left}..${right}`
|
|
466
|
+
];
|
|
467
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
468
|
+
return this.git.diff(args);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Generate name-only output between two refs
|
|
472
|
+
*/
|
|
473
|
+
async diffNameOnly(left, right, options) {
|
|
474
|
+
const args = [
|
|
475
|
+
...this.buildExtraArgs(options),
|
|
476
|
+
...this.buildColorArgs(options),
|
|
477
|
+
"--name-only",
|
|
478
|
+
`${left}..${right}`
|
|
479
|
+
];
|
|
480
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
481
|
+
return this.git.diff(args);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Generate summary statistics between a ref and the working tree
|
|
485
|
+
*/
|
|
486
|
+
async diffShortStatAgainstWorktree(ref, options) {
|
|
487
|
+
const args = [
|
|
488
|
+
...this.buildExtraArgs(options),
|
|
489
|
+
...this.buildColorArgs(options),
|
|
490
|
+
"--shortstat",
|
|
491
|
+
ref
|
|
492
|
+
];
|
|
493
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
494
|
+
return this.git.diff(args);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Generate name-only output between a ref and the working tree
|
|
498
|
+
*/
|
|
499
|
+
async diffNameOnlyAgainstWorktree(ref, options) {
|
|
500
|
+
const args = [
|
|
501
|
+
...this.buildExtraArgs(options),
|
|
502
|
+
...this.buildColorArgs(options),
|
|
503
|
+
"--name-only",
|
|
504
|
+
ref
|
|
505
|
+
];
|
|
506
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
507
|
+
return this.git.diff(args);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Generate name-status output between a ref and the working tree
|
|
511
|
+
*/
|
|
512
|
+
async diffNameStatusAgainstWorktree(ref, options) {
|
|
513
|
+
const args = [
|
|
514
|
+
...this.buildExtraArgs(options),
|
|
515
|
+
...this.buildColorArgs(options),
|
|
516
|
+
"--name-status",
|
|
517
|
+
ref
|
|
518
|
+
];
|
|
519
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
520
|
+
return this.git.diff(args);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Check if a ref exists locally
|
|
524
|
+
*/
|
|
525
|
+
async refExists(ref) {
|
|
526
|
+
try {
|
|
527
|
+
await this.git.revparse(["--verify", `refs/heads/${ref}`]);
|
|
528
|
+
return true;
|
|
529
|
+
} catch {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Check if a ref exists (local or remote)
|
|
535
|
+
*/
|
|
536
|
+
async refExistsAny(ref) {
|
|
537
|
+
try {
|
|
538
|
+
await this.git.revparse(["--verify", ref]);
|
|
539
|
+
return true;
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Add a remote repository
|
|
546
|
+
*/
|
|
547
|
+
async addRemote(name, url) {
|
|
548
|
+
await this.git.remote([
|
|
549
|
+
"add",
|
|
550
|
+
name,
|
|
551
|
+
url
|
|
552
|
+
]);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Get all remotes
|
|
556
|
+
*/
|
|
557
|
+
async getRemotes() {
|
|
558
|
+
return (await this.git.getRemotes(true)).map((r) => ({
|
|
559
|
+
name: r.name,
|
|
560
|
+
fetchUrl: r.refs.fetch,
|
|
561
|
+
pushUrl: r.refs.push
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Fetch refs from a remote (shallow fetch)
|
|
566
|
+
*/
|
|
567
|
+
async fetch(remote, refs) {
|
|
568
|
+
const args = [
|
|
569
|
+
"fetch",
|
|
570
|
+
"--no-tags",
|
|
571
|
+
"--depth",
|
|
572
|
+
"1",
|
|
573
|
+
remote
|
|
574
|
+
];
|
|
575
|
+
if (refs && refs.length > 0) args.push(...refs);
|
|
576
|
+
await this.git.raw(args);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Fetch refs from a URL into explicit refspecs (shallow fetch)
|
|
580
|
+
*/
|
|
581
|
+
async fetchFromUrl(url, refspecs, depth) {
|
|
582
|
+
const args = [
|
|
583
|
+
"fetch",
|
|
584
|
+
"--no-tags",
|
|
585
|
+
"--depth",
|
|
586
|
+
String(depth),
|
|
587
|
+
url,
|
|
588
|
+
...refspecs
|
|
589
|
+
];
|
|
590
|
+
await this.git.raw(args);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Fetch a specific PR reference (without depth limit to get merge history)
|
|
594
|
+
*/
|
|
595
|
+
async fetchPR(remote, prNumber) {
|
|
596
|
+
const headRef = `refs/pull/${prNumber}/head:refs/remotes/${remote}/pull/${prNumber}/head`;
|
|
597
|
+
const mergeRef = `refs/pull/${prNumber}/merge:refs/remotes/${remote}/pull/${prNumber}/merge`;
|
|
598
|
+
await this.git.raw([
|
|
599
|
+
"fetch",
|
|
600
|
+
"--no-tags",
|
|
601
|
+
"--depth",
|
|
602
|
+
"2",
|
|
603
|
+
remote,
|
|
604
|
+
headRef,
|
|
605
|
+
mergeRef
|
|
606
|
+
]);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Delete refs if they exist
|
|
610
|
+
*/
|
|
611
|
+
async deleteRefs(refs) {
|
|
612
|
+
await Promise.all(refs.map(async (ref) => {
|
|
613
|
+
try {
|
|
614
|
+
await this.git.raw([
|
|
615
|
+
"update-ref",
|
|
616
|
+
"-d",
|
|
617
|
+
ref
|
|
618
|
+
]);
|
|
619
|
+
} catch {}
|
|
620
|
+
}));
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Get the current branch name
|
|
624
|
+
*/
|
|
625
|
+
async getCurrentBranch() {
|
|
626
|
+
return (await this.git.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Get the HEAD commit hash
|
|
630
|
+
*/
|
|
631
|
+
async getHeadHash() {
|
|
632
|
+
return this.git.revparse(["HEAD"]);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Check if the working tree has staged or unstaged changes
|
|
636
|
+
*/
|
|
637
|
+
async hasWorktreeChanges() {
|
|
638
|
+
const status = await this.git.status();
|
|
639
|
+
return status.files.length > 0 || status.not_added.length > 0;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Get working tree status
|
|
643
|
+
*/
|
|
644
|
+
async getStatus() {
|
|
645
|
+
return this.git.status();
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Get untracked files
|
|
649
|
+
*/
|
|
650
|
+
async getUntrackedFiles() {
|
|
651
|
+
return (await this.git.status()).not_added;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Generate a unified diff for an untracked file (vs /dev/null)
|
|
655
|
+
*/
|
|
656
|
+
async diffNoIndex(filePath, color) {
|
|
657
|
+
const colorArgs = this.buildColorFlag(color);
|
|
658
|
+
return this.git.raw([
|
|
659
|
+
"diff",
|
|
660
|
+
...colorArgs,
|
|
661
|
+
"--no-index",
|
|
662
|
+
"--",
|
|
663
|
+
"/dev/null",
|
|
664
|
+
filePath
|
|
665
|
+
]);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Generate diff statistics for an untracked file (vs /dev/null)
|
|
669
|
+
*/
|
|
670
|
+
async diffStatNoIndex(filePath, color) {
|
|
671
|
+
const colorArgs = this.buildColorFlag(color);
|
|
672
|
+
return this.git.raw([
|
|
673
|
+
"diff",
|
|
674
|
+
...colorArgs,
|
|
675
|
+
"--no-index",
|
|
676
|
+
"--stat",
|
|
677
|
+
"--",
|
|
678
|
+
"/dev/null",
|
|
679
|
+
filePath
|
|
680
|
+
]);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Generate per-file additions/deletions for an untracked file (vs /dev/null)
|
|
684
|
+
*/
|
|
685
|
+
async diffNumStatNoIndex(filePath, color) {
|
|
686
|
+
const colorArgs = this.buildColorFlag(color);
|
|
687
|
+
return this.git.raw([
|
|
688
|
+
"diff",
|
|
689
|
+
...colorArgs,
|
|
690
|
+
"--no-index",
|
|
691
|
+
"--numstat",
|
|
692
|
+
"--",
|
|
693
|
+
"/dev/null",
|
|
694
|
+
filePath
|
|
695
|
+
]);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Generate name-status diff between two refs
|
|
699
|
+
*/
|
|
700
|
+
async diffNameStatus(left, right, options) {
|
|
701
|
+
const args = [
|
|
702
|
+
...this.buildExtraArgs(options),
|
|
703
|
+
...this.buildColorArgs(options),
|
|
704
|
+
"--name-status",
|
|
705
|
+
`${left}..${right}`
|
|
706
|
+
];
|
|
707
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
708
|
+
return this.git.diff(args);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Generate summary output between two refs
|
|
712
|
+
*/
|
|
713
|
+
async diffSummary(left, right, options) {
|
|
714
|
+
const args = [
|
|
715
|
+
...this.buildExtraArgs(options),
|
|
716
|
+
...this.buildColorArgs(options),
|
|
717
|
+
"--summary",
|
|
718
|
+
`${left}..${right}`
|
|
719
|
+
];
|
|
720
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
721
|
+
return this.git.diff(args);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Generate summary output between a ref and the working tree
|
|
725
|
+
*/
|
|
726
|
+
async diffSummaryAgainstWorktree(ref, options) {
|
|
727
|
+
const args = [
|
|
728
|
+
...this.buildExtraArgs(options),
|
|
729
|
+
...this.buildColorArgs(options),
|
|
730
|
+
"--summary",
|
|
731
|
+
ref
|
|
732
|
+
];
|
|
733
|
+
if (options?.files && options.files.length > 0) args.push("--", ...options.files);
|
|
734
|
+
return this.git.diff(args);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Get the default branch ref for a remote (e.g., origin/main)
|
|
738
|
+
*/
|
|
739
|
+
async getRemoteHeadRef(remote) {
|
|
740
|
+
try {
|
|
741
|
+
const trimmed = (await this.git.raw([
|
|
742
|
+
"symbolic-ref",
|
|
743
|
+
"--quiet",
|
|
744
|
+
"--short",
|
|
745
|
+
`refs/remotes/${remote}/HEAD`
|
|
746
|
+
])).trim();
|
|
747
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Get a best-effort default branch ref (remote or local)
|
|
754
|
+
*/
|
|
755
|
+
async getDefaultBranchRef() {
|
|
756
|
+
const remoteNames = (await this.getRemotes()).map((r) => r.name);
|
|
757
|
+
const preferredRemotes = remoteNames.includes("origin") ? ["origin", ...remoteNames.filter((name) => name !== "origin")] : remoteNames;
|
|
758
|
+
for (const remote of preferredRemotes) {
|
|
759
|
+
const headRef = await this.getRemoteHeadRef(remote);
|
|
760
|
+
if (headRef) return headRef;
|
|
761
|
+
}
|
|
762
|
+
const fallbackBranchNames = [
|
|
763
|
+
"main",
|
|
764
|
+
"master",
|
|
765
|
+
"develop",
|
|
766
|
+
"trunk"
|
|
767
|
+
];
|
|
768
|
+
for (const remote of preferredRemotes) for (const branch of fallbackBranchNames) {
|
|
769
|
+
const ref = `${remote}/${branch}`;
|
|
770
|
+
if (await this.refExistsAny(ref)) return ref;
|
|
771
|
+
}
|
|
772
|
+
for (const branch of fallbackBranchNames) if (await this.refExistsAny(branch)) return branch;
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Get the merge-base between two refs
|
|
777
|
+
*/
|
|
778
|
+
async mergeBase(left, right) {
|
|
779
|
+
return this.git.raw([
|
|
780
|
+
"merge-base",
|
|
781
|
+
left,
|
|
782
|
+
right
|
|
783
|
+
]);
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Get a git config value
|
|
787
|
+
*/
|
|
788
|
+
async getConfigValue(key, scope = "all") {
|
|
789
|
+
try {
|
|
790
|
+
const scopeArgs = scope === "all" ? [] : [`--${scope}`];
|
|
791
|
+
const trimmed = (await this.git.raw([
|
|
792
|
+
"config",
|
|
793
|
+
...scopeArgs,
|
|
794
|
+
"--get",
|
|
795
|
+
key
|
|
796
|
+
])).trim();
|
|
797
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
798
|
+
} catch {
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Validate that two refs can be diffed
|
|
804
|
+
*/
|
|
805
|
+
async validateRefs(left, right) {
|
|
806
|
+
try {
|
|
807
|
+
await this.git.diff([`${left}..${right}`]);
|
|
808
|
+
return true;
|
|
809
|
+
} catch {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Run native git diff with raw arguments
|
|
815
|
+
* This is the primary method for git diff pass-through compatibility
|
|
816
|
+
*
|
|
817
|
+
* @param args - Raw git diff arguments (e.g., ["--stat", "HEAD", "--", "src/"])
|
|
818
|
+
* @param options - Execution options
|
|
819
|
+
* @returns Git diff output and exit information
|
|
820
|
+
*/
|
|
821
|
+
async runGitDiffRaw(args, options = {}) {
|
|
822
|
+
const { capture = true } = options;
|
|
823
|
+
const colorArgs = process.stdout.isTTY ? ["--color=always"] : ["--color=never"];
|
|
824
|
+
try {
|
|
825
|
+
const fullArgs = [
|
|
826
|
+
"diff",
|
|
827
|
+
...colorArgs,
|
|
828
|
+
...args
|
|
829
|
+
];
|
|
830
|
+
if (capture) return {
|
|
831
|
+
stdout: await this.git.raw(fullArgs),
|
|
832
|
+
stderr: "",
|
|
833
|
+
exitCode: 0
|
|
834
|
+
};
|
|
835
|
+
else return {
|
|
836
|
+
stdout: await this.git.raw(fullArgs),
|
|
837
|
+
stderr: "",
|
|
838
|
+
exitCode: 0
|
|
839
|
+
};
|
|
840
|
+
} catch (error) {
|
|
841
|
+
if (error instanceof Error) return {
|
|
842
|
+
stdout: "",
|
|
843
|
+
stderr: error.message,
|
|
844
|
+
exitCode: 1
|
|
845
|
+
};
|
|
846
|
+
return {
|
|
847
|
+
stdout: "",
|
|
848
|
+
stderr: String(error),
|
|
849
|
+
exitCode: 1
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* Singleton Git client instance
|
|
856
|
+
*/
|
|
857
|
+
const gitClient = new GitClient();
|
|
858
|
+
|
|
859
|
+
//#endregion
|
|
860
|
+
//#region src/git/utils.ts
|
|
861
|
+
/**
|
|
862
|
+
* Git utility functions
|
|
863
|
+
*/
|
|
864
|
+
/**
|
|
865
|
+
* Build a GitHub HTTPS URL from owner/repo
|
|
866
|
+
*/
|
|
867
|
+
function buildGitHubUrl(owner, repo) {
|
|
868
|
+
return `https://github.com/${owner}/${repo}.git`;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Normalize a ref for use with git commands
|
|
872
|
+
*/
|
|
873
|
+
function normalizeRef(ref) {
|
|
874
|
+
return ref.replace(/^refs\/(heads|tags)\//, "");
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Create a temporary ref prefix for one-off fetches
|
|
878
|
+
*/
|
|
879
|
+
function createTempRefPrefix() {
|
|
880
|
+
const token = crypto.randomBytes(8).toString("hex");
|
|
881
|
+
return `refs/diffx/tmp/${Date.now().toString(36)}-${token}`;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
//#endregion
|
|
885
|
+
//#region src/resolvers/local-ref-resolver.ts
|
|
886
|
+
/**
|
|
887
|
+
* Resolve a local ref range to actual refs
|
|
888
|
+
*/
|
|
889
|
+
async function resolveLocalRefs(range) {
|
|
890
|
+
if (range.type !== "local-range") throw new DiffxError("Invalid ref type for local resolver", ExitCode.INVALID_INPUT);
|
|
891
|
+
const left = normalizeRef(range.left);
|
|
892
|
+
const right = normalizeRef(range.right);
|
|
893
|
+
const leftExists = await gitClient.refExistsAny(left);
|
|
894
|
+
const rightExists = await gitClient.refExistsAny(right);
|
|
895
|
+
if (!leftExists) throw new DiffxError(`Left ref does not exist: ${range.left}`, ExitCode.INVALID_INPUT);
|
|
896
|
+
if (!rightExists) throw new DiffxError(`Right ref does not exist: ${range.right}`, ExitCode.INVALID_INPUT);
|
|
897
|
+
return {
|
|
898
|
+
left,
|
|
899
|
+
right
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region src/resolvers/remote-ref-resolver.ts
|
|
905
|
+
/**
|
|
906
|
+
* Resolve a remote ref range to local refs
|
|
907
|
+
*/
|
|
908
|
+
async function resolveRemoteRefs(range) {
|
|
909
|
+
if (range.type !== "remote-range" || !range.ownerRepo) throw new DiffxError("Invalid ref type for remote resolver", ExitCode.INVALID_INPUT);
|
|
910
|
+
const [owner, repo] = range.ownerRepo.split("/");
|
|
911
|
+
if (!owner || !repo) throw new DiffxError(`Invalid owner/repo: ${range.ownerRepo}`, ExitCode.INVALID_INPUT);
|
|
912
|
+
const leftMatch = range.left.match(/^[^/]+\/[^@]+@(.+)$/);
|
|
913
|
+
const rightMatch = range.right.match(/^[^/]+\/[^@]+@(.+)$/);
|
|
914
|
+
if (!leftMatch || !rightMatch) throw new DiffxError("Invalid remote ref format", ExitCode.INVALID_INPUT);
|
|
915
|
+
const leftRemoteRef = leftMatch[1];
|
|
916
|
+
const rightRemoteRef = rightMatch[1];
|
|
917
|
+
const remoteUrl = buildGitHubUrl(owner, repo);
|
|
918
|
+
const tempPrefix = createTempRefPrefix();
|
|
919
|
+
const leftDestRef = `${tempPrefix}/left`;
|
|
920
|
+
const rightDestRef = `${tempPrefix}/right`;
|
|
921
|
+
try {
|
|
922
|
+
await gitClient.fetchFromUrl(remoteUrl, [`${leftRemoteRef}:${leftDestRef}`, `${rightRemoteRef}:${rightDestRef}`], 1);
|
|
923
|
+
return {
|
|
924
|
+
left: leftDestRef,
|
|
925
|
+
right: rightDestRef,
|
|
926
|
+
cleanup: async () => {
|
|
927
|
+
await gitClient.deleteRefs([leftDestRef, rightDestRef]);
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
} catch (error) {
|
|
931
|
+
throw new DiffxError(`Failed to fetch remote refs: ${error.message}`, ExitCode.GIT_ERROR);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
//#endregion
|
|
936
|
+
//#region src/resolvers/pr-url-resolver.ts
|
|
937
|
+
async function fetchPRRefs(pr, tempPrefix) {
|
|
938
|
+
const { owner, repo, prNumber } = pr;
|
|
939
|
+
const remoteUrl = buildGitHubUrl(owner, repo);
|
|
940
|
+
const headRef = `${tempPrefix}/pull/${prNumber}/head`;
|
|
941
|
+
const mergeRef = `${tempPrefix}/pull/${prNumber}/merge`;
|
|
942
|
+
await gitClient.fetchFromUrl(remoteUrl, [`refs/pull/${prNumber}/head:${headRef}`, `refs/pull/${prNumber}/merge:${mergeRef}`], 2);
|
|
943
|
+
return {
|
|
944
|
+
headRef,
|
|
945
|
+
mergeRef,
|
|
946
|
+
cleanupRefs: [headRef, mergeRef]
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Resolve a GitHub PR to local refs
|
|
951
|
+
*/
|
|
952
|
+
async function resolvePRRefs(range) {
|
|
953
|
+
if (!range.ownerRepo || range.prNumber === void 0) throw new DiffxError("Invalid PR ref", ExitCode.INVALID_INPUT);
|
|
954
|
+
try {
|
|
955
|
+
const [owner, repo] = range.ownerRepo.split("/");
|
|
956
|
+
if (!owner || !repo) throw new DiffxError(`Invalid owner/repo: ${range.ownerRepo}`, ExitCode.INVALID_INPUT);
|
|
957
|
+
const tempPrefix = createTempRefPrefix();
|
|
958
|
+
const refs = await fetchPRRefs({
|
|
959
|
+
owner,
|
|
960
|
+
repo,
|
|
961
|
+
prNumber: range.prNumber
|
|
962
|
+
}, tempPrefix);
|
|
963
|
+
return {
|
|
964
|
+
left: `${refs.mergeRef}^1`,
|
|
965
|
+
right: refs.mergeRef,
|
|
966
|
+
cleanup: async () => {
|
|
967
|
+
await gitClient.deleteRefs(refs.cleanupRefs);
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
} catch (error) {
|
|
971
|
+
throw new DiffxError(`Failed to fetch PR refs: ${error.message}`, ExitCode.GIT_ERROR);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Resolve a PR-to-PR range (compare PR heads)
|
|
976
|
+
*/
|
|
977
|
+
async function resolvePRRangeRefs(range) {
|
|
978
|
+
if (!range.leftPr || !range.rightPr) throw new DiffxError("Invalid PR range", ExitCode.INVALID_INPUT);
|
|
979
|
+
try {
|
|
980
|
+
const tempPrefix = createTempRefPrefix();
|
|
981
|
+
const leftRefs = await fetchPRRefs(range.leftPr, `${tempPrefix}/left`);
|
|
982
|
+
const rightRefs = await fetchPRRefs(range.rightPr, `${tempPrefix}/right`);
|
|
983
|
+
return {
|
|
984
|
+
left: leftRefs.headRef,
|
|
985
|
+
right: rightRefs.headRef,
|
|
986
|
+
cleanup: async () => {
|
|
987
|
+
await gitClient.deleteRefs([...leftRefs.cleanupRefs, ...rightRefs.cleanupRefs]);
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
} catch (error) {
|
|
991
|
+
throw new DiffxError(`Failed to fetch PR range refs: ${error.message}`, ExitCode.GIT_ERROR);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Resolve a GitHub commit URL to local refs
|
|
996
|
+
* Shows the changes in that commit (commit^..commit)
|
|
997
|
+
*/
|
|
998
|
+
async function resolveGitHubCommitRefs(range) {
|
|
999
|
+
if (!range.ownerRepo || !range.commitSha) throw new DiffxError("Invalid GitHub commit URL", ExitCode.INVALID_INPUT);
|
|
1000
|
+
try {
|
|
1001
|
+
const [owner, repo] = range.ownerRepo.split("/");
|
|
1002
|
+
if (!owner || !repo) throw new DiffxError(`Invalid owner/repo: ${range.ownerRepo}`, ExitCode.INVALID_INPUT);
|
|
1003
|
+
const remoteUrl = buildGitHubUrl(owner, repo);
|
|
1004
|
+
const commitRef = `${createTempRefPrefix()}/commit/${range.commitSha}`;
|
|
1005
|
+
await gitClient.fetchFromUrl(remoteUrl, [`${range.commitSha}:${commitRef}`], 2);
|
|
1006
|
+
return {
|
|
1007
|
+
left: `${commitRef}^`,
|
|
1008
|
+
right: commitRef,
|
|
1009
|
+
cleanup: async () => {
|
|
1010
|
+
await gitClient.deleteRefs([commitRef]);
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
throw new DiffxError(`Failed to fetch commit refs: ${error.message}`, ExitCode.GIT_ERROR);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Resolve a GitHub PR changes URL (compare two commits in a PR)
|
|
1019
|
+
*/
|
|
1020
|
+
async function resolveGitHubPRChangesRefs(range) {
|
|
1021
|
+
if (!range.ownerRepo || !range.prNumber || !range.leftCommitSha || !range.rightCommitSha) throw new DiffxError("Invalid GitHub PR changes URL", ExitCode.INVALID_INPUT);
|
|
1022
|
+
try {
|
|
1023
|
+
const [owner, repo] = range.ownerRepo.split("/");
|
|
1024
|
+
if (!owner || !repo) throw new DiffxError(`Invalid owner/repo: ${range.ownerRepo}`, ExitCode.INVALID_INPUT);
|
|
1025
|
+
const remoteUrl = buildGitHubUrl(owner, repo);
|
|
1026
|
+
const tempPrefix = createTempRefPrefix();
|
|
1027
|
+
const leftCommitRef = `${tempPrefix}/left-commit/${range.leftCommitSha}`;
|
|
1028
|
+
const rightCommitRef = `${tempPrefix}/right-commit/${range.rightCommitSha}`;
|
|
1029
|
+
await gitClient.fetchFromUrl(remoteUrl, [`${range.leftCommitSha}:${leftCommitRef}`, `${range.rightCommitSha}:${rightCommitRef}`], 2);
|
|
1030
|
+
return {
|
|
1031
|
+
left: leftCommitRef,
|
|
1032
|
+
right: rightCommitRef,
|
|
1033
|
+
cleanup: async () => {
|
|
1034
|
+
await gitClient.deleteRefs([leftCommitRef, rightCommitRef]);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
throw new DiffxError(`Failed to fetch PR changes refs: ${error.message}`, ExitCode.GIT_ERROR);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Resolve a GitHub compare URL (compare two refs, possibly across forks)
|
|
1043
|
+
*/
|
|
1044
|
+
async function resolveGitHubCompareRefs(range) {
|
|
1045
|
+
if (!range.ownerRepo || !range.leftRef || !range.rightRef) throw new DiffxError("Invalid GitHub compare URL", ExitCode.INVALID_INPUT);
|
|
1046
|
+
try {
|
|
1047
|
+
const [owner, repo] = range.ownerRepo.split("/");
|
|
1048
|
+
if (!owner || !repo) throw new DiffxError(`Invalid owner/repo: ${range.ownerRepo}`, ExitCode.INVALID_INPUT);
|
|
1049
|
+
const tempPrefix = createTempRefPrefix();
|
|
1050
|
+
const leftRef = `${tempPrefix}/left/${range.leftRef}`;
|
|
1051
|
+
const rightRef = `${tempPrefix}/right/${range.rightRef}`;
|
|
1052
|
+
const cleanupRefs$1 = [leftRef, rightRef];
|
|
1053
|
+
const leftUrl = buildGitHubUrl(owner, repo);
|
|
1054
|
+
const rightUrl = buildGitHubUrl(range.rightOwner || owner, range.rightRepo || repo);
|
|
1055
|
+
const isCommitSha = (ref) => /^[a-f0-9]{7,40}$/i.test(ref);
|
|
1056
|
+
const buildRefspec = (ref, targetRef) => {
|
|
1057
|
+
if (isCommitSha(ref)) return [`${ref}:${targetRef}`];
|
|
1058
|
+
return [`refs/heads/${ref}:${targetRef}`, `refs/tags/${ref}:${targetRef}`];
|
|
1059
|
+
};
|
|
1060
|
+
const fetchRef = async (url, ref, targetRef) => {
|
|
1061
|
+
let fetchError = null;
|
|
1062
|
+
for (const refspec of buildRefspec(ref, targetRef)) try {
|
|
1063
|
+
await gitClient.fetchFromUrl(url, [refspec], 1);
|
|
1064
|
+
return refspec;
|
|
1065
|
+
} catch (e) {
|
|
1066
|
+
fetchError = e;
|
|
1067
|
+
}
|
|
1068
|
+
throw fetchError ?? /* @__PURE__ */ new Error(`Failed to fetch ref: ${ref}`);
|
|
1069
|
+
};
|
|
1070
|
+
const leftRefspec = await fetchRef(leftUrl, range.leftRef, leftRef);
|
|
1071
|
+
const rightRefspec = await fetchRef(rightUrl, range.rightRef, rightRef);
|
|
1072
|
+
const getMergeBase = async () => {
|
|
1073
|
+
try {
|
|
1074
|
+
const mergeBase$1 = (await gitClient.mergeBase(leftRef, rightRef)).trim();
|
|
1075
|
+
return mergeBase$1.length > 0 ? mergeBase$1 : null;
|
|
1076
|
+
} catch {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
let mergeBase = await getMergeBase();
|
|
1081
|
+
if (!mergeBase) {
|
|
1082
|
+
await gitClient.fetchFromUrl(leftUrl, [leftRefspec], 200);
|
|
1083
|
+
await gitClient.fetchFromUrl(rightUrl, [rightRefspec], 200);
|
|
1084
|
+
mergeBase = await getMergeBase();
|
|
1085
|
+
}
|
|
1086
|
+
if (!mergeBase) throw new Error("Failed to determine merge base for compare refs");
|
|
1087
|
+
return {
|
|
1088
|
+
left: mergeBase,
|
|
1089
|
+
right: rightRef,
|
|
1090
|
+
cleanup: async () => {
|
|
1091
|
+
await gitClient.deleteRefs(cleanupRefs$1);
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
throw new DiffxError(`Failed to fetch compare refs: ${error.message}`, ExitCode.GIT_ERROR);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
//#endregion
|
|
1100
|
+
//#region src/resolvers/git-url-resolver.ts
|
|
1101
|
+
/**
|
|
1102
|
+
* Resolve a git URL range to local refs
|
|
1103
|
+
*/
|
|
1104
|
+
async function resolveGitUrlRefs(range) {
|
|
1105
|
+
if (range.type !== "git-url-range" || !range.leftGitUrl || !range.rightGitUrl) throw new DiffxError("Invalid ref type for git URL resolver", ExitCode.INVALID_INPUT);
|
|
1106
|
+
const leftUrl = range.leftGitUrl;
|
|
1107
|
+
const rightUrl = range.rightGitUrl;
|
|
1108
|
+
const leftRef = range.left;
|
|
1109
|
+
const rightRef = range.right;
|
|
1110
|
+
const tempPrefix = createTempRefPrefix();
|
|
1111
|
+
const leftDestRef = `${tempPrefix}/left`;
|
|
1112
|
+
const rightDestRef = `${tempPrefix}/right`;
|
|
1113
|
+
try {
|
|
1114
|
+
if (leftUrl === rightUrl) await gitClient.fetchFromUrl(leftUrl, [`${leftRef}:${leftDestRef}`, `${rightRef}:${rightDestRef}`], 1);
|
|
1115
|
+
else {
|
|
1116
|
+
await gitClient.fetchFromUrl(leftUrl, [`${leftRef}:${leftDestRef}`], 1);
|
|
1117
|
+
await gitClient.fetchFromUrl(rightUrl, [`${rightRef}:${rightDestRef}`], 1);
|
|
1118
|
+
}
|
|
1119
|
+
return {
|
|
1120
|
+
left: leftDestRef,
|
|
1121
|
+
right: rightDestRef,
|
|
1122
|
+
cleanup: async () => {
|
|
1123
|
+
await gitClient.deleteRefs([leftDestRef, rightDestRef]);
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
throw new DiffxError(`Failed to fetch refs from git URL: ${error.message}`, ExitCode.GIT_ERROR);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
//#endregion
|
|
1132
|
+
//#region src/resolvers/gitlab-mr-resolver.ts
|
|
1133
|
+
async function fetchMRRefs(range, tempPrefix) {
|
|
1134
|
+
if (!range.ownerRepo || range.prNumber === void 0) throw new DiffxError("Invalid GitLab MR ref", ExitCode.INVALID_INPUT);
|
|
1135
|
+
const [owner, repo] = range.ownerRepo.split("/");
|
|
1136
|
+
if (!owner || !repo) throw new DiffxError(`Invalid owner/repo: ${range.ownerRepo}`, ExitCode.INVALID_INPUT);
|
|
1137
|
+
const remoteUrl = `git@gitlab.com:${owner}/${repo}.git`;
|
|
1138
|
+
const mrNumber = range.prNumber;
|
|
1139
|
+
const headRef = `${tempPrefix}/merge-requests/${mrNumber}/head`;
|
|
1140
|
+
const mergeRef = `${tempPrefix}/merge-requests/${mrNumber}/merge`;
|
|
1141
|
+
await gitClient.fetchFromUrl(remoteUrl, [`refs/merge-requests/${mrNumber}/head:${headRef}`, `refs/merge-requests/${mrNumber}/merge:${mergeRef}`], 2);
|
|
1142
|
+
return {
|
|
1143
|
+
headRef,
|
|
1144
|
+
mergeRef,
|
|
1145
|
+
cleanupRefs: [headRef, mergeRef]
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Resolve a GitLab MR to local refs
|
|
1150
|
+
*/
|
|
1151
|
+
async function resolveGitLabMRRefs(range) {
|
|
1152
|
+
try {
|
|
1153
|
+
const refs = await fetchMRRefs(range, createTempRefPrefix());
|
|
1154
|
+
return {
|
|
1155
|
+
left: `${refs.mergeRef}^1`,
|
|
1156
|
+
right: refs.mergeRef,
|
|
1157
|
+
cleanup: async () => {
|
|
1158
|
+
await gitClient.deleteRefs(refs.cleanupRefs);
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
throw new DiffxError(`Failed to fetch GitLab MR refs: ${error.message}`, ExitCode.GIT_ERROR);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
//#endregion
|
|
1167
|
+
//#region src/resolvers/ref-resolver.ts
|
|
1168
|
+
const resolversByRefRangeType = {
|
|
1169
|
+
"local-range": resolveLocalRefs,
|
|
1170
|
+
"remote-range": resolveRemoteRefs,
|
|
1171
|
+
"pr-ref": resolvePRRefs,
|
|
1172
|
+
"github-url": resolvePRRefs,
|
|
1173
|
+
"pr-range": resolvePRRangeRefs,
|
|
1174
|
+
"git-url-range": resolveGitUrlRefs,
|
|
1175
|
+
"github-commit-url": resolveGitHubCommitRefs,
|
|
1176
|
+
"github-pr-changes-url": resolveGitHubPRChangesRefs,
|
|
1177
|
+
"github-compare-url": resolveGitHubCompareRefs,
|
|
1178
|
+
"gitlab-mr-ref": resolveGitLabMRRefs
|
|
1179
|
+
};
|
|
1180
|
+
/**
|
|
1181
|
+
* Resolve any ref range to concrete left/right refs
|
|
1182
|
+
*/
|
|
1183
|
+
async function resolveRefs(range) {
|
|
1184
|
+
const resolver = resolversByRefRangeType[range.type];
|
|
1185
|
+
return resolver(range);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
//#endregion
|
|
1189
|
+
//#region src/resolvers/auto-base-resolver.ts
|
|
1190
|
+
/**
|
|
1191
|
+
* Auto base resolver
|
|
1192
|
+
* Resolves current branch diff against its inferred base branch
|
|
1193
|
+
*/
|
|
1194
|
+
/**
|
|
1195
|
+
* Resolve refs for auto base diff (merge-base..HEAD)
|
|
1196
|
+
*/
|
|
1197
|
+
async function resolveAutoBaseRefs() {
|
|
1198
|
+
const baseRef = await gitClient.getDefaultBranchRef();
|
|
1199
|
+
if (!baseRef) throw new DiffxError("Could not determine a base branch automatically. Provide an explicit range (e.g., main..HEAD).", ExitCode.INVALID_INPUT);
|
|
1200
|
+
let mergeBase;
|
|
1201
|
+
try {
|
|
1202
|
+
mergeBase = (await gitClient.mergeBase(baseRef, "HEAD")).trim();
|
|
1203
|
+
} catch {
|
|
1204
|
+
throw new DiffxError(`Could not find a merge base with ${baseRef}. Provide an explicit range (e.g., ${baseRef}..HEAD).`, ExitCode.INVALID_INPUT);
|
|
1205
|
+
}
|
|
1206
|
+
if (!mergeBase) throw new DiffxError(`Could not find a merge base with ${baseRef}. Provide an explicit range (e.g., ${baseRef}..HEAD).`, ExitCode.INVALID_INPUT);
|
|
1207
|
+
return {
|
|
1208
|
+
left: mergeBase,
|
|
1209
|
+
right: "HEAD",
|
|
1210
|
+
baseRef,
|
|
1211
|
+
mergeBase
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
//#endregion
|
|
1216
|
+
//#region src/output/patch-generator.ts
|
|
1217
|
+
/**
|
|
1218
|
+
* Generate patch between two refs
|
|
1219
|
+
*/
|
|
1220
|
+
async function generatePatch(left, right, options, patchStyle = "diff") {
|
|
1221
|
+
if (patchStyle === "diff") return gitClient.diff(left, right, options);
|
|
1222
|
+
return gitClient.formatPatch(left, right, options);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
//#endregion
|
|
1226
|
+
//#region src/output/output-factory.ts
|
|
1227
|
+
const outputGeneratorsByMode = {
|
|
1228
|
+
diff: (left, right, options) => gitClient.diff(left, right, options),
|
|
1229
|
+
patch: generatePatch,
|
|
1230
|
+
stat: (left, right, options) => gitClient.diffStat(left, right, options),
|
|
1231
|
+
numstat: (left, right, options) => gitClient.diffNumStat(left, right, options),
|
|
1232
|
+
shortstat: (left, right, options) => gitClient.diffShortStat(left, right, options),
|
|
1233
|
+
"name-only": (left, right, options) => gitClient.diffNameOnly(left, right, options),
|
|
1234
|
+
"name-status": (left, right, options) => gitClient.diffNameStatus(left, right, options),
|
|
1235
|
+
summary: (left, right, options) => gitClient.diffSummary(left, right, options)
|
|
1236
|
+
};
|
|
1237
|
+
const outputGeneratorsAgainstWorktreeByMode = {
|
|
1238
|
+
diff: (ref, options) => gitClient.diffAgainstWorktree(ref, options),
|
|
1239
|
+
patch: (ref, options) => gitClient.diffAgainstWorktree(ref, options),
|
|
1240
|
+
stat: (ref, options) => gitClient.diffStatAgainstWorktree(ref, options),
|
|
1241
|
+
numstat: (ref, options) => gitClient.diffNumStatAgainstWorktree(ref, options),
|
|
1242
|
+
shortstat: (ref, options) => gitClient.diffShortStatAgainstWorktree(ref, options),
|
|
1243
|
+
"name-only": (ref, options) => gitClient.diffNameOnlyAgainstWorktree(ref, options),
|
|
1244
|
+
"name-status": (ref, options) => gitClient.diffNameStatusAgainstWorktree(ref, options),
|
|
1245
|
+
summary: (ref, options) => gitClient.diffSummaryAgainstWorktree(ref, options)
|
|
1246
|
+
};
|
|
1247
|
+
/**
|
|
1248
|
+
* Generate output based on the specified mode
|
|
1249
|
+
*/
|
|
1250
|
+
async function generateOutput(mode, left, right, options, patchStyle) {
|
|
1251
|
+
const generator = outputGeneratorsByMode[mode];
|
|
1252
|
+
return generator(left, right, options, patchStyle);
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Generate output between a ref and the working tree
|
|
1256
|
+
*/
|
|
1257
|
+
async function generateOutputAgainstWorktree(mode, ref, options) {
|
|
1258
|
+
const generator = outputGeneratorsAgainstWorktreeByMode[mode];
|
|
1259
|
+
return generator(ref, options);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/errors/error-handler.ts
|
|
1264
|
+
/**
|
|
1265
|
+
* Error handler
|
|
1266
|
+
* Normalizes unknown errors into DiffxError instances
|
|
1267
|
+
*/
|
|
1268
|
+
/**
|
|
1269
|
+
* Normalize an unknown error into a typed DiffxError
|
|
1270
|
+
*/
|
|
1271
|
+
function handleError(error) {
|
|
1272
|
+
if (error instanceof DiffxError) return error;
|
|
1273
|
+
if (error instanceof Error) return new DiffxError(error.message, ExitCode.GIT_ERROR);
|
|
1274
|
+
return new DiffxError(String(error), ExitCode.GIT_ERROR);
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Check whether output is empty and whether that emptiness came from file filters
|
|
1278
|
+
*/
|
|
1279
|
+
function checkEmptyOutput(output, { hasActiveFilters: hasActiveFilters$1, hasUnfilteredChanges: hasUnfilteredChanges$1 }) {
|
|
1280
|
+
if (!(!output || output.trim().length === 0)) return {
|
|
1281
|
+
isEmpty: false,
|
|
1282
|
+
isFilterMismatch: false
|
|
1283
|
+
};
|
|
1284
|
+
return {
|
|
1285
|
+
isEmpty: true,
|
|
1286
|
+
isFilterMismatch: hasActiveFilters$1 && hasUnfilteredChanges$1
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Create a typed no-files-matched error
|
|
1291
|
+
*/
|
|
1292
|
+
function createNoFilesMatchedError() {
|
|
1293
|
+
return new DiffxError("No files matched the specified filters", ExitCode.NO_FILES_MATCHED);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
//#endregion
|
|
1297
|
+
//#region src/cli/pager.ts
|
|
1298
|
+
/**
|
|
1299
|
+
* Pager utilities
|
|
1300
|
+
*/
|
|
1301
|
+
function shouldUsePager(control) {
|
|
1302
|
+
if (control.disable) return false;
|
|
1303
|
+
if (control.force) return true;
|
|
1304
|
+
if (!process.stdout.isTTY) return false;
|
|
1305
|
+
if (control.pager === "false") return false;
|
|
1306
|
+
if (control.pager) return true;
|
|
1307
|
+
return resolvePagerCommand().then((cmd) => cmd !== null && cmd !== "" && cmd !== "false");
|
|
1308
|
+
}
|
|
1309
|
+
async function resolvePagerCommand() {
|
|
1310
|
+
const gitPager = process.env.GIT_PAGER;
|
|
1311
|
+
if (gitPager && gitPager.trim().length > 0) return gitPager.trim();
|
|
1312
|
+
const corePager = await gitClient.getConfigValue("core.pager", "global") ?? await gitClient.getConfigValue("core.pager", "system");
|
|
1313
|
+
if (corePager && corePager.trim().length > 0) return corePager.trim();
|
|
1314
|
+
const pager = process.env.PAGER;
|
|
1315
|
+
if (pager && pager.trim().length > 0) return pager.trim();
|
|
1316
|
+
return null;
|
|
1317
|
+
}
|
|
1318
|
+
function parsePagerCommand(command) {
|
|
1319
|
+
const tokens = [];
|
|
1320
|
+
let current = "";
|
|
1321
|
+
let quote = null;
|
|
1322
|
+
let escaping = false;
|
|
1323
|
+
for (let i = 0; i < command.length; i++) {
|
|
1324
|
+
const char = command[i];
|
|
1325
|
+
if (escaping) {
|
|
1326
|
+
current += char;
|
|
1327
|
+
escaping = false;
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (char === "\\") {
|
|
1331
|
+
escaping = true;
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
if (quote) {
|
|
1335
|
+
if (char === quote) quote = null;
|
|
1336
|
+
else current += char;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
if (char === "'" || char === "\"") {
|
|
1340
|
+
quote = char;
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
if (/\s/.test(char)) {
|
|
1344
|
+
if (current.length > 0) {
|
|
1345
|
+
tokens.push(current);
|
|
1346
|
+
current = "";
|
|
1347
|
+
}
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
current += char;
|
|
1351
|
+
}
|
|
1352
|
+
if (escaping || quote) return null;
|
|
1353
|
+
if (current.length > 0) tokens.push(current);
|
|
1354
|
+
if (tokens.length === 0) return null;
|
|
1355
|
+
const [file, ...args] = tokens;
|
|
1356
|
+
return {
|
|
1357
|
+
file,
|
|
1358
|
+
args
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
async function runPager(command, output) {
|
|
1362
|
+
const parsed = parsePagerCommand(command);
|
|
1363
|
+
if (!parsed) throw new Error("Invalid pager command");
|
|
1364
|
+
let args = parsed.args;
|
|
1365
|
+
const env = {};
|
|
1366
|
+
if (parsed.file.endsWith("less") || parsed.file === "less") {
|
|
1367
|
+
if (!args.includes("-R")) args = ["-R", ...args];
|
|
1368
|
+
env.LESS = "FRX";
|
|
1369
|
+
} else env.LESS = void 0;
|
|
1370
|
+
await new Promise((resolve, reject) => {
|
|
1371
|
+
let settled = false;
|
|
1372
|
+
const resolveOnce = () => {
|
|
1373
|
+
if (settled) return;
|
|
1374
|
+
settled = true;
|
|
1375
|
+
resolve();
|
|
1376
|
+
};
|
|
1377
|
+
const rejectOnce = (error) => {
|
|
1378
|
+
if (settled) return;
|
|
1379
|
+
settled = true;
|
|
1380
|
+
reject(error);
|
|
1381
|
+
};
|
|
1382
|
+
const child = spawn(parsed.file, args, {
|
|
1383
|
+
stdio: [
|
|
1384
|
+
"pipe",
|
|
1385
|
+
"inherit",
|
|
1386
|
+
"inherit"
|
|
1387
|
+
],
|
|
1388
|
+
env: {
|
|
1389
|
+
...process.env,
|
|
1390
|
+
...env
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
child.on("error", (error) => rejectOnce(error instanceof Error ? error : new Error(String(error))));
|
|
1394
|
+
child.on("exit", (code) => {
|
|
1395
|
+
if (code && code !== 0) {
|
|
1396
|
+
rejectOnce(/* @__PURE__ */ new Error(`Pager exited with code ${code}`));
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
resolveOnce();
|
|
1400
|
+
});
|
|
1401
|
+
if (child.stdin) {
|
|
1402
|
+
child.stdin.on("error", (error) => {
|
|
1403
|
+
if (error.code === "EPIPE" || error.code === "ERR_STREAM_DESTROYED") return;
|
|
1404
|
+
rejectOnce(error);
|
|
1405
|
+
});
|
|
1406
|
+
child.stdin.write(output);
|
|
1407
|
+
child.stdin.end();
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
async function pageOutput(output, control) {
|
|
1412
|
+
if (!await shouldUsePager(control)) return false;
|
|
1413
|
+
let pagerCommand = control.pager;
|
|
1414
|
+
if (!pagerCommand) pagerCommand = await resolvePagerCommand() ?? "less -R";
|
|
1415
|
+
try {
|
|
1416
|
+
await runPager(pagerCommand, output);
|
|
1417
|
+
return true;
|
|
1418
|
+
} catch {
|
|
1419
|
+
return false;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
//#endregion
|
|
1424
|
+
//#region src/cli/arg-partitioner.ts
|
|
1425
|
+
/**
|
|
1426
|
+
* Argument partitioning for git diff pass-through
|
|
1427
|
+
*
|
|
1428
|
+
* This module handles the separation of diffx-owned flags from git pass-through flags.
|
|
1429
|
+
* The goal is to allow any git diff flag to work without diffx needing to know about it.
|
|
1430
|
+
*/
|
|
1431
|
+
/**
|
|
1432
|
+
* Flags that are owned and processed by diffx
|
|
1433
|
+
* Everything else is passed through to git diff
|
|
1434
|
+
*
|
|
1435
|
+
* Note: Git output format flags (--stat, --numstat, etc.) are NOT owned by diffx
|
|
1436
|
+
* and are passed through to git for native git compatibility.
|
|
1437
|
+
* Use --overview to get diffx enhancements (e.g., including untracked files).
|
|
1438
|
+
*/
|
|
1439
|
+
const DIFFX_OWNED_FLAGS = [
|
|
1440
|
+
"--mode",
|
|
1441
|
+
"--include",
|
|
1442
|
+
"--exclude",
|
|
1443
|
+
"--pager",
|
|
1444
|
+
"--no-pager",
|
|
1445
|
+
"--overview",
|
|
1446
|
+
"--index"
|
|
1447
|
+
];
|
|
1448
|
+
const DIFFX_SHORT_FLAG_ALIASES = {
|
|
1449
|
+
"-i": "--include",
|
|
1450
|
+
"-e": "--exclude"
|
|
1451
|
+
};
|
|
1452
|
+
/**
|
|
1453
|
+
* Note: --summary is no longer a diffx-owned flag as of Phase 2
|
|
1454
|
+
* It now passes through to native git diff --summary (structural summary)
|
|
1455
|
+
* The custom diffx table output is now only available via --overview
|
|
1456
|
+
*/
|
|
1457
|
+
/**
|
|
1458
|
+
* Git output format flags that are mutually exclusive with --overview
|
|
1459
|
+
*/
|
|
1460
|
+
const GIT_OUTPUT_FORMAT_FLAGS = [
|
|
1461
|
+
"--stat",
|
|
1462
|
+
"--numstat",
|
|
1463
|
+
"--name-only",
|
|
1464
|
+
"--name-status",
|
|
1465
|
+
"--raw",
|
|
1466
|
+
"-p",
|
|
1467
|
+
"--patch",
|
|
1468
|
+
"--shortstat"
|
|
1469
|
+
];
|
|
1470
|
+
/**
|
|
1471
|
+
* Git diff flags that consume the next argv token as a value.
|
|
1472
|
+
* This prevents value tokens (e.g. regex patterns containing "..")
|
|
1473
|
+
* from being misclassified as diff ranges.
|
|
1474
|
+
*/
|
|
1475
|
+
const GIT_FLAGS_WITH_SEPARATE_VALUE = new Set([
|
|
1476
|
+
"--abbrev",
|
|
1477
|
+
"--anchored",
|
|
1478
|
+
"--color",
|
|
1479
|
+
"--color-moved",
|
|
1480
|
+
"--color-moved-ws",
|
|
1481
|
+
"--diff-algorithm",
|
|
1482
|
+
"--dst-prefix",
|
|
1483
|
+
"--find-object",
|
|
1484
|
+
"--find-renames",
|
|
1485
|
+
"--find-copies",
|
|
1486
|
+
"--inter-hunk-context",
|
|
1487
|
+
"--line-prefix",
|
|
1488
|
+
"--output",
|
|
1489
|
+
"--output-indicator-context",
|
|
1490
|
+
"--output-indicator-new",
|
|
1491
|
+
"--output-indicator-old",
|
|
1492
|
+
"--relative",
|
|
1493
|
+
"--skip-to",
|
|
1494
|
+
"--rotate-to",
|
|
1495
|
+
"--src-prefix",
|
|
1496
|
+
"--stat-count",
|
|
1497
|
+
"--stat-graph-width",
|
|
1498
|
+
"--stat-name-width",
|
|
1499
|
+
"--stat-width",
|
|
1500
|
+
"--submodule",
|
|
1501
|
+
"--word-diff",
|
|
1502
|
+
"--word-diff-regex",
|
|
1503
|
+
"-G",
|
|
1504
|
+
"-O",
|
|
1505
|
+
"-S",
|
|
1506
|
+
"-U"
|
|
1507
|
+
]);
|
|
1508
|
+
/**
|
|
1509
|
+
* Check if a flag is a diffx-owned flag
|
|
1510
|
+
*/
|
|
1511
|
+
function isDiffxOwnedFlag(flag) {
|
|
1512
|
+
return DIFFX_OWNED_FLAGS.includes(flag);
|
|
1513
|
+
}
|
|
1514
|
+
function normalizeDiffxFlag(flag) {
|
|
1515
|
+
if (isDiffxOwnedFlag(flag)) return flag;
|
|
1516
|
+
return DIFFX_SHORT_FLAG_ALIASES[flag] ?? null;
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Check if a flag is a git output format flag
|
|
1520
|
+
*/
|
|
1521
|
+
function isGitOutputFormatFlag(flag) {
|
|
1522
|
+
return GIT_OUTPUT_FORMAT_FLAGS.includes(flag);
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Check if the flag is a negatable form (no-* version)
|
|
1526
|
+
*/
|
|
1527
|
+
function isNegatedFlag(flag) {
|
|
1528
|
+
return flag.startsWith("--no-");
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Get the base flag name from a negated flag
|
|
1532
|
+
* e.g., "--no-pager" => "pager"
|
|
1533
|
+
*/
|
|
1534
|
+
function getBaseFlagName(flag) {
|
|
1535
|
+
if (isNegatedFlag(flag)) return `--${flag.slice(5)}`;
|
|
1536
|
+
return flag;
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Parse a flag token to extract the flag name
|
|
1540
|
+
* Handles:
|
|
1541
|
+
* - --flag
|
|
1542
|
+
* - --flag=value
|
|
1543
|
+
* - --flag value
|
|
1544
|
+
* - -f
|
|
1545
|
+
* - -fvalue (combined short form)
|
|
1546
|
+
*/
|
|
1547
|
+
function parseFlagName$1(arg) {
|
|
1548
|
+
if (arg.startsWith("--")) {
|
|
1549
|
+
const idx = arg.indexOf("=");
|
|
1550
|
+
return idx >= 0 ? arg.slice(0, idx) : arg;
|
|
1551
|
+
} else if (arg.startsWith("-") && arg.length > 1) return arg.slice(0, 2);
|
|
1552
|
+
return arg;
|
|
1553
|
+
}
|
|
1554
|
+
function isGitFlagWithSeparateValue(arg) {
|
|
1555
|
+
const flagName = parseFlagName$1(arg);
|
|
1556
|
+
return GIT_FLAGS_WITH_SEPARATE_VALUE.has(flagName);
|
|
1557
|
+
}
|
|
1558
|
+
function isValueForPreviousGitFlag(argv, index) {
|
|
1559
|
+
if (index <= 0) return false;
|
|
1560
|
+
const previousArg = argv[index - 1];
|
|
1561
|
+
return !previousArg.startsWith("--no-") && !normalizeDiffxFlag(parseFlagName$1(previousArg)) && isGitFlagWithSeparateValue(previousArg);
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Check if an argument takes a value
|
|
1565
|
+
* For diffx flags, we know which ones take values
|
|
1566
|
+
*/
|
|
1567
|
+
function diffxFlagTakesValue(flag) {
|
|
1568
|
+
return flag === "--mode" || flag === "--include" || flag === "--exclude";
|
|
1569
|
+
}
|
|
1570
|
+
function appendDiffxFlagValue(diffxFlags, flag, value) {
|
|
1571
|
+
const existing = diffxFlags.get(flag);
|
|
1572
|
+
if (existing === void 0) {
|
|
1573
|
+
diffxFlags.set(flag, value);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
if (Array.isArray(existing)) {
|
|
1577
|
+
existing.push(value);
|
|
1578
|
+
diffxFlags.set(flag, existing);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
if (typeof existing === "string") {
|
|
1582
|
+
diffxFlags.set(flag, [existing, value]);
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
diffxFlags.set(flag, value);
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Check if a token looks like a range/URL input
|
|
1589
|
+
* This is a heuristic to distinguish positionals from git revs
|
|
1590
|
+
*/
|
|
1591
|
+
function looksLikeRangeOrUrl(arg) {
|
|
1592
|
+
if (arg.startsWith("github:")) return true;
|
|
1593
|
+
if (arg.startsWith("gitlab:")) return true;
|
|
1594
|
+
if (arg.startsWith("http://") || arg.startsWith("https://")) return true;
|
|
1595
|
+
if (arg.startsWith("git@") || arg.includes("://")) return true;
|
|
1596
|
+
if (arg.includes("..")) return true;
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Partition command-line arguments into diffx-owned and git pass-through
|
|
1601
|
+
*
|
|
1602
|
+
* @param argv - Raw command-line arguments (excluding program name)
|
|
1603
|
+
* @param tokens - Parsed tokens from gunshi CLI framework
|
|
1604
|
+
* @returns Partitioned arguments
|
|
1605
|
+
*/
|
|
1606
|
+
function partitionArgs(argv, tokens) {
|
|
1607
|
+
const diffxFlags = /* @__PURE__ */ new Map();
|
|
1608
|
+
const gitArgs = [];
|
|
1609
|
+
const pathspecs = [];
|
|
1610
|
+
let inputRange = void 0;
|
|
1611
|
+
let seenDoubleDash = false;
|
|
1612
|
+
let i = 0;
|
|
1613
|
+
while (i < argv.length) {
|
|
1614
|
+
const arg = argv[i];
|
|
1615
|
+
if (arg === "--" && !seenDoubleDash) {
|
|
1616
|
+
seenDoubleDash = true;
|
|
1617
|
+
i++;
|
|
1618
|
+
while (i < argv.length) {
|
|
1619
|
+
pathspecs.push(argv[i]);
|
|
1620
|
+
i++;
|
|
1621
|
+
}
|
|
1622
|
+
break;
|
|
1623
|
+
}
|
|
1624
|
+
if (seenDoubleDash) {
|
|
1625
|
+
pathspecs.push(arg);
|
|
1626
|
+
i++;
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
const flagName = parseFlagName$1(arg);
|
|
1630
|
+
const normalizedFlag = normalizeDiffxFlag(flagName);
|
|
1631
|
+
if (normalizedFlag) {
|
|
1632
|
+
if (diffxFlagTakesValue(normalizedFlag)) {
|
|
1633
|
+
const idx = arg.indexOf("=");
|
|
1634
|
+
if (idx >= 0) appendDiffxFlagValue(diffxFlags, normalizedFlag, arg.slice(idx + 1));
|
|
1635
|
+
else if (arg.startsWith(flagName) && arg.length > flagName.length) appendDiffxFlagValue(diffxFlags, normalizedFlag, arg.slice(flagName.length));
|
|
1636
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
1637
|
+
appendDiffxFlagValue(diffxFlags, normalizedFlag, argv[i + 1]);
|
|
1638
|
+
i++;
|
|
1639
|
+
} else diffxFlags.set(normalizedFlag, true);
|
|
1640
|
+
} else {
|
|
1641
|
+
const baseFlag = getBaseFlagName(normalizedFlag);
|
|
1642
|
+
const value = isNegatedFlag(flagName);
|
|
1643
|
+
diffxFlags.set(baseFlag, !value);
|
|
1644
|
+
}
|
|
1645
|
+
i++;
|
|
1646
|
+
continue;
|
|
1647
|
+
}
|
|
1648
|
+
if (!arg.startsWith("-") && !inputRange && !isValueForPreviousGitFlag(argv, i) && looksLikeRangeOrUrl(arg)) {
|
|
1649
|
+
inputRange = arg;
|
|
1650
|
+
i++;
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
gitArgs.push(arg);
|
|
1654
|
+
i++;
|
|
1655
|
+
}
|
|
1656
|
+
if (!inputRange) {
|
|
1657
|
+
const positionals = tokens.filter((t) => t.kind === "positional").map((t) => t.value).filter((v) => v && v.trim().length > 0).filter((v) => looksLikeRangeOrUrl(v)).filter((v) => {
|
|
1658
|
+
if (argv.findIndex((arg, i$1) => arg === v && !isValueForPreviousGitFlag(argv, i$1)) >= 0) return true;
|
|
1659
|
+
if (argv.indexOf(v) >= 0) return false;
|
|
1660
|
+
return true;
|
|
1661
|
+
});
|
|
1662
|
+
if (positionals.length > 0) inputRange = positionals[0];
|
|
1663
|
+
}
|
|
1664
|
+
return {
|
|
1665
|
+
diffxFlags,
|
|
1666
|
+
inputRange,
|
|
1667
|
+
gitArgs,
|
|
1668
|
+
pathspecs
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Validate that --overview is not used with git output format flags
|
|
1673
|
+
*/
|
|
1674
|
+
function validateOverviewMutualExclusivity(diffxFlags, gitArgs) {
|
|
1675
|
+
if (!(diffxFlags.get("--overview") === true)) return;
|
|
1676
|
+
const conflictingFlags = [];
|
|
1677
|
+
for (const [flag] of diffxFlags) if (isGitOutputFormatFlag(flag)) conflictingFlags.push(flag);
|
|
1678
|
+
for (const arg of gitArgs) {
|
|
1679
|
+
const flagName = parseFlagName$1(arg);
|
|
1680
|
+
if (isGitOutputFormatFlag(flagName)) conflictingFlags.push(flagName);
|
|
1681
|
+
}
|
|
1682
|
+
if (conflictingFlags.length > 0) {
|
|
1683
|
+
const flags = conflictingFlags.join(", ");
|
|
1684
|
+
throw new Error(`Cannot use --overview with git output format flags: ${flags}\nUse --overview for diffx custom output, or git flags for native output.`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
//#endregion
|
|
1689
|
+
//#region src/filters/file-filter.ts
|
|
1690
|
+
/**
|
|
1691
|
+
* Build file patterns array for git commands
|
|
1692
|
+
* Filters include patterns first, then excludes from those results
|
|
1693
|
+
*/
|
|
1694
|
+
function buildFilePatterns(options) {
|
|
1695
|
+
const patterns = [];
|
|
1696
|
+
if (!options.include && !options.exclude) return patterns;
|
|
1697
|
+
if (options.include && options.include.length > 0) patterns.push(...options.include);
|
|
1698
|
+
if (options.exclude && options.exclude.length > 0) options.exclude.forEach((pattern) => {
|
|
1699
|
+
patterns.push(`:!${pattern}`);
|
|
1700
|
+
});
|
|
1701
|
+
return patterns;
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Check if a file should be included based on filter options
|
|
1705
|
+
*/
|
|
1706
|
+
function shouldIncludeFile(filePath, options) {
|
|
1707
|
+
if (!options.include && !options.exclude) return true;
|
|
1708
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
1709
|
+
for (const pattern of options.exclude) if (minimatch(filePath, pattern, { dot: true })) return false;
|
|
1710
|
+
}
|
|
1711
|
+
if (options.include && options.include.length > 0) {
|
|
1712
|
+
for (const pattern of options.include) if (minimatch(filePath, pattern, { dot: true })) return true;
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
//#endregion
|
|
1719
|
+
//#region src/cli/command-options.ts
|
|
1720
|
+
const MODES = new Set([
|
|
1721
|
+
"diff",
|
|
1722
|
+
"patch",
|
|
1723
|
+
"stat",
|
|
1724
|
+
"numstat",
|
|
1725
|
+
"shortstat",
|
|
1726
|
+
"name-only",
|
|
1727
|
+
"name-status",
|
|
1728
|
+
"summary"
|
|
1729
|
+
]);
|
|
1730
|
+
function parseFlagName(arg) {
|
|
1731
|
+
if (arg.startsWith("--")) {
|
|
1732
|
+
const idx = arg.indexOf("=");
|
|
1733
|
+
return idx >= 0 ? arg.slice(0, idx) : arg;
|
|
1734
|
+
}
|
|
1735
|
+
if (arg.startsWith("-") && arg.length > 1) return arg.slice(0, 2);
|
|
1736
|
+
return arg;
|
|
1737
|
+
}
|
|
1738
|
+
function parseMode(value) {
|
|
1739
|
+
if (typeof value !== "string") return null;
|
|
1740
|
+
if (!MODES.has(value)) return null;
|
|
1741
|
+
return value;
|
|
1742
|
+
}
|
|
1743
|
+
function validateNoConflictingFlags(diffxFlags, gitArgs) {
|
|
1744
|
+
const pager = diffxFlags.get("--pager");
|
|
1745
|
+
const noPager = diffxFlags.get("--no-pager");
|
|
1746
|
+
if (pager && noPager) throw new DiffxError("Cannot use both --pager and --no-pager", ExitCode.INVALID_INPUT);
|
|
1747
|
+
validateOverviewMutualExclusivity(diffxFlags, gitArgs);
|
|
1748
|
+
}
|
|
1749
|
+
function shouldUseGitPassThrough(partitioned, hasRange, useGitCompat, hasActiveFilters$1) {
|
|
1750
|
+
if (hasActiveFilters$1) return false;
|
|
1751
|
+
if (partitioned.diffxFlags.get("--overview") === true) return false;
|
|
1752
|
+
if (partitioned.diffxFlags.get("--mode")) return false;
|
|
1753
|
+
for (const arg of partitioned.gitArgs) if (isGitOutputFormatFlag(parseFlagName(arg))) return true;
|
|
1754
|
+
if (useGitCompat) return true;
|
|
1755
|
+
if (hasRange) return true;
|
|
1756
|
+
if (partitioned.gitArgs.length > 0 || partitioned.pathspecs.length > 0) return true;
|
|
1757
|
+
return false;
|
|
1758
|
+
}
|
|
1759
|
+
function getOutputMode({ overview, stat, numstat, shortstat, rawMode, hasStatFlag, hasNumstatFlag, hasShortstatFlag, hasNameOnlyFlag, hasNameStatusFlag }) {
|
|
1760
|
+
const mode = parseMode(rawMode);
|
|
1761
|
+
if (mode) return mode;
|
|
1762
|
+
if (rawMode !== void 0) throw new DiffxError(`Invalid mode: ${rawMode}\nSupported modes: diff, patch, stat, numstat, shortstat, name-only, name-status`, ExitCode.INVALID_INPUT);
|
|
1763
|
+
if (overview) return "numstat";
|
|
1764
|
+
if (stat || hasStatFlag) return "stat";
|
|
1765
|
+
if (numstat || hasNumstatFlag) return "numstat";
|
|
1766
|
+
if (shortstat || hasShortstatFlag) return "shortstat";
|
|
1767
|
+
if (hasNameOnlyFlag) return "name-only";
|
|
1768
|
+
if (hasNameStatusFlag) return "name-status";
|
|
1769
|
+
return "diff";
|
|
1770
|
+
}
|
|
1771
|
+
function getRangeOrUrl(positionals, partitionedInputRange) {
|
|
1772
|
+
if (positionals.length <= 1) return partitionedInputRange;
|
|
1773
|
+
throw new DiffxError(`Unexpected arguments: ${positionals.slice(1).join(" ")}`, ExitCode.INVALID_INPUT);
|
|
1774
|
+
}
|
|
1775
|
+
function validatePagerOptions(pager, noPager) {
|
|
1776
|
+
if (pager && noPager) throw new DiffxError("Cannot use both --pager and --no-pager", ExitCode.INVALID_INPUT);
|
|
1777
|
+
}
|
|
1778
|
+
function hasLongOptionFlag(tokens, optionName) {
|
|
1779
|
+
let seenOptionTerminator = false;
|
|
1780
|
+
for (const token of tokens) {
|
|
1781
|
+
if (token.kind === "option-terminator") {
|
|
1782
|
+
seenOptionTerminator = true;
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
if (seenOptionTerminator || token.kind !== "option") continue;
|
|
1786
|
+
if (token.name === optionName) return true;
|
|
1787
|
+
}
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1790
|
+
function getFilterOptions({ include, exclude }) {
|
|
1791
|
+
const normalize = (value) => {
|
|
1792
|
+
if (!value) return void 0;
|
|
1793
|
+
return Array.isArray(value) ? value : [value];
|
|
1794
|
+
};
|
|
1795
|
+
return {
|
|
1796
|
+
include: normalize(include),
|
|
1797
|
+
exclude: normalize(exclude)
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
function buildDiffOptions(filterOptions, pager, mode, extraGitArgs = []) {
|
|
1801
|
+
const patterns = buildFilePatterns(filterOptions);
|
|
1802
|
+
const color = (mode === "diff" || mode === "patch" || mode === "stat") && (Boolean(pager) || Boolean(process.stdout.isTTY)) ? "always" : "never";
|
|
1803
|
+
return {
|
|
1804
|
+
diffOptions: {
|
|
1805
|
+
files: patterns.length > 0 ? patterns : void 0,
|
|
1806
|
+
color,
|
|
1807
|
+
extraArgs: extraGitArgs.length > 0 ? extraGitArgs : void 0
|
|
1808
|
+
},
|
|
1809
|
+
color
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
function hasActiveFilters(filterOptions) {
|
|
1813
|
+
return Boolean(filterOptions.include?.length || filterOptions.exclude?.length);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
//#endregion
|
|
1817
|
+
//#region src/cli/git-pass-through.ts
|
|
1818
|
+
async function runGitPassThrough({ partitioned, rangeOrUrl, useGitCompat, pager, noPager }) {
|
|
1819
|
+
let cleanup;
|
|
1820
|
+
let left = "";
|
|
1821
|
+
let right = "";
|
|
1822
|
+
if (rangeOrUrl) {
|
|
1823
|
+
let parsed;
|
|
1824
|
+
try {
|
|
1825
|
+
parsed = parseRangeInput(rangeOrUrl);
|
|
1826
|
+
} catch (error) {
|
|
1827
|
+
if (error instanceof DiffxError && error.exitCode === ExitCode.INVALID_INPUT) parsed = void 0;
|
|
1828
|
+
else throw error;
|
|
1829
|
+
}
|
|
1830
|
+
if (!parsed) {
|
|
1831
|
+
if (!partitioned.gitArgs.includes(rangeOrUrl)) left = rangeOrUrl;
|
|
1832
|
+
} else if (parsed.type === "local-range") {
|
|
1833
|
+
left = parsed.left;
|
|
1834
|
+
right = parsed.right;
|
|
1835
|
+
} else {
|
|
1836
|
+
const resolved = await resolveRefs(parsed);
|
|
1837
|
+
left = resolved.left;
|
|
1838
|
+
right = resolved.right;
|
|
1839
|
+
cleanup = resolved.cleanup;
|
|
1840
|
+
}
|
|
1841
|
+
} else if (useGitCompat) {
|
|
1842
|
+
left = "";
|
|
1843
|
+
right = "";
|
|
1844
|
+
}
|
|
1845
|
+
const gitDiffArgs = [];
|
|
1846
|
+
if (left) gitDiffArgs.push(left);
|
|
1847
|
+
if (right) gitDiffArgs.push(right);
|
|
1848
|
+
if (partitioned.pathspecs.length > 0) {
|
|
1849
|
+
gitDiffArgs.push("--");
|
|
1850
|
+
gitDiffArgs.push(...partitioned.pathspecs);
|
|
1851
|
+
}
|
|
1852
|
+
const fullGitArgs = [...partitioned.gitArgs, ...gitDiffArgs];
|
|
1853
|
+
try {
|
|
1854
|
+
const result = await gitClient.runGitDiffRaw(fullGitArgs);
|
|
1855
|
+
if (result.exitCode !== 0) throw new DiffxError(result.stderr.trim().length > 0 ? result.stderr : "git diff failed", ExitCode.GIT_ERROR);
|
|
1856
|
+
const output = result.stdout;
|
|
1857
|
+
if (!await pageOutput(output, {
|
|
1858
|
+
force: pager,
|
|
1859
|
+
disable: Boolean(noPager)
|
|
1860
|
+
})) process.stdout.write(output);
|
|
1861
|
+
} finally {
|
|
1862
|
+
if (cleanup) try {
|
|
1863
|
+
await cleanup();
|
|
1864
|
+
} catch {}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
//#endregion
|
|
1869
|
+
//#region src/utils/overview-utils.ts
|
|
1870
|
+
const StatusCodeByName = {
|
|
1871
|
+
UNTRACKED: "U",
|
|
1872
|
+
UNMERGED: "U",
|
|
1873
|
+
DELETED: "D",
|
|
1874
|
+
ADDED: "A",
|
|
1875
|
+
RENAMED: "R",
|
|
1876
|
+
COPIED: "C",
|
|
1877
|
+
MODIFIED: "M",
|
|
1878
|
+
IGNORED: "!",
|
|
1879
|
+
UNKNOWN: "?"
|
|
1880
|
+
};
|
|
1881
|
+
function hasStatusCode(file, code) {
|
|
1882
|
+
return file.working_dir === code || file.index === code;
|
|
1883
|
+
}
|
|
1884
|
+
function resolveStatusCode(file) {
|
|
1885
|
+
if (hasStatusCode(file, "?")) return StatusCodeByName.UNTRACKED;
|
|
1886
|
+
if (hasStatusCode(file, "U")) return StatusCodeByName.UNMERGED;
|
|
1887
|
+
if (file.index === "!" && file.working_dir === "!") return StatusCodeByName.IGNORED;
|
|
1888
|
+
if (hasStatusCode(file, "D")) return StatusCodeByName.DELETED;
|
|
1889
|
+
if (hasStatusCode(file, "A")) return StatusCodeByName.ADDED;
|
|
1890
|
+
if (hasStatusCode(file, "R")) return StatusCodeByName.RENAMED;
|
|
1891
|
+
if (hasStatusCode(file, "C")) return StatusCodeByName.COPIED;
|
|
1892
|
+
if (hasStatusCode(file, "M")) return StatusCodeByName.MODIFIED;
|
|
1893
|
+
return StatusCodeByName.UNKNOWN;
|
|
1894
|
+
}
|
|
1895
|
+
async function generateUntrackedOutput(mode, files, color, statAlignWidth) {
|
|
1896
|
+
const chunks = [];
|
|
1897
|
+
const statLines = [];
|
|
1898
|
+
let totalInsertions = 0;
|
|
1899
|
+
let totalDeletions = 0;
|
|
1900
|
+
let totalFiles = 0;
|
|
1901
|
+
for (const filePath of files) switch (mode) {
|
|
1902
|
+
case "diff":
|
|
1903
|
+
case "patch":
|
|
1904
|
+
chunks.push(cleanNoIndexPath$1(await gitClient.diffNoIndex(filePath, color)));
|
|
1905
|
+
break;
|
|
1906
|
+
case "stat":
|
|
1907
|
+
{
|
|
1908
|
+
const statLine = extractStatLine(await gitClient.diffStatNoIndex(filePath, color));
|
|
1909
|
+
if (statLine) statLines.push(formatStatLine(statLine, statAlignWidth));
|
|
1910
|
+
const numstat = await gitClient.diffNumStatNoIndex(filePath, color);
|
|
1911
|
+
for (const line of numstat.split("\n")) {
|
|
1912
|
+
const trimmed = line.trim();
|
|
1913
|
+
if (!trimmed) continue;
|
|
1914
|
+
const parts = trimmed.split(" ");
|
|
1915
|
+
if (parts.length < 2) continue;
|
|
1916
|
+
const adds = Number(parts[0]);
|
|
1917
|
+
const dels = Number(parts[1]);
|
|
1918
|
+
totalInsertions += Number.isFinite(adds) ? adds : 0;
|
|
1919
|
+
totalDeletions += Number.isFinite(dels) ? dels : 0;
|
|
1920
|
+
totalFiles += 1;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
break;
|
|
1924
|
+
case "numstat":
|
|
1925
|
+
chunks.push(cleanNoIndexPath$1(await gitClient.diffNumStatNoIndex(filePath, color)).trim());
|
|
1926
|
+
break;
|
|
1927
|
+
case "shortstat":
|
|
1928
|
+
{
|
|
1929
|
+
const numstat = await gitClient.diffNumStatNoIndex(filePath, color);
|
|
1930
|
+
for (const line of numstat.split("\n")) {
|
|
1931
|
+
const trimmed = line.trim();
|
|
1932
|
+
if (!trimmed) continue;
|
|
1933
|
+
const parts = trimmed.split(" ");
|
|
1934
|
+
if (parts.length < 2) continue;
|
|
1935
|
+
const adds = Number(parts[0]);
|
|
1936
|
+
const dels = Number(parts[1]);
|
|
1937
|
+
totalInsertions += Number.isFinite(adds) ? adds : 0;
|
|
1938
|
+
totalDeletions += Number.isFinite(dels) ? dels : 0;
|
|
1939
|
+
totalFiles += 1;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
break;
|
|
1943
|
+
case "name-only":
|
|
1944
|
+
chunks.push(filePath);
|
|
1945
|
+
break;
|
|
1946
|
+
case "name-status":
|
|
1947
|
+
chunks.push(`U\t${filePath}`);
|
|
1948
|
+
break;
|
|
1949
|
+
case "summary":
|
|
1950
|
+
chunks.push(`create mode 100644 ${filePath}`);
|
|
1951
|
+
break;
|
|
1952
|
+
default: throw new Error(`Unknown output mode: ${mode}`);
|
|
1953
|
+
}
|
|
1954
|
+
if (mode === "stat") {
|
|
1955
|
+
if (statLines.length === 0) return "";
|
|
1956
|
+
const summary = formatSummaryLine(totalFiles, totalInsertions, totalDeletions);
|
|
1957
|
+
return `${statLines.join("\n")}\n ${summary}`;
|
|
1958
|
+
}
|
|
1959
|
+
if (mode === "shortstat") {
|
|
1960
|
+
if (totalFiles === 0) return "";
|
|
1961
|
+
return formatSummaryLine(totalFiles, totalInsertions, totalDeletions);
|
|
1962
|
+
}
|
|
1963
|
+
return chunks.filter((chunk) => chunk.trim().length > 0).join("\n");
|
|
1964
|
+
}
|
|
1965
|
+
function mergeOutputs(base, extra) {
|
|
1966
|
+
if (!base) return extra;
|
|
1967
|
+
if (!extra) return base;
|
|
1968
|
+
return `${base.trimEnd()}\n${extra.trimStart()}`;
|
|
1969
|
+
}
|
|
1970
|
+
async function buildStatusMapForWorktree(filterOptions) {
|
|
1971
|
+
const status = await gitClient.getStatus();
|
|
1972
|
+
const map = /* @__PURE__ */ new Map();
|
|
1973
|
+
for (const filePath of status.not_added) if (shouldIncludeFile(filePath, filterOptions)) map.set(filePath, "U");
|
|
1974
|
+
for (const file of status.files) {
|
|
1975
|
+
if (!shouldIncludeFile(file.path, filterOptions)) continue;
|
|
1976
|
+
if (map.get(file.path) === StatusCodeByName.UNTRACKED) continue;
|
|
1977
|
+
map.set(file.path, resolveStatusCode(file));
|
|
1978
|
+
}
|
|
1979
|
+
return map;
|
|
1980
|
+
}
|
|
1981
|
+
async function buildStatusMapForRange(left, right) {
|
|
1982
|
+
const output = await gitClient.diffNameStatus(left, right, void 0);
|
|
1983
|
+
const map = /* @__PURE__ */ new Map();
|
|
1984
|
+
for (const line of output.split("\n")) {
|
|
1985
|
+
const trimmed = line.trim();
|
|
1986
|
+
if (!trimmed) continue;
|
|
1987
|
+
const parts = trimmed.split(" ");
|
|
1988
|
+
const status = parts[0];
|
|
1989
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
1990
|
+
const filePath$1 = parts[2] ?? parts[1];
|
|
1991
|
+
if (filePath$1) map.set(filePath$1, status[0]);
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
const filePath = parts[1];
|
|
1995
|
+
if (filePath) map.set(filePath, status[0]);
|
|
1996
|
+
}
|
|
1997
|
+
return map;
|
|
1998
|
+
}
|
|
1999
|
+
function formatNumstatOutput(output, statusMap) {
|
|
2000
|
+
const rows = output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).map((line) => {
|
|
2001
|
+
const parts = line.split(" ");
|
|
2002
|
+
if (parts.length < 3) return {
|
|
2003
|
+
filePath: line,
|
|
2004
|
+
status: "?",
|
|
2005
|
+
adds: "0",
|
|
2006
|
+
dels: "0"
|
|
2007
|
+
};
|
|
2008
|
+
const adds = parts[0];
|
|
2009
|
+
const dels = parts[1];
|
|
2010
|
+
const filePath = parts.slice(2).join(" ").trim();
|
|
2011
|
+
return {
|
|
2012
|
+
filePath,
|
|
2013
|
+
status: statusMap.get(filePath) ?? "?",
|
|
2014
|
+
adds,
|
|
2015
|
+
dels
|
|
2016
|
+
};
|
|
2017
|
+
});
|
|
2018
|
+
const fileWidth = Math.max(4, ...rows.map((row) => row.filePath.length));
|
|
2019
|
+
const statusWidth = 1;
|
|
2020
|
+
const addsWidth = Math.max(1, ...rows.map((row) => row.adds.length));
|
|
2021
|
+
const delsWidth = Math.max(1, ...rows.map((row) => row.dels.length));
|
|
2022
|
+
return `${`${"FILE".padEnd(fileWidth)} ${"S".padEnd(statusWidth)} ${"+".padStart(addsWidth)} ${"-".padStart(delsWidth)}`}\n${rows.map((row) => {
|
|
2023
|
+
return `${row.filePath.padEnd(fileWidth)} ${row.status.padEnd(statusWidth)} ${row.adds.padStart(addsWidth)} ${row.dels.padStart(delsWidth)}`;
|
|
2024
|
+
}).join("\n")}`;
|
|
2025
|
+
}
|
|
2026
|
+
function cleanNoIndexPath$1(output) {
|
|
2027
|
+
return output.replace(/\/dev\/null => /g, "");
|
|
2028
|
+
}
|
|
2029
|
+
function formatSummaryLine(files, insertions, deletions) {
|
|
2030
|
+
return `${files} ${files === 1 ? "file changed" : "files changed"}, ${insertions} ${insertions === 1 ? "insertion(+)" : "insertions(+)"}, ${deletions} ${deletions === 1 ? "deletion(-)" : "deletions(-)"}`;
|
|
2031
|
+
}
|
|
2032
|
+
function extractStatLine(output) {
|
|
2033
|
+
for (const line of output.split("\n")) if (line.includes("|")) return line;
|
|
2034
|
+
return null;
|
|
2035
|
+
}
|
|
2036
|
+
function formatStatLine(line, alignWidth) {
|
|
2037
|
+
const [leftPart, rightPart = ""] = line.split("|");
|
|
2038
|
+
const fileWithLead = ` ${cleanNoIndexPath$1(leftPart).trim()}`;
|
|
2039
|
+
const width = Math.max(alignWidth ?? 0, fileWithLead.length);
|
|
2040
|
+
return `${fileWithLead.padEnd(width)} |${rightPart}`;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
//#endregion
|
|
2044
|
+
//#region src/cli/worktree-output.ts
|
|
2045
|
+
function cleanNoIndexPath(output) {
|
|
2046
|
+
return output.replace(/\/dev\/null => /g, "");
|
|
2047
|
+
}
|
|
2048
|
+
function parseStatOutput(output) {
|
|
2049
|
+
const rows = [];
|
|
2050
|
+
let summary = {
|
|
2051
|
+
files: 0,
|
|
2052
|
+
insertions: 0,
|
|
2053
|
+
deletions: 0
|
|
2054
|
+
};
|
|
2055
|
+
for (const line of output.split("\n")) {
|
|
2056
|
+
const idx = line.indexOf("|");
|
|
2057
|
+
if (idx >= 0) {
|
|
2058
|
+
const leftPart = line.slice(0, idx);
|
|
2059
|
+
const rightPart = line.slice(idx + 1);
|
|
2060
|
+
const filePath = cleanNoIndexPath(leftPart).trim();
|
|
2061
|
+
if (filePath.length > 0) {
|
|
2062
|
+
const trimmedRight = rightPart.trim();
|
|
2063
|
+
const match = trimmedRight.match(/^(\d+)\s+(.*)$/);
|
|
2064
|
+
rows.push({
|
|
2065
|
+
filePath,
|
|
2066
|
+
changeCount: match ? match[1] : "0",
|
|
2067
|
+
changeBar: match ? match[2] : trimmedRight,
|
|
2068
|
+
additions: 0,
|
|
2069
|
+
deletions: 0
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
continue;
|
|
2073
|
+
}
|
|
2074
|
+
const trimmed = line.trim();
|
|
2075
|
+
if (!trimmed.includes("file changed") && !trimmed.includes("files changed")) continue;
|
|
2076
|
+
const filesMatch = trimmed.match(/^(\d+)\s+files?\s+changed/);
|
|
2077
|
+
const insertionsMatch = trimmed.match(/(\d+)\s+insertions?\(\+\)/);
|
|
2078
|
+
const deletionsMatch = trimmed.match(/(\d+)\s+deletions?\(-\)/);
|
|
2079
|
+
if (filesMatch) summary = {
|
|
2080
|
+
files: Number.parseInt(filesMatch[1], 10) || 0,
|
|
2081
|
+
insertions: insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) || 0 : 0,
|
|
2082
|
+
deletions: deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) || 0 : 0
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
return {
|
|
2086
|
+
rows,
|
|
2087
|
+
summary
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
function parseNumstatOutput(output) {
|
|
2091
|
+
const map = /* @__PURE__ */ new Map();
|
|
2092
|
+
for (const line of output.split("\n")) {
|
|
2093
|
+
const trimmed = line.trim();
|
|
2094
|
+
if (!trimmed) continue;
|
|
2095
|
+
const parts = trimmed.split(" ");
|
|
2096
|
+
if (parts.length < 3) continue;
|
|
2097
|
+
const filePath = cleanNoIndexPath(parts.slice(2).join(" ")).trim();
|
|
2098
|
+
if (!filePath) continue;
|
|
2099
|
+
const additions = Number(parts[0]);
|
|
2100
|
+
const deletions = Number(parts[1]);
|
|
2101
|
+
map.set(filePath, {
|
|
2102
|
+
additions: Number.isFinite(additions) ? additions : 0,
|
|
2103
|
+
deletions: Number.isFinite(deletions) ? deletions : 0
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
return map;
|
|
2107
|
+
}
|
|
2108
|
+
function buildStatBar(additions, deletions, width, color) {
|
|
2109
|
+
if (width <= 0) return "";
|
|
2110
|
+
const total = additions + deletions;
|
|
2111
|
+
if (total <= 0) return "";
|
|
2112
|
+
const effectiveWidth = additions > 0 && deletions > 0 ? Math.max(2, width) : width;
|
|
2113
|
+
let plusWidth = additions > 0 ? Math.round(additions / total * effectiveWidth) : 0;
|
|
2114
|
+
let minusWidth = effectiveWidth - plusWidth;
|
|
2115
|
+
if (additions > 0 && plusWidth < 1) plusWidth = 1;
|
|
2116
|
+
if (deletions > 0 && minusWidth < 1) minusWidth = 1;
|
|
2117
|
+
if (plusWidth + minusWidth > effectiveWidth) if (plusWidth > minusWidth && additions > 0) plusWidth = Math.max(1, effectiveWidth - minusWidth);
|
|
2118
|
+
else minusWidth = Math.max(1, effectiveWidth - plusWidth);
|
|
2119
|
+
const plus = "+".repeat(plusWidth);
|
|
2120
|
+
const minus = "-".repeat(minusWidth);
|
|
2121
|
+
if (color === "never") return `${plus}${minus}`;
|
|
2122
|
+
return `${plus ? `\u001b[32m${plus}\u001b[m` : ""}${minus ? `\u001b[31m${minus}\u001b[m` : ""}`;
|
|
2123
|
+
}
|
|
2124
|
+
function formatStatSummary(summary) {
|
|
2125
|
+
const fileLabel = summary.files === 1 ? "file changed" : "files changed";
|
|
2126
|
+
const insertionLabel = summary.insertions === 1 ? "insertion(+)" : "insertions(+)";
|
|
2127
|
+
const deletionLabel = summary.deletions === 1 ? "deletion(-)" : "deletions(-)";
|
|
2128
|
+
return `${summary.files} ${fileLabel}, ${summary.insertions} ${insertionLabel}, ${summary.deletions} ${deletionLabel}`;
|
|
2129
|
+
}
|
|
2130
|
+
function formatStatRows(rows) {
|
|
2131
|
+
if (rows.length === 0) return "";
|
|
2132
|
+
const pathWidth = Math.max(...rows.map((row) => row.filePath.length));
|
|
2133
|
+
const countWidth = Math.max(...rows.map((row) => row.changeCount.length));
|
|
2134
|
+
return rows.map((row) => {
|
|
2135
|
+
return ` ${row.filePath.padEnd(pathWidth)} | ${row.changeCount.padStart(countWidth)} ${row.changeBar}`;
|
|
2136
|
+
}).join("\n");
|
|
2137
|
+
}
|
|
2138
|
+
function parseShortStatOutput(output) {
|
|
2139
|
+
const filesMatch = output.match(/(\d+)\s+files?\s+changed/);
|
|
2140
|
+
const insertionsMatch = output.match(/(\d+)\s+insertions?/);
|
|
2141
|
+
const deletionsMatch = output.match(/(\d+)\s+deletions?/);
|
|
2142
|
+
return {
|
|
2143
|
+
files: filesMatch ? Number.parseInt(filesMatch[1], 10) : 0,
|
|
2144
|
+
insertions: insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0,
|
|
2145
|
+
deletions: deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
async function appendUntrackedStatFiles(output, left, right, filterOptions, color) {
|
|
2149
|
+
if (right) return output;
|
|
2150
|
+
const filteredUntracked = (await gitClient.getUntrackedFiles()).filter((filePath) => shouldIncludeFile(filePath, filterOptions));
|
|
2151
|
+
if (filteredUntracked.length === 0) return output;
|
|
2152
|
+
const base = parseStatOutput(output);
|
|
2153
|
+
const patterns = buildFilePatterns(filterOptions);
|
|
2154
|
+
const trackedNumstat = parseNumstatOutput(await gitClient.diffNumStatAgainstWorktree(left, { files: patterns.length > 0 ? patterns : void 0 }));
|
|
2155
|
+
const baseRows = base.rows.map((row) => {
|
|
2156
|
+
const counts = trackedNumstat.get(row.filePath);
|
|
2157
|
+
if (!counts) return row;
|
|
2158
|
+
const total = counts.additions + counts.deletions;
|
|
2159
|
+
return {
|
|
2160
|
+
...row,
|
|
2161
|
+
additions: counts.additions,
|
|
2162
|
+
deletions: counts.deletions,
|
|
2163
|
+
changeCount: String(total)
|
|
2164
|
+
};
|
|
2165
|
+
});
|
|
2166
|
+
const extraRows = [];
|
|
2167
|
+
let extraSummary = {
|
|
2168
|
+
files: 0,
|
|
2169
|
+
insertions: 0,
|
|
2170
|
+
deletions: 0
|
|
2171
|
+
};
|
|
2172
|
+
for (const filePath of filteredUntracked) {
|
|
2173
|
+
const parsedNumstat = parseNumstatOutput(await gitClient.diffNumStatNoIndex(filePath, color));
|
|
2174
|
+
for (const [parsedPath, counts] of parsedNumstat.entries()) {
|
|
2175
|
+
const total = counts.additions + counts.deletions;
|
|
2176
|
+
extraRows.push({
|
|
2177
|
+
filePath: parsedPath,
|
|
2178
|
+
changeCount: String(total),
|
|
2179
|
+
changeBar: "",
|
|
2180
|
+
additions: counts.additions,
|
|
2181
|
+
deletions: counts.deletions
|
|
2182
|
+
});
|
|
2183
|
+
extraSummary.files += 1;
|
|
2184
|
+
extraSummary.insertions += counts.additions;
|
|
2185
|
+
extraSummary.deletions += counts.deletions;
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
const rows = [...baseRows, ...extraRows];
|
|
2189
|
+
if (rows.length === 0) return output;
|
|
2190
|
+
const maxTotalChanges = Math.max(1, ...rows.map((row) => row.additions + row.deletions));
|
|
2191
|
+
const maxGraphWidth = 53;
|
|
2192
|
+
const normalizedRows = rows.map((row) => {
|
|
2193
|
+
const total = row.additions + row.deletions;
|
|
2194
|
+
const graphWidth = total > 0 ? Math.max(1, Math.round(total / maxTotalChanges * maxGraphWidth)) : 0;
|
|
2195
|
+
return {
|
|
2196
|
+
...row,
|
|
2197
|
+
changeCount: String(total),
|
|
2198
|
+
changeBar: buildStatBar(row.additions, row.deletions, graphWidth, color)
|
|
2199
|
+
};
|
|
2200
|
+
});
|
|
2201
|
+
const combinedSummary = {
|
|
2202
|
+
files: base.summary.files + extraSummary.files,
|
|
2203
|
+
insertions: base.summary.insertions + extraSummary.insertions,
|
|
2204
|
+
deletions: base.summary.deletions + extraSummary.deletions
|
|
2205
|
+
};
|
|
2206
|
+
return `${formatStatRows(normalizedRows)}\n ${formatStatSummary(combinedSummary)}`;
|
|
2207
|
+
}
|
|
2208
|
+
async function appendUntrackedShortStatFiles(output, filterOptions, color) {
|
|
2209
|
+
const filteredUntracked = (await gitClient.getUntrackedFiles()).filter((filePath) => shouldIncludeFile(filePath, filterOptions));
|
|
2210
|
+
if (filteredUntracked.length === 0) return output;
|
|
2211
|
+
const base = parseShortStatOutput(output);
|
|
2212
|
+
let untrackedFiles = 0;
|
|
2213
|
+
let untrackedInsertions = 0;
|
|
2214
|
+
let untrackedDeletions = 0;
|
|
2215
|
+
for (const filePath of filteredUntracked) {
|
|
2216
|
+
const numstatOutput = await gitClient.diffNumStatNoIndex(filePath, color);
|
|
2217
|
+
for (const line of numstatOutput.split("\n")) {
|
|
2218
|
+
const trimmed = line.trim();
|
|
2219
|
+
if (!trimmed) continue;
|
|
2220
|
+
const parts = trimmed.split(" ");
|
|
2221
|
+
if (parts.length < 2) continue;
|
|
2222
|
+
const adds = Number(parts[0]);
|
|
2223
|
+
const dels = Number(parts[1]);
|
|
2224
|
+
if (Number.isFinite(adds)) untrackedInsertions += adds;
|
|
2225
|
+
if (Number.isFinite(dels)) untrackedDeletions += dels;
|
|
2226
|
+
untrackedFiles += 1;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
const combined = {
|
|
2230
|
+
files: base.files + untrackedFiles,
|
|
2231
|
+
insertions: base.insertions + untrackedInsertions,
|
|
2232
|
+
deletions: base.deletions + untrackedDeletions
|
|
2233
|
+
};
|
|
2234
|
+
return ` ${combined.files} files changed, ${combined.insertions} insertions(+), ${combined.deletions} deletions(-)`;
|
|
2235
|
+
}
|
|
2236
|
+
async function appendUntrackedFiles(output, mode, left, right, filterOptions, color) {
|
|
2237
|
+
if (right) return output;
|
|
2238
|
+
if (mode === "stat") return appendUntrackedStatFiles(output, left, right, filterOptions, color);
|
|
2239
|
+
if (mode === "shortstat") return appendUntrackedShortStatFiles(output, filterOptions, color);
|
|
2240
|
+
const filteredUntracked = (await gitClient.getUntrackedFiles()).filter((filePath) => shouldIncludeFile(filePath, filterOptions));
|
|
2241
|
+
if (filteredUntracked.length === 0) return output;
|
|
2242
|
+
return mergeOutputs(output, await generateUntrackedOutput(mode, filteredUntracked, color, void 0));
|
|
2243
|
+
}
|
|
2244
|
+
async function hasUnfilteredChanges(refs) {
|
|
2245
|
+
if (!refs.right) return gitClient.hasWorktreeChanges();
|
|
2246
|
+
return (await gitClient.diffShortStat(refs.left, refs.right, void 0)).trim().length > 0;
|
|
2247
|
+
}
|
|
2248
|
+
async function processWorktreeOutput(output, mode, refs, filterOptions, color, useSummaryFormat) {
|
|
2249
|
+
let result = await appendUntrackedFiles(output, mode, refs.left, refs.right || void 0, filterOptions, color);
|
|
2250
|
+
if (mode !== "numstat" || !useSummaryFormat) return result;
|
|
2251
|
+
const statusMap = refs.right ? await buildStatusMapForRange(refs.left, refs.right) : await buildStatusMapForWorktree(filterOptions);
|
|
2252
|
+
result = formatNumstatOutput(result, statusMap);
|
|
2253
|
+
return result;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
//#endregion
|
|
2257
|
+
//#region src/cli/command.ts
|
|
2258
|
+
/**
|
|
2259
|
+
* Gunshi CLI command definition for diffx
|
|
2260
|
+
*/
|
|
2261
|
+
const RANGE_TYPES_USING_DIFF_PATCH_STYLE = new Set([
|
|
2262
|
+
"pr-ref",
|
|
2263
|
+
"github-url",
|
|
2264
|
+
"pr-range",
|
|
2265
|
+
"github-commit-url",
|
|
2266
|
+
"github-pr-changes-url",
|
|
2267
|
+
"github-compare-url"
|
|
2268
|
+
]);
|
|
2269
|
+
async function resolveRefsForRange(rangeOrUrl, mode) {
|
|
2270
|
+
const range = parseRangeInput(rangeOrUrl);
|
|
2271
|
+
const patchStyle = mode === "patch" && RANGE_TYPES_USING_DIFF_PATCH_STYLE.has(range.type) ? "diff" : void 0;
|
|
2272
|
+
return {
|
|
2273
|
+
...await resolveRefs(range),
|
|
2274
|
+
patchStyle
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
async function resolveRefsForDefault(useGitCompat) {
|
|
2278
|
+
if (useGitCompat) return {
|
|
2279
|
+
left: "",
|
|
2280
|
+
right: ""
|
|
2281
|
+
};
|
|
2282
|
+
if (await gitClient.hasWorktreeChanges()) return {
|
|
2283
|
+
left: "HEAD",
|
|
2284
|
+
right: ""
|
|
2285
|
+
};
|
|
2286
|
+
const auto = await resolveAutoBaseRefs();
|
|
2287
|
+
return {
|
|
2288
|
+
left: auto.mergeBase,
|
|
2289
|
+
right: auto.right
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
async function generateDiffOutput(mode, refs, diffOptions) {
|
|
2293
|
+
const { left, right, patchStyle } = refs;
|
|
2294
|
+
if (right) return generateOutput(mode, left, right, diffOptions, patchStyle);
|
|
2295
|
+
return generateOutputAgainstWorktree(mode, left, diffOptions);
|
|
2296
|
+
}
|
|
2297
|
+
async function cleanupRefs(refs) {
|
|
2298
|
+
if (!refs.cleanup) return;
|
|
2299
|
+
try {
|
|
2300
|
+
await refs.cleanup();
|
|
2301
|
+
} catch {}
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Main diffx command
|
|
2305
|
+
*/
|
|
2306
|
+
const diffxCommand = define({
|
|
2307
|
+
rendering: { header: null },
|
|
2308
|
+
args: {
|
|
2309
|
+
mode: {
|
|
2310
|
+
type: "string",
|
|
2311
|
+
description: "Output mode: diff (default), patch (unified diff, same as git diff -p), stat, numstat, or shortstat"
|
|
2312
|
+
},
|
|
2313
|
+
stat: {
|
|
2314
|
+
type: "boolean",
|
|
2315
|
+
description: "Show diff statistics (same as --mode stat)"
|
|
2316
|
+
},
|
|
2317
|
+
numstat: {
|
|
2318
|
+
type: "boolean",
|
|
2319
|
+
description: "Show per-file adds/removes (same as --mode numstat)"
|
|
2320
|
+
},
|
|
2321
|
+
summary: {
|
|
2322
|
+
type: "boolean",
|
|
2323
|
+
description: "Show structural summary (create/delete/rename mode). Equivalent to git diff --summary"
|
|
2324
|
+
},
|
|
2325
|
+
shortstat: {
|
|
2326
|
+
type: "boolean",
|
|
2327
|
+
description: "Show summary line only (same as --mode shortstat)"
|
|
2328
|
+
},
|
|
2329
|
+
"name-only": {
|
|
2330
|
+
type: "boolean",
|
|
2331
|
+
description: "Show only filenames of changed files"
|
|
2332
|
+
},
|
|
2333
|
+
"name-status": {
|
|
2334
|
+
type: "boolean",
|
|
2335
|
+
description: "Show filenames with status (M/A/D/etc)"
|
|
2336
|
+
},
|
|
2337
|
+
overview: {
|
|
2338
|
+
type: "boolean",
|
|
2339
|
+
description: "Show custom diffx table with status/additions/deletions (not a git flag)"
|
|
2340
|
+
},
|
|
2341
|
+
pager: {
|
|
2342
|
+
type: "boolean",
|
|
2343
|
+
description: "Force output through a pager (overrides TTY detection)"
|
|
2344
|
+
},
|
|
2345
|
+
"no-pager": {
|
|
2346
|
+
type: "boolean",
|
|
2347
|
+
description: "Disable the pager"
|
|
2348
|
+
},
|
|
2349
|
+
include: {
|
|
2350
|
+
type: "string",
|
|
2351
|
+
description: "Only include files matching this glob pattern",
|
|
2352
|
+
short: "i"
|
|
2353
|
+
},
|
|
2354
|
+
exclude: {
|
|
2355
|
+
type: "string",
|
|
2356
|
+
description: "Exclude files matching this glob pattern",
|
|
2357
|
+
short: "e"
|
|
2358
|
+
},
|
|
2359
|
+
index: {
|
|
2360
|
+
type: "boolean",
|
|
2361
|
+
description: "Strict git diff compatibility: show unstaged changes (index vs working tree)"
|
|
2362
|
+
}
|
|
2363
|
+
},
|
|
2364
|
+
run: async (ctx) => {
|
|
2365
|
+
const partitioned = partitionArgs(process.argv.slice(2), ctx.tokens);
|
|
2366
|
+
validateNoConflictingFlags(partitioned.diffxFlags, partitioned.gitArgs);
|
|
2367
|
+
const rangeOrUrl = getRangeOrUrl(ctx.positionals ?? [], partitioned.inputRange);
|
|
2368
|
+
const { include, exclude, mode: rawMode, stat, numstat, shortstat, overview, "name-only": _nameOnly, "name-status": _nameStatus, pager, "no-pager": noPager, index } = ctx.values;
|
|
2369
|
+
const toPatternList = (value) => {
|
|
2370
|
+
if (typeof value === "string") return value;
|
|
2371
|
+
if (Array.isArray(value)) {
|
|
2372
|
+
const patterns = value.filter((item) => typeof item === "string");
|
|
2373
|
+
return patterns.length > 0 ? patterns : void 0;
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
validatePagerOptions(pager, noPager);
|
|
2377
|
+
const hasRange = Boolean(rangeOrUrl);
|
|
2378
|
+
const partitionedInclude = partitioned.diffxFlags.get("--include");
|
|
2379
|
+
const partitionedExclude = partitioned.diffxFlags.get("--exclude");
|
|
2380
|
+
const filterOptions = getFilterOptions({
|
|
2381
|
+
include: toPatternList(partitionedInclude) ?? toPatternList(include),
|
|
2382
|
+
exclude: toPatternList(partitionedExclude) ?? toPatternList(exclude)
|
|
2383
|
+
});
|
|
2384
|
+
const filtersAreActive = hasActiveFilters(filterOptions);
|
|
2385
|
+
if (shouldUseGitPassThrough(partitioned, hasRange, Boolean(index), filtersAreActive)) {
|
|
2386
|
+
await runGitPassThrough({
|
|
2387
|
+
partitioned,
|
|
2388
|
+
rangeOrUrl,
|
|
2389
|
+
useGitCompat: Boolean(index),
|
|
2390
|
+
pager,
|
|
2391
|
+
noPager
|
|
2392
|
+
});
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
const mode = getOutputMode({
|
|
2396
|
+
rawMode,
|
|
2397
|
+
stat,
|
|
2398
|
+
numstat,
|
|
2399
|
+
shortstat,
|
|
2400
|
+
overview,
|
|
2401
|
+
hasStatFlag: hasLongOptionFlag(ctx.tokens, "stat"),
|
|
2402
|
+
hasNumstatFlag: hasLongOptionFlag(ctx.tokens, "numstat"),
|
|
2403
|
+
hasShortstatFlag: hasLongOptionFlag(ctx.tokens, "shortstat"),
|
|
2404
|
+
hasNameOnlyFlag: hasLongOptionFlag(ctx.tokens, "name-only"),
|
|
2405
|
+
hasNameStatusFlag: hasLongOptionFlag(ctx.tokens, "name-status")
|
|
2406
|
+
});
|
|
2407
|
+
const useSummaryFormat = Boolean(overview);
|
|
2408
|
+
const { diffOptions, color } = buildDiffOptions(filterOptions, pager, mode, partitioned.gitArgs);
|
|
2409
|
+
const refs = rangeOrUrl ? await resolveRefsForRange(rangeOrUrl, mode) : await resolveRefsForDefault(Boolean(index));
|
|
2410
|
+
try {
|
|
2411
|
+
let output = await generateDiffOutput(mode, refs, diffOptions);
|
|
2412
|
+
if (!rangeOrUrl || useSummaryFormat) output = await processWorktreeOutput(output, mode, refs, filterOptions, color, useSummaryFormat);
|
|
2413
|
+
const emptyOutput = checkEmptyOutput(output, {
|
|
2414
|
+
hasActiveFilters: filtersAreActive,
|
|
2415
|
+
hasUnfilteredChanges: filtersAreActive ? await hasUnfilteredChanges(refs) : false
|
|
2416
|
+
});
|
|
2417
|
+
if (emptyOutput.isEmpty) {
|
|
2418
|
+
if (emptyOutput.isFilterMismatch) throw createNoFilesMatchedError();
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
const autoPagerMode = mode === "diff" || mode === "patch";
|
|
2422
|
+
if (!await pageOutput(output, {
|
|
2423
|
+
force: pager,
|
|
2424
|
+
disable: Boolean(noPager) || !autoPagerMode && !pager
|
|
2425
|
+
})) console.log(output);
|
|
2426
|
+
} finally {
|
|
2427
|
+
await cleanupRefs(refs);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
//#endregion
|
|
2433
|
+
export { DiffxError as a, parseRangeInput as i, handleError as n, ExitCode as o, resolveRefs as r, diffxCommand as t };
|
|
2434
|
+
//# sourceMappingURL=command-5FPIcmTx.mjs.map
|