@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.
@@ -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