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.
- package/.dependency-cruiser.cjs +67 -0
- package/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +15 -0
- package/README.md +43 -35
- package/bun.lock +60 -4
- package/dist/App.js +495 -131
- package/dist/KeyBindings.js +134 -10
- package/dist/MouseHandlers.js +67 -20
- package/dist/core/ExplorerStateManager.js +37 -75
- package/dist/core/GitStateManager.js +252 -46
- package/dist/git/diff.js +99 -18
- package/dist/git/status.js +111 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +54 -43
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +22 -0
- package/dist/types/remote.js +5 -0
- package/dist/ui/PaneRenderers.js +45 -15
- package/dist/ui/modals/BranchPicker.js +157 -0
- package/dist/ui/modals/CommitActionConfirm.js +66 -0
- package/dist/ui/modals/FileFinder.js +45 -75
- package/dist/ui/modals/HotkeysModal.js +35 -3
- package/dist/ui/modals/SoftResetConfirm.js +68 -0
- package/dist/ui/modals/StashListModal.js +98 -0
- package/dist/ui/modals/ThemePicker.js +1 -2
- package/dist/ui/widgets/CommitPanel.js +113 -7
- package/dist/ui/widgets/CompareListView.js +44 -23
- package/dist/ui/widgets/DiffView.js +216 -170
- package/dist/ui/widgets/ExplorerView.js +50 -54
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -15
- package/dist/ui/widgets/Header.js +51 -9
- package/dist/ui/widgets/fileRowFormatters.js +73 -0
- package/dist/utils/ansiTruncate.js +0 -1
- package/dist/utils/displayRows.js +101 -21
- package/dist/utils/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +0 -1
- package/metrics/v0.2.2.json +229 -0
- package/metrics/v0.2.3.json +243 -0
- 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
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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,
|