Skip to content
This repository was archived by the owner on Apr 15, 2026. It is now read-only.

Commit 8aa2efc

Browse files
authored
Merge pull request #108 from everpcpc/bangkok-w5gcz2se-1
Switch diff panel to @pierre/diffs and harden hunk staging
2 parents d46c2b4 + 7982e71 commit 8aa2efc

16 files changed

Lines changed: 1594 additions & 447 deletions

File tree

packages/desktop/src/features/git/StagingManager.ts

Lines changed: 80 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export interface StageLinesResult {
6969
}
7070

7171
export class GitStagingManager {
72+
// Keep in sync with UI working-tree diff context (DiffManager.WORKING_DIFF_CONTEXT_LINES).
73+
private readonly UI_WORKING_DIFF_CONTEXT_LINES = 3;
74+
7275
constructor(
7376
private gitExecutor: GitExecutor,
7477
private statusManager: GitStatusManager
@@ -93,7 +96,7 @@ export class GitStagingManager {
9396
console.debug('[GitStagingManager] Got diff, length:', fullDiff.length);
9497

9598
// Check for binary files
96-
if (fullDiff.includes('Binary files differ')) {
99+
if (this.isBinaryDiffOutput(fullDiff)) {
97100
return {
98101
success: false,
99102
error: 'Cannot stage individual lines of binary files',
@@ -151,23 +154,18 @@ export class GitStagingManager {
151154
async stageHunk(options: StageHunkOptions): Promise<StageLinesResult> {
152155
try {
153156
const scope = options.isStaging ? 'unstaged' : 'staged';
154-
// Keep hunk operations aligned with the UI diff (unified=0), so the hunk header matches.
155-
const fullDiff = await this.getFileDiff(options.worktreePath, options.filePath, scope, options.sessionId, 0);
156-
157-
if (fullDiff.includes('Binary files differ')) {
158-
return { success: false, error: 'Cannot stage hunks of binary files' };
159-
}
160-
161-
const hunks = this.parseDiffIntoHunks(fullDiff);
162-
if (hunks.length === 0) {
163-
return { success: false, error: 'No changes found in diff' };
164-
}
165-
166-
const normalizedHeader = options.hunkHeader.trim();
167-
const targetHunk = hunks.find((h) => h.header.trim() === normalizedHeader);
168-
if (!targetHunk) {
169-
return { success: false, error: 'Target hunk not found in diff' };
157+
const resolved = await this.resolveTargetHunkByHeader({
158+
worktreePath: options.worktreePath,
159+
filePath: options.filePath,
160+
scope,
161+
sessionId: options.sessionId,
162+
hunkHeader: options.hunkHeader,
163+
binaryError: 'Cannot stage hunks of binary files',
164+
});
165+
if ('error' in resolved) {
166+
return { success: false, error: resolved.error };
170167
}
168+
const targetHunk = resolved.hunk;
171169

172170
const patch = this.generateHunkPatch(targetHunk, options.filePath);
173171
return await this.applyPatch(options.worktreePath, patch, options.isStaging, options.sessionId, {
@@ -190,23 +188,18 @@ export class GitStagingManager {
190188
async restoreHunk(options: RestoreHunkOptions): Promise<StageLinesResult> {
191189
try {
192190
const fullDiffScope = options.scope === 'staged' ? 'staged' : 'unstaged';
193-
// Keep hunk operations aligned with the UI diff (unified=0), so the hunk header matches.
194-
const fullDiff = await this.getFileDiff(options.worktreePath, options.filePath, fullDiffScope, options.sessionId, 0);
195-
196-
if (fullDiff.includes('Binary files differ')) {
197-
return { success: false, error: 'Cannot restore hunks of binary files' };
198-
}
199-
200-
const hunks = this.parseDiffIntoHunks(fullDiff);
201-
if (hunks.length === 0) {
202-
return { success: false, error: 'No changes found in diff' };
203-
}
204-
205-
const normalizedHeader = options.hunkHeader.trim();
206-
const targetHunk = hunks.find((h) => h.header.trim() === normalizedHeader);
207-
if (!targetHunk) {
208-
return { success: false, error: 'Target hunk not found in diff' };
191+
const resolved = await this.resolveTargetHunkByHeader({
192+
worktreePath: options.worktreePath,
193+
filePath: options.filePath,
194+
scope: fullDiffScope,
195+
sessionId: options.sessionId,
196+
hunkHeader: options.hunkHeader,
197+
binaryError: 'Cannot restore hunks of binary files',
198+
});
199+
if ('error' in resolved) {
200+
return { success: false, error: resolved.error };
209201
}
202+
const targetHunk = resolved.hunk;
210203

211204
const patch = this.generateHunkPatch(targetHunk, options.filePath);
212205

@@ -331,6 +324,51 @@ export class GitStagingManager {
331324
}
332325
}
333326

327+
private async resolveTargetHunkByHeader(options: {
328+
worktreePath: string;
329+
filePath: string;
330+
scope: 'staged' | 'unstaged';
331+
sessionId: string;
332+
hunkHeader: string;
333+
binaryError: string;
334+
}): Promise<{ hunk: Hunk } | { error: string }> {
335+
const normalizedHeader = options.hunkHeader.trim();
336+
if (!normalizedHeader) {
337+
return { error: 'Invalid hunk header' };
338+
}
339+
340+
let sawAnyHunks = false;
341+
const unifiedCandidates = [0, this.UI_WORKING_DIFF_CONTEXT_LINES];
342+
const attemptedUnified = new Set<number>();
343+
344+
for (const unified of unifiedCandidates) {
345+
if (attemptedUnified.has(unified)) continue;
346+
attemptedUnified.add(unified);
347+
348+
const fullDiff = await this.getFileDiff(
349+
options.worktreePath,
350+
options.filePath,
351+
options.scope,
352+
options.sessionId,
353+
unified
354+
);
355+
356+
if (this.isBinaryDiffOutput(fullDiff)) {
357+
return { error: options.binaryError };
358+
}
359+
360+
const hunks = this.parseDiffIntoHunks(fullDiff);
361+
if (hunks.length === 0) continue;
362+
sawAnyHunks = true;
363+
364+
const target = hunks.find((h) => h.header.trim() === normalizedHeader);
365+
if (target) return { hunk: target };
366+
}
367+
368+
if (!sawAnyHunks) return { error: 'No changes found in diff' };
369+
return { error: 'Target hunk not found in diff' };
370+
}
371+
334372
private async getFileDiff(
335373
worktreePath: string,
336374
filePath: string,
@@ -360,6 +398,15 @@ export class GitStagingManager {
360398
return result.stdout;
361399
}
362400

401+
private isBinaryDiffOutput(diffText: string): boolean {
402+
for (const rawLine of diffText.split('\n')) {
403+
const line = rawLine.trimEnd();
404+
if (line === 'GIT binary patch') return true;
405+
if (line.startsWith('Binary files ') && line.endsWith(' differ')) return true;
406+
}
407+
return false;
408+
}
409+
363410
/**
364411
* Parse diff text into hunks with line number tracking
365412
*/

packages/desktop/src/features/git/__tests__/StagingManager.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,113 @@ Binary files differ`;
690690
argv: expect.arrayContaining(['git', 'apply', '-R']),
691691
}));
692692
});
693+
694+
it('should fallback to unified=3 hunk lookup when unified=0 header does not match', async () => {
695+
const diffUnified0 = `diff --git a/test.txt b/test.txt
696+
--- a/test.txt
697+
+++ b/test.txt
698+
@@ -2 +2 @@
699+
-old line
700+
+new line`;
701+
702+
const diffUnified3 = `diff --git a/test.txt b/test.txt
703+
--- a/test.txt
704+
+++ b/test.txt
705+
@@ -1,3 +1,3 @@
706+
context-before
707+
-old line
708+
+new line
709+
context-after`;
710+
711+
vi.mocked(mockGitExecutor.run)
712+
.mockResolvedValueOnce({
713+
// First lookup: --unified=0
714+
exitCode: 0,
715+
stdout: diffUnified0,
716+
stderr: '',
717+
commandDisplay: 'git diff --unified=0',
718+
} as any)
719+
.mockResolvedValueOnce({
720+
// Fallback lookup: --unified=3
721+
exitCode: 0,
722+
stdout: diffUnified3,
723+
stderr: '',
724+
commandDisplay: 'git diff --unified=3',
725+
} as any)
726+
.mockResolvedValueOnce({
727+
// Apply selected hunk patch
728+
exitCode: 0,
729+
stdout: '',
730+
stderr: '',
731+
commandDisplay: 'git apply --cached',
732+
} as any);
733+
734+
const result = await stagingManager.stageHunk({
735+
worktreePath: '/tmp/project',
736+
sessionId: 'test-session',
737+
filePath: 'test.txt',
738+
isStaging: true,
739+
hunkHeader: '@@ -1,3 +1,3 @@',
740+
});
741+
742+
expect(result.success).toBe(true);
743+
expect(mockGitExecutor.run).toHaveBeenCalledTimes(3);
744+
745+
expect(mockGitExecutor.run).toHaveBeenNthCalledWith(1, expect.objectContaining({
746+
argv: expect.arrayContaining(['git', 'diff', '--unified=0']),
747+
}));
748+
749+
expect(mockGitExecutor.run).toHaveBeenNthCalledWith(2, expect.objectContaining({
750+
argv: expect.arrayContaining(['git', 'diff', '--unified=3']),
751+
}));
752+
753+
const [, patchText] = (fs.promises.writeFile as any).mock.calls[0] as [string, string, string];
754+
expect(patchText).toContain('@@ -1,3 +1,3 @@');
755+
expect(patchText).toContain('context-before');
756+
expect(patchText).toContain('context-after');
757+
});
758+
759+
it('should not treat source lines containing "Binary files differ" as binary diff metadata', async () => {
760+
const mockDiff = `diff --git a/test.txt b/test.txt
761+
--- a/test.txt
762+
+++ b/test.txt
763+
@@ -1,1 +1,1 @@
764+
-if (fullDiff.includes('Binary files differ')) {
765+
+if (this.isBinaryDiffOutput(fullDiff)) {`;
766+
767+
vi.mocked(mockGitExecutor.run)
768+
.mockResolvedValueOnce({
769+
// First call: get diff
770+
exitCode: 0,
771+
stdout: mockDiff,
772+
stderr: '',
773+
commandDisplay: 'git diff',
774+
} as any)
775+
.mockResolvedValueOnce({
776+
// Second call: apply patch
777+
exitCode: 0,
778+
stdout: '',
779+
stderr: '',
780+
commandDisplay: 'git apply',
781+
} as any);
782+
783+
const result = await stagingManager.stageHunk({
784+
worktreePath: '/tmp/project',
785+
sessionId: 'test-session',
786+
filePath: 'test.txt',
787+
isStaging: true,
788+
hunkHeader: '@@ -1,1 +1,1 @@',
789+
});
790+
791+
expect(result.success).toBe(true);
792+
expect(mockGitExecutor.run).toHaveBeenCalledTimes(2);
793+
expect(mockGitExecutor.run).toHaveBeenNthCalledWith(1, expect.objectContaining({
794+
argv: expect.arrayContaining(['git', 'diff', '--unified=0']),
795+
}));
796+
expect(mockGitExecutor.run).toHaveBeenNthCalledWith(2, expect.objectContaining({
797+
argv: expect.arrayContaining(['git', 'apply', '--cached']),
798+
}));
799+
});
693800
});
694801

695802
describe('restoreHunk', () => {
@@ -788,6 +895,73 @@ Binary files differ`;
788895
argv: expect.arrayContaining(['git', 'apply', '-R']),
789896
}));
790897
});
898+
899+
it('should fallback to unified=3 hunk lookup when restoring unstaged hunk', async () => {
900+
const diffUnified0 = `diff --git a/test.txt b/test.txt
901+
--- a/test.txt
902+
+++ b/test.txt
903+
@@ -2 +2 @@
904+
-old line
905+
+new line`;
906+
907+
const diffUnified3 = `diff --git a/test.txt b/test.txt
908+
--- a/test.txt
909+
+++ b/test.txt
910+
@@ -1,3 +1,3 @@
911+
context-before
912+
-old line
913+
+new line
914+
context-after`;
915+
916+
vi.mocked(mockGitExecutor.run)
917+
.mockResolvedValueOnce({
918+
// First lookup: --unified=0
919+
exitCode: 0,
920+
stdout: diffUnified0,
921+
stderr: '',
922+
commandDisplay: 'git diff --unified=0',
923+
} as any)
924+
.mockResolvedValueOnce({
925+
// Fallback lookup: --unified=3
926+
exitCode: 0,
927+
stdout: diffUnified3,
928+
stderr: '',
929+
commandDisplay: 'git diff --unified=3',
930+
} as any)
931+
.mockResolvedValueOnce({
932+
// Restore in worktree
933+
exitCode: 0,
934+
stdout: '',
935+
stderr: '',
936+
commandDisplay: 'git apply -R',
937+
} as any);
938+
939+
const result = await stagingManager.restoreHunk({
940+
worktreePath: '/tmp/project',
941+
sessionId: 'test-session',
942+
filePath: 'test.txt',
943+
scope: 'unstaged',
944+
hunkHeader: '@@ -1,3 +1,3 @@',
945+
});
946+
947+
expect(result.success).toBe(true);
948+
expect(mockGitExecutor.run).toHaveBeenCalledTimes(3);
949+
950+
expect(mockGitExecutor.run).toHaveBeenNthCalledWith(1, expect.objectContaining({
951+
argv: expect.arrayContaining(['git', 'diff', '--unified=0']),
952+
}));
953+
954+
expect(mockGitExecutor.run).toHaveBeenNthCalledWith(2, expect.objectContaining({
955+
argv: expect.arrayContaining(['git', 'diff', '--unified=3']),
956+
}));
957+
958+
expect(mockGitExecutor.run).toHaveBeenNthCalledWith(3, expect.objectContaining({
959+
argv: expect.arrayContaining(['git', 'apply', '-R']),
960+
}));
961+
expect(mockGitExecutor.run).toHaveBeenNthCalledWith(3, expect.not.objectContaining({
962+
argv: expect.arrayContaining(['--cached']),
963+
}));
964+
});
791965
});
792966

793967
describe('changeAllStage', () => {

0 commit comments

Comments
 (0)