diffstalker 0.2.1 → 0.2.3

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.
Files changed (42) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/README.md +43 -35
  5. package/bun.lock +60 -4
  6. package/dist/App.js +495 -131
  7. package/dist/KeyBindings.js +134 -10
  8. package/dist/MouseHandlers.js +67 -20
  9. package/dist/core/ExplorerStateManager.js +37 -75
  10. package/dist/core/GitStateManager.js +252 -46
  11. package/dist/git/diff.js +99 -18
  12. package/dist/git/status.js +111 -54
  13. package/dist/git/test-helpers.js +67 -0
  14. package/dist/index.js +54 -43
  15. package/dist/ipc/CommandClient.js +6 -7
  16. package/dist/state/UIState.js +22 -0
  17. package/dist/types/remote.js +5 -0
  18. package/dist/ui/PaneRenderers.js +45 -15
  19. package/dist/ui/modals/BranchPicker.js +157 -0
  20. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  21. package/dist/ui/modals/FileFinder.js +45 -75
  22. package/dist/ui/modals/HotkeysModal.js +35 -3
  23. package/dist/ui/modals/SoftResetConfirm.js +68 -0
  24. package/dist/ui/modals/StashListModal.js +98 -0
  25. package/dist/ui/modals/ThemePicker.js +1 -2
  26. package/dist/ui/widgets/CommitPanel.js +113 -7
  27. package/dist/ui/widgets/CompareListView.js +44 -23
  28. package/dist/ui/widgets/DiffView.js +216 -170
  29. package/dist/ui/widgets/ExplorerView.js +50 -54
  30. package/dist/ui/widgets/FileList.js +62 -95
  31. package/dist/ui/widgets/FlatFileList.js +65 -0
  32. package/dist/ui/widgets/Footer.js +25 -15
  33. package/dist/ui/widgets/Header.js +51 -9
  34. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  35. package/dist/utils/ansiTruncate.js +0 -1
  36. package/dist/utils/displayRows.js +101 -21
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +0 -1
  40. package/metrics/v0.2.2.json +229 -0
  41. package/metrics/v0.2.3.json +243 -0
  42. package/package.json +10 -3
@@ -5,8 +5,8 @@ import { watch } from 'chokidar';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import ignore from 'ignore';
7
7
  import { getQueueForRepo, removeQueueForRepo } from './GitOperationQueue.js';
8
- import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, } from '../git/status.js';
9
- import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, } from '../git/diff.js';
8
+ import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, stageHunk as gitStageHunk, unstageHunk as gitUnstageHunk, push as gitPush, fetchRemote as gitFetchRemote, pullRebase as gitPullRebase, getStashList as gitGetStashList, stashSave as gitStashSave, stashPop as gitStashPop, getLocalBranches as gitGetLocalBranches, switchBranch as gitSwitchBranch, createBranch as gitCreateBranch, softResetHead as gitSoftResetHead, cherryPick as gitCherryPick, revertCommit as gitRevertCommit, } from '../git/status.js';
9
+ import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, countHunksPerFile, } from '../git/diff.js';
10
10
  import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
11
11
  /**
12
12
  * GitStateManager manages git state independent of React.
@@ -23,9 +23,12 @@ export class GitStateManager extends EventEmitter {
23
23
  _state = {
24
24
  status: null,
25
25
  diff: null,
26
+ combinedFileDiffs: null,
26
27
  selectedFile: null,
27
28
  isLoading: false,
28
29
  error: null,
30
+ hunkCounts: null,
31
+ stashList: [],
29
32
  };
30
33
  _compareState = {
31
34
  compareDiff: null,
@@ -44,6 +47,12 @@ export class GitStateManager extends EventEmitter {
44
47
  index: 0,
45
48
  diff: null,
46
49
  };
50
+ _remoteState = {
51
+ operation: null,
52
+ inProgress: false,
53
+ error: null,
54
+ lastResult: null,
55
+ };
47
56
  constructor(repoPath) {
48
57
  super();
49
58
  this.repoPath = repoPath;
@@ -61,6 +70,13 @@ export class GitStateManager extends EventEmitter {
61
70
  get compareSelectionState() {
62
71
  return this._compareSelectionState;
63
72
  }
73
+ get remoteState() {
74
+ return this._remoteState;
75
+ }
76
+ updateRemoteState(partial) {
77
+ this._remoteState = { ...this._remoteState, ...partial };
78
+ this.emit('remote-state-change', this._remoteState);
79
+ }
64
80
  updateState(partial) {
65
81
  this._state = { ...this._state, ...partial };
66
82
  this.emit('state-change', this._state);
@@ -256,34 +272,24 @@ export class GitStateManager extends EventEmitter {
256
272
  });
257
273
  return;
258
274
  }
259
- // Emit status immediately so the file list updates after a single git spawn
260
- this.updateState({ status: newStatus });
261
- // Fetch unstaged diff (updates diff view once complete)
262
- const allUnstagedDiff = await getDiff(this.repoPath, undefined, false);
275
+ // Fetch unstaged and staged diffs in parallel
276
+ const [allUnstagedDiff, allStagedDiff] = await Promise.all([
277
+ getDiff(this.repoPath, undefined, false),
278
+ getDiff(this.repoPath, undefined, true),
279
+ ]);
280
+ // Count hunks per file for the file list display
281
+ const hunkCounts = {
282
+ unstaged: countHunksPerFile(allUnstagedDiff.raw),
283
+ staged: countHunksPerFile(allStagedDiff.raw),
284
+ };
263
285
  // Determine display diff based on selected file
264
- let displayDiff;
265
- const currentSelectedFile = this._state.selectedFile;
266
- if (currentSelectedFile) {
267
- const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged);
268
- if (currentFile) {
269
- if (currentFile.status === 'untracked') {
270
- displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
271
- }
272
- else {
273
- displayDiff = await getDiff(this.repoPath, currentFile.path, currentFile.staged);
274
- }
275
- }
276
- else {
277
- // File no longer exists - clear selection, show unstaged diff
278
- displayDiff = allUnstagedDiff;
279
- this.updateState({ selectedFile: null });
280
- }
281
- }
282
- else {
283
- displayDiff = allUnstagedDiff;
284
- }
286
+ const { displayDiff, combinedFileDiffs } = await this.resolveFileDiffs(newStatus, allUnstagedDiff);
287
+ // Batch status + diffs into a single update to avoid flicker
285
288
  this.updateState({
289
+ status: newStatus,
286
290
  diff: displayDiff,
291
+ combinedFileDiffs,
292
+ hunkCounts,
287
293
  isLoading: false,
288
294
  });
289
295
  }
@@ -294,6 +300,37 @@ export class GitStateManager extends EventEmitter {
294
300
  });
295
301
  }
296
302
  }
303
+ /**
304
+ * Resolve display diff and combined diffs for the currently selected file.
305
+ */
306
+ async resolveFileDiffs(newStatus, fallbackDiff) {
307
+ const currentSelectedFile = this._state.selectedFile;
308
+ if (!currentSelectedFile) {
309
+ return { displayDiff: fallbackDiff, combinedFileDiffs: null };
310
+ }
311
+ // Match by path + staged, falling back to path-only (handles staging state changes)
312
+ const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged) ?? newStatus.files.find((f) => f.path === currentSelectedFile.path);
313
+ if (!currentFile) {
314
+ this.updateState({ selectedFile: null });
315
+ return { displayDiff: fallbackDiff, combinedFileDiffs: null };
316
+ }
317
+ if (currentFile.status === 'untracked') {
318
+ const displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
319
+ return {
320
+ displayDiff,
321
+ combinedFileDiffs: { unstaged: displayDiff, staged: { raw: '', lines: [] } },
322
+ };
323
+ }
324
+ const [unstagedFileDiff, stagedFileDiff] = await Promise.all([
325
+ getDiff(this.repoPath, currentFile.path, false),
326
+ getDiff(this.repoPath, currentFile.path, true),
327
+ ]);
328
+ const displayDiff = currentFile.staged ? stagedFileDiff : unstagedFileDiff;
329
+ return {
330
+ displayDiff,
331
+ combinedFileDiffs: { unstaged: unstagedFileDiff, staged: stagedFileDiff },
332
+ };
333
+ }
297
334
  /**
298
335
  * Select a file and update the diff display.
299
336
  * The selection highlight updates immediately; the diff fetch is debounced
@@ -323,27 +360,9 @@ export class GitStateManager extends EventEmitter {
323
360
  const file = this._state.selectedFile;
324
361
  this.queue
325
362
  .enqueue(async () => {
326
- // Selection changed while queued — skip stale fetch
327
363
  if (file !== this._state.selectedFile)
328
364
  return;
329
- if (file) {
330
- let fileDiff;
331
- if (file.status === 'untracked') {
332
- fileDiff = await getDiffForUntracked(this.repoPath, file.path);
333
- }
334
- else {
335
- fileDiff = await getDiff(this.repoPath, file.path, file.staged);
336
- }
337
- if (file === this._state.selectedFile) {
338
- this.updateState({ diff: fileDiff });
339
- }
340
- }
341
- else {
342
- const allDiff = await getStagedDiff(this.repoPath);
343
- if (this._state.selectedFile === null) {
344
- this.updateState({ diff: allDiff });
345
- }
346
- }
365
+ await this.doFetchDiffForFile(file);
347
366
  })
348
367
  .catch((err) => {
349
368
  this.updateState({
@@ -351,6 +370,36 @@ export class GitStateManager extends EventEmitter {
351
370
  });
352
371
  });
353
372
  }
373
+ async doFetchDiffForFile(file) {
374
+ if (!file) {
375
+ const allDiff = await getStagedDiff(this.repoPath);
376
+ if (this._state.selectedFile === null) {
377
+ this.updateState({ diff: allDiff, combinedFileDiffs: null });
378
+ }
379
+ return;
380
+ }
381
+ if (file.status === 'untracked') {
382
+ const fileDiff = await getDiffForUntracked(this.repoPath, file.path);
383
+ if (file === this._state.selectedFile) {
384
+ this.updateState({
385
+ diff: fileDiff,
386
+ combinedFileDiffs: { unstaged: fileDiff, staged: { raw: '', lines: [] } },
387
+ });
388
+ }
389
+ return;
390
+ }
391
+ const [unstagedDiff, stagedDiff] = await Promise.all([
392
+ getDiff(this.repoPath, file.path, false),
393
+ getDiff(this.repoPath, file.path, true),
394
+ ]);
395
+ if (file === this._state.selectedFile) {
396
+ const displayDiff = file.staged ? stagedDiff : unstagedDiff;
397
+ this.updateState({
398
+ diff: displayDiff,
399
+ combinedFileDiffs: { unstaged: unstagedDiff, staged: stagedDiff },
400
+ });
401
+ }
402
+ }
354
403
  /**
355
404
  * Stage a file.
356
405
  */
@@ -381,6 +430,36 @@ export class GitStateManager extends EventEmitter {
381
430
  });
382
431
  }
383
432
  }
433
+ /**
434
+ * Stage a single hunk via patch.
435
+ */
436
+ async stageHunk(patch) {
437
+ try {
438
+ await this.queue.enqueueMutation(async () => gitStageHunk(this.repoPath, patch));
439
+ this.scheduleRefresh();
440
+ }
441
+ catch (err) {
442
+ await this.refresh();
443
+ this.updateState({
444
+ error: `Failed to stage hunk: ${err instanceof Error ? err.message : String(err)}`,
445
+ });
446
+ }
447
+ }
448
+ /**
449
+ * Unstage a single hunk via patch.
450
+ */
451
+ async unstageHunk(patch) {
452
+ try {
453
+ await this.queue.enqueueMutation(async () => gitUnstageHunk(this.repoPath, patch));
454
+ this.scheduleRefresh();
455
+ }
456
+ catch (err) {
457
+ await this.refresh();
458
+ this.updateState({
459
+ error: `Failed to unstage hunk: ${err instanceof Error ? err.message : String(err)}`,
460
+ });
461
+ }
462
+ }
384
463
  /**
385
464
  * Discard changes to a file.
386
465
  */
@@ -439,6 +518,133 @@ export class GitStateManager extends EventEmitter {
439
518
  });
440
519
  }
441
520
  }
521
+ // Remote operations
522
+ /**
523
+ * Push to remote.
524
+ */
525
+ async push() {
526
+ if (this._remoteState.inProgress)
527
+ return;
528
+ await this.runRemoteOperation('push', () => gitPush(this.repoPath));
529
+ }
530
+ /**
531
+ * Fetch from remote.
532
+ */
533
+ async fetchRemote() {
534
+ if (this._remoteState.inProgress)
535
+ return;
536
+ await this.runRemoteOperation('fetch', () => gitFetchRemote(this.repoPath));
537
+ }
538
+ /**
539
+ * Pull with rebase from remote.
540
+ */
541
+ async pullRebase() {
542
+ if (this._remoteState.inProgress)
543
+ return;
544
+ await this.runRemoteOperation('pull', () => gitPullRebase(this.repoPath));
545
+ }
546
+ async runRemoteOperation(operation, fn) {
547
+ this.updateRemoteState({ operation, inProgress: true, error: null, lastResult: null });
548
+ try {
549
+ const result = await this.queue.enqueue(fn);
550
+ this.updateRemoteState({ inProgress: false, lastResult: result });
551
+ // Refresh status to pick up new ahead/behind counts
552
+ this.scheduleRefresh();
553
+ }
554
+ catch (err) {
555
+ const message = err instanceof Error ? err.message : String(err);
556
+ this.updateRemoteState({ inProgress: false, error: message });
557
+ }
558
+ }
559
+ // Stash operations
560
+ /**
561
+ * Load the stash list.
562
+ */
563
+ async loadStashList() {
564
+ try {
565
+ const stashList = await this.queue.enqueue(() => gitGetStashList(this.repoPath));
566
+ this.updateState({ stashList });
567
+ }
568
+ catch {
569
+ // Silently ignore — stash list is non-critical
570
+ }
571
+ }
572
+ /**
573
+ * Save working changes to stash.
574
+ */
575
+ async stash(message) {
576
+ if (this._remoteState.inProgress)
577
+ return;
578
+ await this.runRemoteOperation('stash', () => gitStashSave(this.repoPath, message));
579
+ await this.loadStashList();
580
+ }
581
+ /**
582
+ * Pop a stash entry.
583
+ */
584
+ async stashPop(index = 0) {
585
+ if (this._remoteState.inProgress)
586
+ return;
587
+ await this.runRemoteOperation('stashPop', () => gitStashPop(this.repoPath, index));
588
+ await this.loadStashList();
589
+ }
590
+ // Branch operations
591
+ /**
592
+ * Get local branches.
593
+ */
594
+ async getLocalBranches() {
595
+ return this.queue.enqueue(() => gitGetLocalBranches(this.repoPath));
596
+ }
597
+ /**
598
+ * Switch to an existing branch.
599
+ */
600
+ async switchBranch(name) {
601
+ if (this._remoteState.inProgress)
602
+ return;
603
+ await this.runRemoteOperation('branchSwitch', () => gitSwitchBranch(this.repoPath, name));
604
+ // Reset compare base branch since it may not exist on the new branch
605
+ this.updateCompareState({ compareBaseBranch: null });
606
+ }
607
+ /**
608
+ * Create and switch to a new branch.
609
+ */
610
+ async createBranch(name) {
611
+ if (this._remoteState.inProgress)
612
+ return;
613
+ await this.runRemoteOperation('branchCreate', () => gitCreateBranch(this.repoPath, name));
614
+ this.updateCompareState({ compareBaseBranch: null });
615
+ }
616
+ // Undo operations
617
+ /**
618
+ * Soft reset HEAD by count commits.
619
+ */
620
+ async softReset(count = 1) {
621
+ if (this._remoteState.inProgress)
622
+ return;
623
+ await this.runRemoteOperation('softReset', () => gitSoftResetHead(this.repoPath, count));
624
+ }
625
+ // History actions
626
+ /**
627
+ * Cherry-pick a commit.
628
+ */
629
+ async cherryPick(hash) {
630
+ if (this._remoteState.inProgress)
631
+ return;
632
+ await this.runRemoteOperation('cherryPick', () => gitCherryPick(this.repoPath, hash));
633
+ }
634
+ /**
635
+ * Revert a commit.
636
+ */
637
+ async revertCommit(hash) {
638
+ if (this._remoteState.inProgress)
639
+ return;
640
+ await this.runRemoteOperation('revert', () => gitRevertCommit(this.repoPath, hash));
641
+ }
642
+ /**
643
+ * Clear the remote state (e.g. after auto-clear timeout).
644
+ */
645
+ clearRemoteState() {
646
+ this.updateRemoteState({ operation: null, error: null, lastResult: null });
647
+ }
442
648
  /**
443
649
  * Get the HEAD commit message.
444
650
  */
package/dist/git/diff.js CHANGED
@@ -41,6 +41,11 @@ export function parseHunkHeader(line) {
41
41
  */
42
42
  export function parseDiffWithLineNumbers(raw) {
43
43
  const lines = raw.split('\n');
44
+ // Remove trailing empty string from the final newline in git output,
45
+ // otherwise it gets parsed as a phantom context line on the last hunk
46
+ if (lines.length > 1 && lines[lines.length - 1] === '') {
47
+ lines.pop();
48
+ }
44
49
  const result = [];
45
50
  let oldLineNum = 0;
46
51
  let newLineNum = 0;
@@ -91,6 +96,96 @@ export function parseDiffWithLineNumbers(raw) {
91
96
  }
92
97
  return result;
93
98
  }
99
+ /**
100
+ * Count the number of hunks in a raw diff string.
101
+ * A hunk starts with a line beginning with '@@'.
102
+ */
103
+ export function countHunks(rawDiff) {
104
+ if (!rawDiff)
105
+ return 0;
106
+ let count = 0;
107
+ for (const line of rawDiff.split('\n')) {
108
+ if (line.startsWith('@@'))
109
+ count++;
110
+ }
111
+ return count;
112
+ }
113
+ /**
114
+ * Extract a valid single-hunk patch from a raw diff.
115
+ * Includes all file headers (diff --git, index, new file mode,
116
+ * rename from/to, ---, +++) plus the Nth @@ hunk and its lines
117
+ * (including '' markers).
118
+ * Returns null if hunkIndex is out of range.
119
+ */
120
+ export function extractHunkPatch(rawDiff, hunkIndex) {
121
+ if (!rawDiff)
122
+ return null;
123
+ const lines = rawDiff.split('\n');
124
+ // Collect file headers (everything before the first @@)
125
+ const headers = [];
126
+ let firstHunkLine = -1;
127
+ for (let i = 0; i < lines.length; i++) {
128
+ if (lines[i].startsWith('@@')) {
129
+ firstHunkLine = i;
130
+ break;
131
+ }
132
+ headers.push(lines[i]);
133
+ }
134
+ if (firstHunkLine === -1)
135
+ return null;
136
+ // Find the Nth @@ line
137
+ let hunkCount = -1;
138
+ let hunkStart = -1;
139
+ for (let i = firstHunkLine; i < lines.length; i++) {
140
+ if (lines[i].startsWith('@@')) {
141
+ hunkCount++;
142
+ if (hunkCount === hunkIndex) {
143
+ hunkStart = i;
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ if (hunkStart === -1)
149
+ return null;
150
+ // Collect from that @@ until the next @@ or end-of-content
151
+ const hunkLines = [lines[hunkStart]];
152
+ for (let i = hunkStart + 1; i < lines.length; i++) {
153
+ if (lines[i].startsWith('@@') || lines[i].startsWith('diff --git'))
154
+ break;
155
+ hunkLines.push(lines[i]);
156
+ }
157
+ // Remove trailing empty line if present (artifact of split)
158
+ while (hunkLines.length > 1 && hunkLines[hunkLines.length - 1] === '') {
159
+ hunkLines.pop();
160
+ }
161
+ const patch = [...headers, ...hunkLines].join('\n') + '\n';
162
+ return patch;
163
+ }
164
+ /**
165
+ * Count the number of hunks per file in a multi-file raw diff string.
166
+ * Returns a map of file path -> hunk count.
167
+ */
168
+ export function countHunksPerFile(rawDiff) {
169
+ const result = new Map();
170
+ if (!rawDiff)
171
+ return result;
172
+ let currentFile = null;
173
+ for (const line of rawDiff.split('\n')) {
174
+ if (line.startsWith('diff --git')) {
175
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
176
+ if (match) {
177
+ currentFile = match[1];
178
+ if (!result.has(currentFile)) {
179
+ result.set(currentFile, 0);
180
+ }
181
+ }
182
+ }
183
+ else if (line.startsWith('@@') && currentFile) {
184
+ result.set(currentFile, (result.get(currentFile) ?? 0) + 1);
185
+ }
186
+ }
187
+ return result;
188
+ }
94
189
  export async function getDiff(repoPath, file, staged = false) {
95
190
  const git = simpleGit(repoPath);
96
191
  try {
@@ -338,16 +433,13 @@ export async function getCommitDiff(repoPath, hash) {
338
433
  */
339
434
  export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
340
435
  const git = simpleGit(repoPath);
341
- // Get the committed PR diff first
342
436
  const committedDiff = await getDiffBetweenRefs(repoPath, baseRef);
343
- // Get uncommitted changes (both staged and unstaged)
344
437
  const stagedRaw = await git.diff(['--cached', '--numstat']);
345
438
  const unstagedRaw = await git.diff(['--numstat']);
346
439
  const stagedDiff = await git.diff(['--cached']);
347
440
  const unstagedDiff = await git.diff([]);
348
- // Parse uncommitted file stats
441
+ // Parse uncommitted file stats from numstat output
349
442
  const uncommittedFiles = new Map();
350
- // Parse staged files
351
443
  for (const line of stagedRaw
352
444
  .trim()
353
445
  .split('\n')
@@ -360,7 +452,6 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
360
452
  uncommittedFiles.set(filepath, { additions, deletions, staged: true, unstaged: false });
361
453
  }
362
454
  }
363
- // Parse unstaged files
364
455
  for (const line of unstagedRaw
365
456
  .trim()
366
457
  .split('\n')
@@ -381,7 +472,7 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
381
472
  }
382
473
  }
383
474
  }
384
- // Get status for file status detection
475
+ // Build status map from git status
385
476
  const status = await git.status();
386
477
  const statusMap = new Map();
387
478
  for (const file of status.files) {
@@ -402,7 +493,6 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
402
493
  const uncommittedFileDiffs = [];
403
494
  const combinedDiff = stagedDiff + unstagedDiff;
404
495
  const diffChunks = combinedDiff.split(/(?=^diff --git )/m).filter((chunk) => chunk.trim());
405
- // Track files we've already processed (avoid duplicates if file has both staged and unstaged)
406
496
  const processedFiles = new Set();
407
497
  for (const chunk of diffChunks) {
408
498
  const match = chunk.match(/^diff --git a\/.+ b\/(.+)$/m);
@@ -427,13 +517,9 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
427
517
  // Merge: keep committed files, add/replace with uncommitted
428
518
  const committedFilePaths = new Set(committedDiff.files.map((f) => f.path));
429
519
  const mergedFiles = [];
430
- // Add committed files first
431
520
  for (const file of committedDiff.files) {
432
521
  const uncommittedFile = uncommittedFileDiffs.find((f) => f.path === file.path);
433
522
  if (uncommittedFile) {
434
- // If file has both committed and uncommitted changes, combine them
435
- // For simplicity, we'll show committed + uncommitted as separate entries
436
- // with the uncommitted one marked
437
523
  mergedFiles.push(file);
438
524
  mergedFiles.push(uncommittedFile);
439
525
  }
@@ -441,25 +527,20 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
441
527
  mergedFiles.push(file);
442
528
  }
443
529
  }
444
- // Add uncommitted-only files (not in committed diff)
445
530
  for (const file of uncommittedFileDiffs) {
446
531
  if (!committedFilePaths.has(file.path)) {
447
532
  mergedFiles.push(file);
448
533
  }
449
534
  }
450
- // Calculate new totals including uncommitted
535
+ // Calculate totals
451
536
  let totalAdditions = 0;
452
537
  let totalDeletions = 0;
453
538
  const seenPaths = new Set();
454
539
  for (const file of mergedFiles) {
455
- // Count unique file paths for stats
456
- if (!seenPaths.has(file.path)) {
457
- seenPaths.add(file.path);
458
- }
540
+ seenPaths.add(file.path);
459
541
  totalAdditions += file.additions;
460
542
  totalDeletions += file.deletions;
461
543
  }
462
- // Sort files alphabetically by path
463
544
  mergedFiles.sort((a, b) => a.path.localeCompare(b.path));
464
545
  return {
465
546
  baseBranch: committedDiff.baseBranch,