From 354c164490a6fd09d393cae90ebe34688ab8269a Mon Sep 17 00:00:00 2001 From: sehlceris Date: Tue, 23 Jun 2026 10:03:25 -0700 Subject: [PATCH 1/5] feat(git): reverse commit changes by file, hunk, or line Add backend support for undoing part of a past commit against the working tree. `buildReversePatch` constructs a minimal, reverse-applicable patch for a single hunk (optionally narrowed to selected lines), and `GitService.reverseCommitChanges` applies it with `git apply --reverse --recount`. A new `reverseCommitChanges` message routes the request through MainPanel, which reports completion and shows the reverse toast. Named "reverse" (not "revert") because it reverse-applies to the working tree without creating a commit, and to distinguish it from the existing `git revert`. The patch builder reconstructs `\ No newline at end of file` markers from the final hunk state rather than re-emitting them per source line, so partial-line reverses on files without a trailing newline no longer corrupt the result. Covered by patch-builder unit tests and real-git integration tests, including no-trailing-newline edge cases. Claude-Session: https://claude.ai/code/session_01B3yKdHuNTMUk6foSE96buB --- l10n/bundle.l10n.json | 1 + l10n/bundle.l10n.ko.json | 1 + l10n/bundle.l10n.zh-cn.json | 1 + .../reverse-changes.integration.test.ts | 187 ++++++++++++ src/git/__tests__/patch-builder.test.ts | 209 ++++++++++++++ src/git/git-service.ts | 60 ++++ src/git/patch-builder.ts | 267 ++++++++++++++++++ src/panels/MainPanel.ts | 8 + src/utils/message-bus.ts | 1 + 9 files changed, 735 insertions(+) create mode 100644 src/git/__tests__/integration/reverse-changes.integration.test.ts create mode 100644 src/git/__tests__/patch-builder.test.ts create mode 100644 src/git/patch-builder.ts diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 36f8b30..fbfe039 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -93,6 +93,7 @@ "nCommits": "{0} commits", "pushAfterCherryPickFailed": "Cherry-pick succeeded, but the follow-up push failed: {0}", "reverted": "Reverted {0}", + "reversedFileChange": "Reversed changes to {0}", "revertedAndPushed": "Reverted {0} and pushed", "pushAfterRevertFailed": "Revert succeeded, but the follow-up push failed: {0}", "resetComplete": "Reset to {0}", diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index 2138af7..9f3f670 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -93,6 +93,7 @@ "nCommits": "commit {0}개", "pushAfterCherryPickFailed": "Cherry-pick은 성공했지만 이후 push에 실패했습니다: {0}", "reverted": "{0}이(가) revert 되었습니다", + "reversedFileChange": "{0}의 변경 사항을 되돌렸습니다", "revertedAndPushed": "{0}이(가) revert 후 push 되었습니다", "pushAfterRevertFailed": "Revert는 성공했지만 이후 push에 실패했습니다: {0}", "resetComplete": "{0}으(로) reset 되었습니다", diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 560130d..10b9b0b 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -93,6 +93,7 @@ "nCommits": "{0} 个提交", "pushAfterCherryPickFailed": "遴选成功,但后续 push 失败:{0}", "reverted": "已还原 {0}", + "reversedFileChange": "已还原对 {0} 的更改", "revertedAndPushed": "已还原 {0} 并完成 push", "pushAfterRevertFailed": "还原成功,但后续 push 失败:{0}", "resetComplete": "已重置到 {0}", diff --git a/src/git/__tests__/integration/reverse-changes.integration.test.ts b/src/git/__tests__/integration/reverse-changes.integration.test.ts new file mode 100644 index 0000000..b201e52 --- /dev/null +++ b/src/git/__tests__/integration/reverse-changes.integration.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { GitService } from '../../git-service'; +import { TempRepo, commit, createTempRepo, runGit } from './helpers'; + +// Two edits far enough apart (line 2 and line 14) that git keeps them in +// separate hunks rather than merging them. +const BASE_F = 'alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\niota\nkappa\nlambda\nmu\nnu\nxi\nomicron\npi\n'; +const CHANGED_F = 'alpha\nbeta2\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\niota\nkappa\nlambda\nmu\nnu\nxi2\nomicron\npi\n'; +const BASE_G = 'one\ntwo\nthree\nfour\nfive\n'; +const CHANGED_G = 'one\ntwo\nINSERTED-A\nINSERTED-B\nthree\nfour\nfive\n'; +// Two edits close enough (line 2 and line 5) that git keeps them in ONE hunk, +// separated by context lines — two distinct change blocks within the hunk. +const BASE_H = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n'; +const CHANGED_H = 'l1\nl2X\nl3\nl4\nl5X\nl6\nl7\nl8\n'; + +function read(repo: TempRepo, file: string): string { + return readFileSync(join(repo.path, file), 'utf-8'); +} + +describe('GitService integration — reverseCommitChanges', () => { + let repo: TempRepo; + let svc: GitService; + let hash: string; + + beforeEach(() => { + repo = createTempRepo(); + svc = new GitService(repo.path); + commit(repo.path, 'base', { 'f.txt': BASE_F, 'g.txt': BASE_G, 'h.txt': BASE_H }); + // One commit touching three files: f.txt with two separated hunks, g.txt + // with a single pure-addition hunk, h.txt with two change regions in one hunk. + hash = commit(repo.path, 'change', { 'f.txt': CHANGED_F, 'g.txt': CHANGED_G, 'h.txt': CHANGED_H }); + }); + afterEach(() => repo.cleanup()); + + it('reverses a whole file back to its pre-commit state in the working tree', async () => { + await svc.reverseCommitChanges(hash, 'f.txt'); + expect(read(repo, 'f.txt')).toBe(BASE_F); + // Lands as an unstaged working-tree change, not a commit. + const status = runGit(repo.path, ['status', '--porcelain', 'f.txt']); + expect(status.trim()).toBe('M f.txt'); + expect(runGit(repo.path, ['rev-parse', 'HEAD']).trim()).toBe(hash); + }); + + it('reverses a single hunk, leaving the other hunk intact', async () => { + const diff = (await svc.showCommitDiff(hash, 'f.txt'))[0]; + expect(diff.hunks.length).toBe(2); + // Hunk 0 covers "beta", hunk 1 covers "xi". + await svc.reverseCommitChanges(hash, 'f.txt', { hunkIndex: 0 }); + + const result = read(repo, 'f.txt'); + expect(result).toContain('\nbeta\n'); // hunk 0 reversed + expect(result).toContain('\nxi2\n'); // hunk 1 untouched + }); + + it('reverses a whole hunk containing multiple change regions (reverses all of them)', async () => { + // h.txt has two change regions (l2→l2X and l5→l5X) bundled into one hunk. + const diff = (await svc.showCommitDiff(hash, 'h.txt'))[0]; + expect(diff.hunks.length).toBe(1); + await svc.reverseCommitChanges(hash, 'h.txt', { hunkIndex: 0 }); + + // Reversing the hunk restores BOTH regions, not just one. + expect(read(repo, 'h.txt')).toBe(BASE_H); + }); + + it('reverses a single added line, leaving its sibling addition intact', async () => { + const hunk = (await svc.showCommitDiff(hash, 'g.txt'))[0].hunks[0]; + const idxB = hunk.lines.findIndex((l) => l.type === 'add' && l.content === 'INSERTED-B'); + expect(idxB).toBeGreaterThanOrEqual(0); + + await svc.reverseCommitChanges(hash, 'g.txt', { hunkIndex: 0, lineIndices: [idxB] }); + + const result = read(repo, 'g.txt'); + expect(result).toContain('INSERTED-A'); + expect(result).not.toContain('INSERTED-B'); + }); + + it('reverses a 2-line selection, leaving the rest of the hunk intact', async () => { + const hunk = (await svc.showCommitDiff(hash, 'g.txt'))[0].hunks[0]; + const idxA = hunk.lines.findIndex((l) => l.type === 'add' && l.content === 'INSERTED-A'); + const idxB = hunk.lines.findIndex((l) => l.type === 'add' && l.content === 'INSERTED-B'); + expect(idxA).toBeGreaterThanOrEqual(0); + expect(idxB).toBeGreaterThanOrEqual(0); + + // Both inserted lines selected → reversing them restores the original file. + await svc.reverseCommitChanges(hash, 'g.txt', { hunkIndex: 0, lineIndices: [idxA, idxB] }); + expect(read(repo, 'g.txt')).toBe(BASE_G); + }); + + it('reverses a selected region of a multi-region hunk, leaving the other region intact', async () => { + // h.txt's single hunk has two regions (l2→l2X and l5→l5X). Select only the + // first region's modify pair (the delete + its following add). + const hunk = (await svc.showCommitDiff(hash, 'h.txt'))[0].hunks[0]; + const delIdx = hunk.lines.findIndex((l) => l.type === 'delete'); + expect(hunk.lines[delIdx + 1].type).toBe('add'); + + await svc.reverseCommitChanges(hash, 'h.txt', { hunkIndex: 0, lineIndices: [delIdx, delIdx + 1] }); + + const result = read(repo, 'h.txt'); + expect(result).toContain('\nl2\n'); // first region reversed + expect(result).not.toContain('l2X'); + expect(result).toContain('\nl5X\n'); // second region untouched + }); + + it('reverses the addition of a file created by the commit (deletes it)', async () => { + const added = commit(repo.path, 'add new', { 'new.txt': 'fresh\n' }); + expect(existsSync(join(repo.path, 'new.txt'))).toBe(true); + await svc.reverseCommitChanges(added, 'new.txt'); + // Reversing a file-creation diff removes the file from the working tree. + expect(existsSync(join(repo.path, 'new.txt'))).toBe(false); + }); + + it('throws when the file was not changed by the commit', async () => { + await expect(svc.reverseCommitChanges(hash, 'does-not-exist.txt')).rejects.toThrow(); + }); +}); + +// Files WITHOUT a trailing newline exercise the "\ No newline at end of file" +// marker handling in the patch builder. These read raw bytes so the assertions +// distinguish a present vs. absent trailing newline. +describe('GitService integration — reverseCommitChanges with no-trailing-newline files', () => { + let repo: TempRepo; + let svc: GitService; + + beforeEach(() => { + repo = createTempRepo(); + svc = new GitService(repo.path); + }); + afterEach(() => repo.cleanup()); + + function readBytes(file: string): string { + return readFileSync(join(repo.path, file), 'utf-8'); + } + + // Helper: commit `before` then `after` (both written verbatim, no normalization), + // returning the SHA of the second commit. + function commitPair(file: string, before: string, after: string): string { + commit(repo.path, 'base', { [file]: before }); + return commit(repo.path, 'change', { [file]: after }); + } + + it('Scenario A: reverses only the last added line, restoring "a\\nP" with no trailing newline', async () => { + // old `a` (no EOF nl) → new `a\nP\nQ` (no EOF nl). Reverse only `Q`. + const hash = commitPair('f', 'a', 'a\nP\nQ'); + const hunk = (await svc.showCommitDiff(hash, 'f'))[0].hunks[0]; + const idxQ = hunk.lines.findIndex((l) => l.type === 'add' && l.content === 'Q'); + expect(idxQ).toBeGreaterThanOrEqual(0); + + await svc.reverseCommitChanges(hash, 'f', { hunkIndex: 0, lineIndices: [idxQ] }); + + // Must be exactly "a\nP" — NOT "a\nP\n" (the old bug gained a trailing newline). + expect(readBytes('f')).toBe('a\nP'); + }); + + it('Scenario B: reverses only the deleted line, restoring it on its own line without fusing', async () => { + // old `X\nold` (no EOF nl) → new `X\nnew1\nnew2` (no EOF nl). Reverse only `old`. + const hash = commitPair('f', 'X\nold', 'X\nnew1\nnew2'); + const hunk = (await svc.showCommitDiff(hash, 'f'))[0].hunks[0]; + const idxOld = hunk.lines.findIndex((l) => l.type === 'delete' && l.content === 'old'); + expect(idxOld).toBeGreaterThanOrEqual(0); + + await svc.reverseCommitChanges(hash, 'f', { hunkIndex: 0, lineIndices: [idxOld] }); + + // Must be "X\nold\nnew1\nnew2" — NOT "X\noldnew1\nnew2" (old bug fused old+new1). + expect(readBytes('f')).toBe('X\nold\nnew1\nnew2'); + }); + + it('reverses a whole no-newline hunk back to the pre-commit bytes', async () => { + const hash = commitPair('f', 'a\ntail', 'A\ntail'); + await svc.reverseCommitChanges(hash, 'f'); + // Trailing newline absence is preserved through the round-trip. + expect(readBytes('f')).toBe('a\ntail'); + }); + + it('regression: a normal newline-terminated single-line reverse still works', async () => { + // The common path — files DO end in a newline — must remain correct. + const hash = commitPair('f', 'a\nb\n', 'a\nb\nc\n'); + const hunk = (await svc.showCommitDiff(hash, 'f'))[0].hunks[0]; + const idxC = hunk.lines.findIndex((l) => l.type === 'add' && l.content === 'c'); + expect(idxC).toBeGreaterThanOrEqual(0); + + await svc.reverseCommitChanges(hash, 'f', { hunkIndex: 0, lineIndices: [idxC] }); + + expect(readBytes('f')).toBe('a\nb\n'); + }); +}); diff --git a/src/git/__tests__/patch-builder.test.ts b/src/git/__tests__/patch-builder.test.ts new file mode 100644 index 0000000..7b3fb4b --- /dev/null +++ b/src/git/__tests__/patch-builder.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from 'vitest'; +import { buildReversePatch } from '../patch-builder'; +import { parseDiff } from '../git-parser'; + +// A replace hunk (delete + two adds) plus surrounding context, the shape the +// webview hands back line indices for. +const SAMPLE = `diff --git a/file.txt b/file.txt +index 1111111..2222222 100644 +--- a/file.txt ++++ b/file.txt +@@ -1,4 +1,5 @@ + line1 +-line2 ++line2-changed ++line2b + line3 + line4 +`; + +describe('buildReversePatch', () => { + it('reverses a whole hunk verbatim with recounted header', () => { + const patch = buildReversePatch(SAMPLE, 0); + expect(patch).toBe( + [ + 'diff --git a/file.txt b/file.txt', + 'index 1111111..2222222 100644', + '--- a/file.txt', + '+++ b/file.txt', + '@@ -1,4 +1,5 @@', + ' line1', + '-line2', + '+line2-changed', + '+line2b', + ' line3', + ' line4', + '', + ].join('\n'), + ); + }); + + it('demotes unselected additions to context and drops unselected deletions', () => { + // Reverse only the second added line (index 3 in the parsed hunk). + const hunk = parseDiff(SAMPLE)[0].hunks[0]; + const idx = hunk.lines.findIndex((l) => l.type === 'add' && l.content === 'line2b'); + expect(idx).toBe(3); + + const patch = buildReversePatch(SAMPLE, 0, [idx]); + expect(patch).toBe( + [ + 'diff --git a/file.txt b/file.txt', + 'index 1111111..2222222 100644', + '--- a/file.txt', + '+++ b/file.txt', + '@@ -1,4 +1,5 @@', + ' line1', + ' line2-changed', // unselected add → context (stays in working tree) + '+line2b', // selected add → reversed away + ' line3', + ' line4', + '', + ].join('\n'), + ); + }); + + it('keeps a selected deletion so reverse-apply restores it', () => { + const hunk = parseDiff(SAMPLE)[0].hunks[0]; + const idx = hunk.lines.findIndex((l) => l.type === 'delete'); + const patch = buildReversePatch(SAMPLE, 0, [idx]); + const body = patch.split('\n'); + expect(body).toContain('-line2'); // restored on reverse-apply + expect(body).not.toContain('+line2-changed'); // unselected add dropped from new side + expect(body).toContain(' line2-changed'); // ...as context instead + }); + + it('preserves a "No newline at end of file" marker', () => { + const noEof = `diff --git a/f b/f +index 1..2 100644 +--- a/f ++++ b/f +@@ -1 +1 @@ +-old +\\ No newline at end of file ++new +\\ No newline at end of file +`; + const patch = buildReversePatch(noEof, 0); + expect(patch).toContain('-old\n\\ No newline at end of file'); + expect(patch).toContain('+new\n\\ No newline at end of file'); + }); + + // Scenario A: old file `a` (no EOF newline) → new `a\nP\nQ` (no EOF newline). + // Reversing ONLY the last added line `Q`. The new side ends unterminated at the + // kept add `+Q`. Reversing `+Q` away leaves `P` as the file's last line, which + // must ALSO be unterminated — but `P` is a shared (demoted) line followed by + // `+Q`, so a bare marker after it would wrongly truncate the new side too. The + // builder splits that shared line into `-P`⟵marker / `+P`, terminating only the + // old side while the new side continues to `+Q`. (The old buggy code emitted + // ` P` then `+Q`⟵marker, which reverse-applied to `a\nP\n`, gaining a newline.) + it('Scenario A: splits the shared tail when a no-newline trailing add is reversed', () => { + const noEof = `diff --git a/f b/f +index 2e65efe..e61a8e2 100644 +--- a/f ++++ b/f +@@ -1 +1,3 @@ +-a +\\ No newline at end of file ++a ++P ++Q +\\ No newline at end of file +`; + const hunk = parseDiff(noEof)[0].hunks[0]; + const idxQ = hunk.lines.findIndex((l) => l.type === 'add' && l.content === 'Q'); + const patch = buildReversePatch(noEof, 0, [idxQ]); + expect(patch).toBe( + [ + 'diff --git a/f b/f', + 'index 2e65efe..e61a8e2 100644', + '--- a/f', + '+++ b/f', + '@@ -1,2 +1,3 @@', + ' a', + '-P', + '\\ No newline at end of file', // old side ends unterminated at P, + '+P', // ...while the new side continues... + '+Q', + '\\ No newline at end of file', // ...to the unterminated new EOF at Q. + '', + ].join('\n'), + ); + }); + + // Scenario B: old `X\nold` (no EOF) → new `X\nnew1\nnew2` (no EOF). Reversing + // ONLY the deleted line `old`. The old buggy code kept `-old` WITH its inline + // marker, so reverse-apply fused `old` onto `new1` (`X\noldnew1\nnew2`). The + // marker actually belongs only on the genuine shared last line (`new2`), once. + it('Scenario B: re-anchors the no-newline marker when only a delete is reversed', () => { + const noEof = `diff --git a/f b/f +index 84951a9..3e493e2 100644 +--- a/f ++++ b/f +@@ -1,2 +1,3 @@ + X +-old +\\ No newline at end of file ++new1 ++new2 +\\ No newline at end of file +`; + const hunk = parseDiff(noEof)[0].hunks[0]; + const idxOld = hunk.lines.findIndex((l) => l.type === 'delete' && l.content === 'old'); + const patch = buildReversePatch(noEof, 0, [idxOld]); + expect(patch).toBe( + [ + 'diff --git a/f b/f', + 'index 84951a9..3e493e2 100644', + '--- a/f', + '+++ b/f', + '@@ -1,4 +1,3 @@', + ' X', + '-old', // restored on reverse-apply, NO inline marker + ' new1', + ' new2', + '\\ No newline at end of file', // single marker on shared last context line + '', + ].join('\n'), + ); + }); + + // A no-EOF-newline diff whose hunk ends on a shared context line: the marker + // must be emitted exactly once on that final context line, not duplicated. + it('emits a single marker on a shared trailing context line', () => { + const noEof = `diff --git a/f b/f +index 4d6e807..12db783 100644 +--- a/f ++++ b/f +@@ -1,2 +1,2 @@ +-a ++A + tail +\\ No newline at end of file +`; + const patch = buildReversePatch(noEof, 0); + expect(patch).toBe( + [ + 'diff --git a/f b/f', + 'index 4d6e807..12db783 100644', + '--- a/f', + '+++ b/f', + '@@ -1,2 +1,2 @@', + '-a', + '+A', + ' tail', + '\\ No newline at end of file', + '', + ].join('\n'), + ); + }); + + it('throws for an out-of-range hunk index', () => { + expect(() => buildReversePatch(SAMPLE, 5)).toThrow(/not found/); + }); + + it('throws when the selection contains no changed lines', () => { + const hunk = parseDiff(SAMPLE)[0].hunks[0]; + const ctxIdx = hunk.lines.findIndex((l) => l.type === 'context'); + expect(() => buildReversePatch(SAMPLE, 0, [ctxIdx])).toThrow(/No changed lines/); + }); +}); diff --git a/src/git/git-service.ts b/src/git/git-service.ts index 6f35e0b..8cebf3a 100644 --- a/src/git/git-service.ts +++ b/src/git/git-service.ts @@ -14,6 +14,7 @@ import { resolveGitDirs } from '../services/file-watcher-helpers'; * extension host. Callers can override per-invocation via `maxBufferBytes`. */ const DEFAULT_MAX_BUFFER_BYTES = 256 * 1024 * 1024; import { parseLog, parseBranches, parseTags, parseRemotes, parseStashList, parseDiff, parseWorktreeList, parseLfsFiles, parseLfsLocks, mapSignatureStatus } from './git-parser'; +import { buildReversePatch } from './patch-builder'; import type { Commit, BranchInfo, TagInfo, RemoteInfo, StashEntry, LogOptions, DiffData, WorktreeInfo, CommitSignature } from './types'; export class GitError extends Error { @@ -1049,6 +1050,65 @@ export class GitService { return parseDiff(raw); } + /** Raw unified diff for one file in a commit, against the appropriate parent. + * Mirrors showCommitDiff's parent selection so the patch we reverse matches + * exactly what the user is looking at. */ + private async rawCommitFileDiff(hash: string, file: string): Promise { + this.assertSafeRef(hash, 'diff'); + this.assertSafePath(file, 'diff'); + const parents = await this.commitParents(hash); + + if (parents.length === 0) { + // Root commit: diff against the empty tree. + return this.exec(['show', '--no-color', '--format=', hash, '--', file]); + } + + if (parents.length > 1) { + // Merge: use the first parent whose diff for this file is non-empty, + // matching showCommitDiff (first-parent-only would hide later parents). + for (const parent of parents) { + this.assertSafeRef(parent, 'diff'); + const raw = await this.exec(['diff', '--no-color', `${parent}..${hash}`, '--', file]); + if (raw.trim()) { return raw; } + } + return ''; + } + + return this.exec(['diff', '--no-color', `${hash}^..${hash}`, '--', file]); + } + + /** + * Reverse-apply (undo) a commit's change to one file in the working tree. + * With no selection, undoes the whole file's change; with a hunk index, + * undoes that hunk; with line indices, undoes just those changed lines. + * The result lands in the working tree (unstaged) so the user can review it. + * + * `hunkIndex`/`lineIndices` index the diff the way the webview rendered it + * (see patch-builder / git-parser). Throws if there is nothing to reverse or + * the patch doesn't apply cleanly (e.g. the working tree has diverged). + */ + async reverseCommitChanges( + hash: string, + file: string, + selection?: { hunkIndex?: number; lineIndices?: number[] }, + ): Promise { + this.assertSafeRef(hash, 'apply'); + this.assertSafePath(file, 'apply'); + + const raw = await this.rawCommitFileDiff(hash, file); + if (!raw.trim()) { + throw new GitError(`No changes to reverse for ${file} in ${hash.substring(0, 7)}`, null, []); + } + + const patch = selection && selection.hunkIndex !== undefined + ? buildReversePatch(raw, selection.hunkIndex, selection.lineIndices) + : raw; + + // --recount lets git fix up the line counts of our reconstructed hunks; + // applying without --cached touches only the working tree. + await this.exec(['apply', '--reverse', '--recount'], { stdin: patch }); + } + // 3+ multi-select: union file list + per-commit diff sections. Every union file // has ≥1 section (it's in the union because some selected commit changed it), // so no file is left without a diff. `hashes` is newest-first; sections preserve diff --git a/src/git/patch-builder.ts b/src/git/patch-builder.ts new file mode 100644 index 0000000..7b41399 --- /dev/null +++ b/src/git/patch-builder.ts @@ -0,0 +1,267 @@ +// Builds minimal, reverse-applicable patches from a single-file unified diff so +// we can undo part of a commit's change (one hunk, or a subset of changed lines) +// against the working tree via `git apply --reverse`. +// +// The line indexing here intentionally mirrors `parseDiff` in git-parser.ts: +// `lineIndices` refer to positions in a hunk's `DiffLine[]` exactly as the +// webview sees them, so the frontend can pass back the indices it rendered. + +interface PatchEntry { + kind: 'context' | 'add' | 'delete'; + /** Raw diff line including its leading ' ', '+' or '-'. */ + text: string; + /** Trailing "\ No newline at end of file" markers attached to this line. */ + markers: string[]; +} + +interface PatchHunk { + /** The original `@@ -a,b +c,d @@ ...` header line. */ + headerLine: string; + entries: PatchEntry[]; +} + +interface ParsedFileDiff { + /** Everything before the first hunk: `diff --git`, mode/index lines, ---/+++. */ + header: string[]; + hunks: PatchHunk[]; +} + +/** Parse a single-file `git diff` (or `git show`) body into header + hunks, + * classifying body lines the same way `parseDiff` does so indices line up. + * "\ No newline at end of file" markers are attached to the line they follow + * (never counted as their own index), matching parseDiff which skips them. */ +function parseFileDiff(rawFileDiff: string): ParsedFileDiff { + const lines = rawFileDiff.split('\n'); + const firstHunk = lines.findIndex((l) => l.startsWith('@@')); + if (firstHunk === -1) { + return { header: lines, hunks: [] }; + } + + const header = lines.slice(0, firstHunk); + const hunks: PatchHunk[] = []; + let current: PatchHunk | null = null; + + for (let i = firstHunk; i < lines.length; i++) { + const line = lines[i]; + + if (line.startsWith('@@')) { + current = { headerLine: line, entries: [] }; + hunks.push(current); + continue; + } + if (!current) { continue; } + + if (line.startsWith('\\')) { + // "\ No newline at end of file" — belongs to the preceding content line. + const last = current.entries[current.entries.length - 1]; + if (last) { last.markers.push(line); } + continue; + } + + if (line.startsWith('+')) { + current.entries.push({ kind: 'add', text: line, markers: [] }); + } else if (line.startsWith('-')) { + current.entries.push({ kind: 'delete', text: line, markers: [] }); + } else if (line.startsWith(' ')) { + current.entries.push({ kind: 'context', text: line, markers: [] }); + } else if (line === '' && i < lines.length - 1) { + // A blank context line whose trailing space was stripped (git's normal + // output keeps the leading ' '). The final '' is split('\n')'s trailing + // artifact, not real content — skip it (`i < lines.length - 1`). + current.entries.push({ kind: 'context', text: ' ', markers: [] }); + } + } + + return { header, hunks }; +} + +/** Rewrite a hunk header with recomputed line counts, preserving the original + * start positions and the trailing section heading. */ +function rewriteHunkHeader(headerLine: string, oldCount: number, newCount: number): string { + const m = headerLine.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/); + if (!m) { return headerLine; } + const [, oldStart, newStart, rest] = m; + return `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${rest}`; +} + +/** A reconstructed body line, tagged with which file side(s) it appears on so + * we can find each side's last line when re-attaching no-newline markers. */ +interface BodyLine { + text: string; + onOld: boolean; + onNew: boolean; +} + +/** The "\ No newline at end of file" marker text git emits. We always re-emit + * the canonical form rather than echoing the (whitespace-identical) original. */ +const NO_NEWLINE_MARKER = '\\ No newline at end of file'; + +/** + * Build a patch containing a single hunk from `rawFileDiff`, optionally narrowed + * to a subset of that hunk's changed lines. The result is meant to be applied + * with `git apply --reverse` to undo those changes in the working tree. + * + * Selection semantics (`lineIndices` index into the hunk's `DiffLine[]`): + * - omitted → reverse every changed (+/-) line in the hunk; + * - provided → reverse only the listed +/- lines. Unselected additions become + * context (they stay in the file); unselected deletions are dropped entirely + * (they aren't in the file and we aren't restoring them). + * + * No-newline handling: a "\ No newline at end of file" marker is a property of a + * SIDE's file end, not of any fixed source line. Echoing each line's original + * marker inline corrupts the output whenever a partial selection reshuffles + * which line is last on a side. So we derive, from the original hunk, whether + * each side ends unterminated, then re-attach a single marker after whichever + * reconstructed line is actually last on that side (and only one marker when the + * old- and new-side last line is the same shared context line). + * + * @throws if the hunk index is out of range or nothing reversible is selected. + */ +export function buildReversePatch(rawFileDiff: string, hunkIndex: number, lineIndices?: number[]): string { + const { header, hunks } = parseFileDiff(rawFileDiff); + const hunk = hunks[hunkIndex]; + if (!hunk) { + throw new Error(`Hunk ${hunkIndex} not found in diff`); + } + + const selected = lineIndices ? new Set(lineIndices) : null; + const isReversing = (idx: number, kind: PatchEntry['kind']): boolean => + kind !== 'context' && (selected ? selected.has(idx) : true); + + // Determine, from the ORIGINAL hunk (before filtering), whether each side's + // file ends without a trailing newline. A context marker means BOTH sides end + // unterminated at that shared line; a delete marker → old side; an add → new. + let oldNoNewline = false; + let newNoNewline = false; + for (const entry of hunk.entries) { + if (entry.markers.length === 0) { continue; } + if (entry.kind === 'context') { oldNoNewline = true; newNoNewline = true; } + else if (entry.kind === 'delete') { oldNoNewline = true; } + else { newNoNewline = true; } + } + + // Whether the original hunk's final old-side entry survives onto the old side + // of our reconstruction (and so the old side still reaches old-EOF). Context + // is always retained; a trailing delete is retained only if selected. Adds are + // never on the old side, so they don't bear on old-EOF. + let oldReachesEof = false; + for (let i = hunk.entries.length - 1; i >= 0; i--) { + const entry = hunk.entries[i]; + if (entry.kind === 'context') { oldReachesEof = true; break; } + if (entry.kind === 'delete') { oldReachesEof = isReversing(i, 'delete'); break; } + // 'add' — not on the old side; keep scanning past it. + } + + const bodyLines: BodyLine[] = []; + let oldCount = 0; + let newCount = 0; + let reversedAny = false; + + for (let i = 0; i < hunk.entries.length; i++) { + const entry = hunk.entries[i]; + + if (entry.kind === 'context') { + bodyLines.push({ text: entry.text, onOld: true, onNew: true }); + oldCount++; + newCount++; + } else if (entry.kind === 'add') { + if (isReversing(i, 'add')) { + // Keep as an addition (new side only); `--reverse` turns it into a removal. + bodyLines.push({ text: entry.text, onOld: false, onNew: true }); + newCount++; + reversedAny = true; + } else { + // Demote to context: the line stays in the working tree (both sides). + bodyLines.push({ text: ' ' + entry.text.slice(1), onOld: true, onNew: true }); + oldCount++; + newCount++; + } + } else { + if (isReversing(i, 'delete')) { + // Keep as a removal (old side only); `--reverse` restores it. + bodyLines.push({ text: entry.text, onOld: true, onNew: false }); + oldCount++; + reversedAny = true; + } + // Unselected deletion: drop it — it's absent from the file either way. + } + } + + if (!reversedAny) { + throw new Error('No changed lines selected to reverse'); + } + + // Re-attach no-newline markers from the RECONSTRUCTED body. A marker is the + // terminator of a side's file end, so we find each side's actual last body line + // and decide whether that side ends unterminated. + let lastOld = -1; + let lastNew = -1; + for (let i = 0; i < bodyLines.length; i++) { + if (bodyLines[i].onOld) { lastOld = i; } + if (bodyLines[i].onNew) { lastNew = i; } + } + + // New side: always reaches new-EOF (adds/context are never dropped — adds may + // demote to context but remain). It ends unterminated iff the new file did. + const newEndsNoNewline = newNoNewline && lastNew !== -1; + + // Old side (the post-reverse file the reverse-apply produces). It ends + // unterminated when either: + // - the old side's last line is the same shared trailing line as the new side + // and the new file ended unterminated (the unchanged tail carries over), or + // - the old side's last line is its own trailing line (a kept delete or a + // demoted line followed only by reversed adds) and the file it represents + // ended unterminated. A trailing delete reflects old-EOF (`oldNoNewline`); + // a demoted tail followed by reversed adds reflects the new file's EOF + // (`newNoNewline`) since reversing those trailing adds leaves it last. + let oldEndsNoNewline = false; + if (lastOld !== -1) { + if (lastOld === lastNew) { + oldEndsNoNewline = newNoNewline || (oldNoNewline && oldReachesEof); + } else if (lastOld > lastNew) { + // Kept deletions trail the last shared/new line → old side reaches old-EOF. + oldEndsNoNewline = oldNoNewline && oldReachesEof; + } else { + // Reversed adds trail the old side's last line. Reversing them away leaves + // that line last in the result, so the result ends unterminated iff the + // new file did. + oldEndsNoNewline = newNoNewline; + } + } + + // Emit the body, attaching markers after the appropriate lines. When the old- + // and new-side terminators are the SAME shared line we emit exactly one marker. + // When they differ, each marker sits immediately after its own line; if the + // old-side terminator is a shared line followed by new-only (reversed-add) + // lines, a bare marker there would wrongly terminate the new side too, so we + // split that shared line into a delete/add pair (`-line`⟵marker, `+line`) which + // terminates only the old side while the new side continues. + const body: string[] = []; + for (let i = 0; i < bodyLines.length; i++) { + const ln = bodyLines[i]; + const isOldTerm = oldEndsNoNewline && i === lastOld; + const isNewTerm = newEndsNoNewline && i === lastNew; + + if (isOldTerm && isNewTerm) { + // Same shared trailing line terminates both sides → one marker. + body.push(ln.text, NO_NEWLINE_MARKER); + } else if (isOldTerm && i < lastNew && ln.onOld && ln.onNew) { + // Shared line that is the old-side terminator but new-only lines follow: + // split so the marker terminates the old side without truncating the new. + // The split contributes one old + one new line — the same as the context + // line it replaces — so the running counts are unchanged. + const content = ln.text.slice(1); + body.push('-' + content, NO_NEWLINE_MARKER, '+' + content); + } else if (isOldTerm) { + // Old-only terminator (a kept delete): marker after it is unambiguous. + body.push(ln.text, NO_NEWLINE_MARKER); + } else if (isNewTerm) { + body.push(ln.text, NO_NEWLINE_MARKER); + } else { + body.push(ln.text); + } + } + + const headerLine = rewriteHunkHeader(hunk.headerLine, oldCount, newCount); + return [...header, headerLine, ...body].join('\n') + '\n'; +} diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 50a681a..741b7bc 100644 --- a/src/panels/MainPanel.ts +++ b/src/panels/MainPanel.ts @@ -1136,6 +1136,14 @@ export class MainPanel { await this.refreshAll(); break; } + case 'reverseCommitChanges': { + const { commit, file, hunkIndex, lineIndices } = message.payload; + await this.gitService.reverseCommitChanges(commit, file, { hunkIndex, lineIndices }); + this.post({ type: 'operationComplete', payload: { operation: 'reverseCommitChanges', success: true } }); + vscode.window.showInformationMessage(vscode.l10n.t('reversedFileChange', path.basename(file))); + await this.refreshAll(); + break; + } case 'compareToWorking': { const [workingDiffs, workingFiles] = await Promise.all([ this.gitService.diffCommitToWorking(message.payload.hash), diff --git a/src/utils/message-bus.ts b/src/utils/message-bus.ts index f7eea80..94ce263 100644 --- a/src/utils/message-bus.ts +++ b/src/utils/message-bus.ts @@ -53,6 +53,7 @@ export type WebviewMessage = | { type: 'stashApply'; payload: { index: number; drop?: boolean } } | { type: 'cherryPick'; payload: { commit: string; commits?: string[]; noCommit?: boolean; pushAfter?: boolean } } | { type: 'revert'; payload: { commit: string; noCommit?: boolean; pushAfter?: boolean } } + | { type: 'reverseCommitChanges'; payload: { commit: string; file: string; hunkIndex?: number; lineIndices?: number[] } } | { type: 'addRemote'; payload: { name: string; url: string } } | { type: 'removeRemote'; payload: { name: string } } | { type: 'openDiff'; payload: { file: string; commitHash?: string; ref1?: string; ref2?: string; staged?: boolean } } From 197fbd51a2bf4d9b0d9118babefd3a8f311506be Mon Sep 17 00:00:00 2001 From: sehlceris Date: Tue, 23 Jun 2026 10:03:39 -0700 Subject: [PATCH 2/5] feat(ui): reverse and copy lines in the inline diff Add reverse affordances to the commit-details inline diff: reverse a whole hunk, reverse a gutter-selected set of changed lines, and copy the raw text of selected lines to the clipboard. Lines are selected by dragging in the gutter; hunk-header buttons and the right-click menu expose the actions, scoped to the hunk holding the selection. Copy Lines is gutter-only and treats an empty selection (a single blank line) as valid. Actions are gated to committed, reversible files (not pure adds/deletes, not the working-tree view). Adds i18n strings for the new labels in en/ko/zh. Claude-Session: https://claude.ai/code/session_01B3yKdHuNTMUk6foSE96buB --- README.ko.md | 1 + README.md | 1 + .../components/commit/CommitDetails.svelte | 85 ++- .../src/components/commit/FileDiffView.svelte | 410 +++++++++++-- .../commit/__tests__/FileDiffView.test.ts | 541 ++++++++++++++++++ webview-ui/src/lib/i18n/en.ts | 6 + webview-ui/src/lib/i18n/ko.ts | 6 + webview-ui/src/lib/i18n/zh.ts | 6 + 8 files changed, 1018 insertions(+), 38 deletions(-) create mode 100644 webview-ui/src/components/commit/__tests__/FileDiffView.test.ts diff --git a/README.ko.md b/README.ko.md index 4e5106a..3daa62f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -105,6 +105,7 @@ VS Code를 위한 모던 Git GUI. 커밋 히스토리를 시각화하고, 브랜 | **이미지 Diff** | 이미지 변경사항에 대한 나란히 보기 및 스와이프 비교 | | **Patch 내보내기** | 임의의 커밋을 `.patch` 파일로 저장 | | **에디터에서 열기** | diff 뷰어에서 파일 우클릭으로 파일 열기 또는 VS Code에서 변경사항 보기 | +| **변경 되돌리기** | 커밋의 파일 전체, 단일 hunk, 또는 거터에서 선택한 줄을 작업 트리에 되돌리고, 선택한 줄을 복사 - diff에서 바로 | ### Stash & Worktree diff --git a/README.md b/README.md index 9a55755..654ea4f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ A modern, full-featured Git GUI for VS Code. Visualize your commit history, mana | **Image Diff** | Side-by-side visual preview with swipe comparison for image changes | | **Patch Export** | Save any commit as a `.patch` file | | **Open in Editor** | Right-click any file in the diff viewer to open it or view its changes in VS Code | +| **Reverse Changes** | Undo a whole file, a single hunk, or gutter-selected lines of a commit against your working tree — and copy selected lines — right from the diff | ### Stash & Worktree diff --git a/webview-ui/src/components/commit/CommitDetails.svelte b/webview-ui/src/components/commit/CommitDetails.svelte index 2bf2b85..bdd2adb 100644 --- a/webview-ui/src/components/commit/CommitDetails.svelte +++ b/webview-ui/src/components/commit/CommitDetails.svelte @@ -8,6 +8,7 @@ import { t } from '../../lib/i18n/index.svelte'; import { avatarStore } from '../../lib/stores/avatars.svelte'; import FileDiffView from './FileDiffView.svelte'; + import type { ReverseTarget } from './FileDiffView.svelte'; import ContextMenu from '../common/ContextMenu.svelte'; import CommitHoverCard from '../common/CommitHoverCard.svelte'; import { tooltip } from '../../lib/actions/tooltip'; @@ -63,6 +64,61 @@ return (name || email) ? `${name} <${email}>`.trim() : ''; } let fileContextMenu = $state<{ x: number; y: number; items: any[] } | null>(null); + + // Single entry point for every reverse action. Omitting hunkIndex reverses the + // whole file; omitting lineIndices reverses the whole hunk (see patch-builder). + function postReverse(commit: string, file: string, hunkIndex?: number, lineIndices?: number[]) { + vscode.postMessage({ type: 'reverseCommitChanges', payload: { commit, file, hunkIndex, lineIndices } }); + } + + // Right-click on a diff line (committed view) → offer to copy any selected + // text and to reverse the clicked hunk against the working tree. FileDiffView + // hands us the location and the hunk index; we build the menu here since this + // component already owns the ContextMenu host. + function handleDiffReverse(target: ReverseTarget) { + const items: Array<{ label: string; action: () => void; danger?: boolean; separator?: boolean }> = []; + if (target.selectionText) { + items.push({ + label: t('file.copySelection'), + action: () => { vscode.postMessage({ type: 'copyToClipboard', payload: { text: target.selectionText } }); fileContextMenu = null; }, + }); + } + // copyLinesText is '' for a lone blank line, so gate on presence (!== undefined). + if (target.copyLinesText !== undefined) { + const text = target.copyLinesText; + const count = target.copyLinesCount ?? 0; + items.push({ + label: `${t('file.copyLines')} (${count})`, + action: () => { vscode.postMessage({ type: 'copyToClipboard', payload: { text } }); fileContextMenu = null; }, + }); + } + // When the user has dragged a line-selection, offer to reverse just those + // changed lines (lineIndices) in addition to the whole-hunk action below. + if (target.selectedLineIndices?.length) { + const lineIndices = target.selectedLineIndices; + items.push({ + label: `${t('file.reverseLines')} (${lineIndices.length})`, + danger: true, + action: () => { postReverse(target.commitHash, target.file, target.hunkIndex, lineIndices); fileContextMenu = null; }, + }); + } + items.push({ + label: t('file.reverseHunk'), + danger: true, + action: () => { postReverse(target.commitHash, target.file, target.hunkIndex); fileContextMenu = null; }, + }); + fileContextMenu = { x: target.x, y: target.y, items }; + } + + // The per-hunk header "Reverse Hunk" button reverses immediately (no menu). + function handleHunkReverse(target: Pick) { + postReverse(target.commitHash, target.file, target.hunkIndex); + } + + // The "Reverse Selected Lines" button reverses just the dragged changed lines. + function handleLinesReverse(target: Pick & { lineIndices: number[] }) { + postReverse(target.commitHash, target.file, target.hunkIndex, target.lineIndices); + } let previewCommit = $state(null); let previewPos = $state<{ x: number; y: number } | null>(null); let hoveredHash = $state(null); @@ -95,6 +151,11 @@ return m ? Number(m[1]) : null; }); + // Reversing against the working tree is offered only for a real commit that + // isn't a stash (stashes offer "restore" instead). Gates every reverse callback + // passed to FileDiffView and the tree's "Reverse File" action. + const canReverseInThisView = $derived(!!commit && stashIndex === null); + let filesPanelWidth = $state(240); let isResizing = $state(false); let resizeStartX = 0; @@ -797,6 +858,17 @@ }, }); + // Reverse this file's change against the working tree. + if (commit && canReverseInThisView) { + const hash = commit.hash; + items.push({ separator: true, label: '', action: () => {} }); + items.push({ + label: t('file.reverseFile'), + danger: true, + action: () => { postReverse(hash, node.path); fileContextMenu = null; }, + }); + } + // Create Patch (committed view only) if (commit) { const multi = selectedPatchFiles.has(node.path) && selectedPatchFiles.size >= 2; @@ -950,11 +1022,22 @@ commitHash={sec.commit} stacked heading={sec.subject ? `${sec.shortHash} ${sec.subject}` : sec.shortHash} + onReverse={canReverseInThisView ? handleDiffReverse : undefined} + onReverseHunk={canReverseInThisView ? handleHunkReverse : undefined} + onReverseLines={canReverseInThisView ? handleLinesReverse : undefined} + fileStatus={files.find(f => f.path === sec.file)?.status} /> {/each} {:else if selectedDiff} - + f.path === selectedDiff.file)?.status} + /> {/if} diff --git a/webview-ui/src/components/commit/FileDiffView.svelte b/webview-ui/src/components/commit/FileDiffView.svelte index b1651b0..b5ee2a7 100644 --- a/webview-ui/src/components/commit/FileDiffView.svelte +++ b/webview-ui/src/components/commit/FileDiffView.svelte @@ -5,15 +5,188 @@ import { detectLanguage, highlightLineSync, getHighlighter, ensureLanguage, activeShikiTheme, escapeHtml } from '../../lib/utils/highlighter'; import ImageDiff from '../common/ImageDiff.svelte'; + // Right-click target on a diff line. The parent owns the context menu (it + // already hosts one for the file tree), so we just hand it the location plus + // enough to address the change: the hunk index lines up with the diff the + // backend re-parses (see patch-builder / git-parser), where omitting line + // indices reverses the whole hunk. + export interface ReverseTarget { + commitHash: string; + file: string; + hunkIndex: number; + // The changed (+/-) line indices the user selected via the gutter drag, + // addressing a subset of the hunk to reverse. Omitted reverses the whole hunk. + selectedLineIndices?: number[]; + selectionText: string; + // Raw newline-joined text of every gutter-selected line, set only when the + // right-click originates in the gutter and a line selection is active. The + // parent offers a "Copy Lines" menu item when present. An empty string is a + // valid value (a lone blank line was selected), so the parent gates on + // `!== undefined`, not truthiness. `copyLinesCount` carries the matching + // line count so the parent need not re-split the text. + copyLinesText?: string; + copyLinesCount?: number; + x: number; + y: number; + } + interface Props { diff: DiffData; commitHash?: string; stacked?: boolean; // Optional commit label shown in the toolbar (used by stacked per-commit sections). heading?: string; + // Git status letter for this file ('A'/'M'/'D'/'R'...). Whole add/delete + // files only offer "Reverse File" (from the tree), not hunk reverse. + fileStatus?: string; + // When provided (committed view only), right-clicking a diff line offers to + // reverse that whole hunk against the working tree. + onReverse?: (target: ReverseTarget) => void; + // When provided (committed view only), the per-hunk header "Reverse Hunk" + // button reverses that hunk immediately (no context menu). + onReverseHunk?: (target: { commitHash: string; file: string; hunkIndex: number }) => void; + // When provided (committed view only), the "Reverse Selected Lines" button + // reverses just the dragged changed lines of a hunk immediately. + onReverseLines?: (target: { commitHash: string; file: string; hunkIndex: number; lineIndices: number[] }) => void; + } + + let { diff, commitHash, stacked = false, heading, fileStatus, onReverse, onReverseHunk, onReverseLines }: Props = $props(); + + // Whether this diff supports reversing (committed view, modified file). Drives + // both the right-click menu and the per-hunk header reverse affordance. + const canReverse = $derived(!!onReverse && !!commitHash && fileStatus !== 'A' && fileStatus !== 'D'); + + // A truncated diff renders only the first N lines of its final hunk (see + // renderHunks). Reversing then would silently undo the unseen tail too, so we + // only allow reverse on hunks rendered in full. Untruncated diffs are always + // complete (renderHunks === diff.hunks). + function isHunkComplete(hunkIndex: number): boolean { + const full = diff.hunks[hunkIndex]; + const shown = renderHunks[hunkIndex]; + return !!full && !!shown && shown.lines.length === full.lines.length; + } + + // Drag-to-select whole lines in the inline gutter (single hunk at a time). + // `indices` holds every line index the drag has covered; the right-click menu + // then narrows it to just the changed (+/-) lines via selectedChangedIndices. + let lineSel = $state<{ hunkIdx: number; anchor: number; indices: Set } | null>(null); + // Plain (non-reactive) flag tracking whether a gutter drag is in progress. + let dragging = false; + + // SBS: the hunk currently under the cursor (in either pane), highlighted in + // both panes so the reverse extent is obvious before right-clicking. + let hoveredHunkIdx = $state(null); + + // All indices from min(a,b)..max(a,b) inclusive. + function rangeSet(a: number, b: number): Set { + const lo = Math.min(a, b); + const hi = Math.max(a, b); + const out = new Set(); + for (let i = lo; i <= hi; i++) out.add(i); + return out; + } + + // The selected line indices that are actually reversible (+/- lines), sorted. + // Context lines in the dragged range are dropped. Pure projection of lineSel + + // diff.hunks, so it's derived rather than recomputed on every template access. + const selectedChangedIndices = $derived.by(() => { + if (!lineSel) return []; + const hunk = diff.hunks[lineSel.hunkIdx]; + if (!hunk) return []; + return [...lineSel.indices] + .filter(i => hunk.lines[i] && hunk.lines[i].type !== 'context') + .sort((a, b) => a - b); + }); + + // The full text of every currently selected line (context included), in line + // order, joined by newlines — for the "Copy Lines" gutter action. + function selectedLinesText(): string { + if (!lineSel) return ''; + const hunk = diff.hunks[lineSel.hunkIdx]; + if (!hunk) return ''; + return [...lineSel.indices] + .sort((a, b) => a - b) + .map(i => hunk.lines[i]?.content ?? '') + .join('\n'); + } + + function startLineSelect(e: MouseEvent, hunkIdx: number, lineIndex: number) { + if (e.button !== 0) return; // right/middle-click must not reset an active selection + if (!canReverse || !isHunkComplete(hunkIdx)) return; + e.preventDefault(); // suppress native text-selection beginning in the gutter + if (e.shiftKey && lineSel && lineSel.hunkIdx === hunkIdx) { + lineSel = { ...lineSel, indices: rangeSet(lineSel.anchor, lineIndex) }; + return; + } + // Plain click on a line already in the selection → deselect. + if (lineSel && lineSel.hunkIdx === hunkIdx && lineSel.indices.has(lineIndex)) { + lineSel = null; + return; + } + lineSel = { hunkIdx, anchor: lineIndex, indices: rangeSet(lineIndex, lineIndex) }; + dragging = true; + window.addEventListener('mouseup', () => { dragging = false; }, { once: true }); } - let { diff, commitHash, stacked = false, heading }: Props = $props(); + function extendLineSelect(_e: MouseEvent, hunkIdx: number, lineIndex: number) { + if (dragging && lineSel && lineSel.hunkIdx === hunkIdx) { + lineSel = { ...lineSel, indices: rangeSet(lineSel.anchor, lineIndex) }; + } + } + + function handleLineContextMenu(e: MouseEvent, hunkIndex: number, lineIndex = -1) { + if (!onReverse || !commitHash) return; // not reversible → native menu + if (fileStatus === 'A' || fileStatus === 'D') return; // whole add/delete → use tree's Reverse File + const changed = selectedChangedIndices; + // Only offer "Reverse Selected Lines" when the right-click lands on a line + // that is part of the active selection; otherwise fall back to whole-hunk. + const inSelection = !!lineSel && lineSel.hunkIdx === hunkIndex && lineSel.indices.has(lineIndex) && changed.length > 0; + const sel = inSelection ? { hunkIdx: lineSel!.hunkIdx, indices: changed } : null; + const targetHunk = sel ? sel.hunkIdx : hunkIndex; + if (!isHunkComplete(targetHunk)) return; // truncated hunk → don't reverse unseen lines + e.preventDefault(); + const selectionText = window.getSelection()?.toString() ?? ''; + // Right-clicking the gutter while lines are selected → offer "Copy Lines". + // Restricted to the SAME hunk that holds the selection so the menu never + // mixes one hunk's "Copy Lines" with another hunk's "Reverse Hunk". + const inGutter = !!(e.target as HTMLElement).closest('.line-gutter'); + const hasCopyLines = inGutter && !!lineSel && lineSel.hunkIdx === hunkIndex && lineSel.indices.size > 0; + onReverse({ + commitHash, + file: diff.file, + hunkIndex: targetHunk, + selectedLineIndices: sel ? sel.indices : undefined, + selectionText, + copyLinesText: hasCopyLines ? selectedLinesText() : undefined, + copyLinesCount: hasCopyLines ? lineSel!.indices.size : undefined, + x: e.clientX, + y: e.clientY, + }); + } + + function reverseHunk(hunkIndex: number) { + if (!onReverseHunk || !commitHash || !isHunkComplete(hunkIndex)) return; + onReverseHunk({ commitHash, file: diff.file, hunkIndex }); + } + + function reverseSelectedLines() { + if (!onReverseLines || !commitHash || !lineSel) return; + const indices = selectedChangedIndices; + if (!indices.length || !isHunkComplete(lineSel.hunkIdx)) return; + onReverseLines({ commitHash, file: diff.file, hunkIndex: lineSel.hunkIdx, lineIndices: indices }); + } + + // Friendly hunk header label, e.g. "Hunk 1: Lines 1-5". Uses the new-side range + // (what the file looks like after the change); falls back to the old side for + // pure-deletion hunks where the new side is empty. + function hunkLabel(hunk: DiffData['hunks'][number], hunkIdx: number): string { + const useNew = hunk.newLines > 0; + const start = useNew ? hunk.newStart : hunk.oldStart; + const count = useNew ? hunk.newLines : hunk.oldLines; + const end = start + Math.max(count, 1) - 1; + const range = end > start ? `${start}-${end}` : `${start}`; + return `Hunk ${hunkIdx + 1}: ${t('file.lines')} ${range}`; + } function getFileName(path: string): string { return path.split('/').pop() ?? path; @@ -51,6 +224,7 @@ $effect(() => { diff; showFullDiff = false; + lineSel = null; }); let diffMode = $state<'inline' | 'side-by-side'>('inline'); @@ -95,6 +269,13 @@ return () => observer.disconnect(); }); + // Escape clears any active gutter line-selection. + onMount(() => { + const onKeydown = (e: KeyboardEvent) => { if (e.key === 'Escape') lineSel = null; }; + window.addEventListener('keydown', onKeydown); + return () => window.removeEventListener('keydown', onKeydown); + }); + $effect(() => { if (!diff || diff.isBinary) return; const lang = detectLanguage(diff.file); @@ -168,11 +349,11 @@
@@ -192,36 +373,78 @@ {:else if diffMode === 'inline'}
{#each renderHunks as hunk, hunkIdx} - {#if hunkIdx > 0}{/if} - {#each hunk.lines as line, lineIndex} -
- {line.oldLineNumber ?? ''} - {line.newLineNumber ?? ''} - {line.type === 'add' ? '+' : line.type === 'delete' ? '-' : ' '} - {@html getHighlighted(hunk.oldStart, lineIndex, line.content)} +
0}> +
+
+ {hunkLabel(hunk, hunkIdx)} + {#if canReverse && isHunkComplete(hunkIdx)} + {#if lineSel?.hunkIdx === hunkIdx && selectedChangedIndices.length > 0} + + {/if} + + {/if} +
- {/each} + {#each hunk.lines as line, lineIndex} + +
handleLineContextMenu(e, hunkIdx, lineIndex)}> + + startLineSelect(e, hunkIdx, lineIndex)} + onmouseenter={(e) => extendLineSelect(e, hunkIdx, lineIndex)} + > + {line.oldLineNumber ?? ''} + {line.newLineNumber ?? ''} + {line.type === 'add' ? '+' : line.type === 'delete' ? '-' : ' '} + + + { if (e.button === 0) lineSel = null; }}>{@html getHighlighted(hunk.oldStart, lineIndex, line.content)} +
+ {/each} +
{/each}
{:else} +
{#each renderHunks as hunk, hunkIdx} {#if hunkIdx > 0}{/if} - {#each hunk.lines as line, lineIndex} - {#if line.type === 'context' || line.type === 'delete'} -
- {line.oldLineNumber ?? ''} - {@html getHighlighted(hunk.oldStart, lineIndex, line.content)} -
- {:else} -
- - -
- {/if} - {/each} + +
{ hoveredHunkIdx = hunkIdx; }} + onmouseleave={() => { if (hoveredHunkIdx === hunkIdx) hoveredHunkIdx = null; }} + oncontextmenu={(e) => handleLineContextMenu(e, hunkIdx)} + > + {#each hunk.lines as line, lineIndex} + {#if line.type === 'context' || line.type === 'delete'} +
+ {line.oldLineNumber ?? ''} + {@html getHighlighted(hunk.oldStart, lineIndex, line.content)} +
+ {:else} +
+ + +
+ {/if} + {/each} +
{/each}
@@ -229,19 +452,28 @@
{#each renderHunks as hunk, hunkIdx} {#if hunkIdx > 0}{/if} - {#each hunk.lines as line, lineIndex} - {#if line.type === 'context' || line.type === 'add'} -
- {line.newLineNumber ?? ''} - {@html getHighlighted(hunk.oldStart, lineIndex, line.content)} -
- {:else} -
- - -
- {/if} - {/each} + +
{ hoveredHunkIdx = hunkIdx; }} + onmouseleave={() => { if (hoveredHunkIdx === hunkIdx) hoveredHunkIdx = null; }} + oncontextmenu={(e) => handleLineContextMenu(e, hunkIdx)} + > + {#each hunk.lines as line, lineIndex} + {#if line.type === 'context' || line.type === 'add'} +
+ {line.newLineNumber ?? ''} + {@html getHighlighted(hunk.oldStart, lineIndex, line.content)} +
+ {:else} +
+ + +
+ {/if} + {/each} +
{/each}
@@ -338,6 +570,95 @@ width: max-content; } + /* Each hunk is a grouping container so it can carry a header bar and show a + hover highlight outlining exactly what "Reverse Hunk" will affect. */ + .diff-hunk { + position: relative; + border-top: 1px solid var(--border-color); + } + + .diff-hunk-header { + padding: 2px 8px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.85em; + border-bottom: 1px solid var(--border-color); + } + + /* inline-flex (content width, not the hunk's full max-content width) so the + label + buttons cluster at the left and the buttons sit right after the + label instead of being pushed to the far-right edge. sticky left:0 keeps + them pinned to the viewport's left so they stay reachable when the diff is + scrolled horizontally. */ + .hunk-header-inner { + display: inline-flex; + align-items: center; + gap: 8px; + position: sticky; + left: 0; + } + + /* No flex-grow: the label takes only its own width so it can't push the + buttons right. It may still shrink/ellipsize if the panel is very narrow. */ + .diff-hunk-range { + font-family: var(--vscode-editor-font-family, monospace); + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .hunk-action-btn { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + white-space: nowrap; + padding: 1px 6px; + background: transparent; + border: none; + cursor: pointer; + font-size: 0.95em; + color: var(--vscode-errorForeground, #f44336); + transition: opacity 0.1s, background 0.1s; + } + + /* The Reverse HUNK button is a hover/focus affordance; the Reverse LINES button + is explicit (only renders when a selection exists), so it's always visible. */ + .hunk-hunk-btn { + opacity: 0; + transition: opacity 0.1s; + } + + .hunk-lines-btn { + opacity: 1; + } + + .diff-hunk.reversible:hover .hunk-hunk-btn, + .diff-hunk.has-selection .hunk-hunk-btn, + .hunk-action-btn:focus { + opacity: 1; + } + + .hunk-action-btn:hover { + background: color-mix(in srgb, var(--vscode-inputValidation-errorBackground, #f44336) 25%, transparent); + } + + .hunk-action-btn i { + font-size: 1em; + } + + /* Outline the whole hunk on hover so the reverse extent is obvious. Inline only + when reversible (non-committed/added/deleted diffs get no misleading hint); + SBS outlines the hovered hunk in both panes (.sbs-hunk.hunk-hover below). */ + .diff-hunk.reversible:hover, + .sbs-hunk.hunk-hover { + outline: 1px solid var(--vscode-focusBorder, rgba(120, 120, 255, 0.4)); + outline-offset: -1px; + } + + /* SBS mode still uses a plain dashed separator between hunks (no header bar). */ .hunk-separator { height: 0; border-top: 1px dashed var(--border-color); @@ -356,6 +677,21 @@ .diff-delete { background: var(--vscode-diffEditor-removedLineBackground, rgba(255, 0, 0, 0.15)); } .diff-empty-line { background: rgba(128, 128, 128, 0.05); } + /* Inline gutter (line numbers + prefix) is the drag handle for line-selection. + Suppress native text selection here so dragging selects whole lines instead. */ + .line-gutter { + display: flex; + flex-shrink: 0; + user-select: none; + cursor: pointer; + } + + /* Selected lines get a clear accent that reads over the add/delete tints. */ + .diff-line.line-selected { + background: var(--vscode-editor-selectionBackground, rgba(120, 150, 255, 0.25)); + box-shadow: inset 3px 0 0 var(--vscode-focusBorder, #4a9eff); + } + .line-num { width: 45px; flex-shrink: 0; diff --git a/webview-ui/src/components/commit/__tests__/FileDiffView.test.ts b/webview-ui/src/components/commit/__tests__/FileDiffView.test.ts new file mode 100644 index 0000000..ac7232c --- /dev/null +++ b/webview-ui/src/components/commit/__tests__/FileDiffView.test.ts @@ -0,0 +1,541 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, cleanup, fireEvent } from '@testing-library/svelte'; +import FileDiffView from '../FileDiffView.svelte'; +import { i18n } from '../../../lib/i18n/index.svelte'; +import type { DiffData } from '../../../lib/types'; + +// One hunk holding two separate change blocks split by a context line. The whole +// hunk is the reverse unit now, so the clicked line only identifies its hunk. +// 0 ctx a +// 1 del b +// 2 add b2 +// 3 ctx c +// 4 add d1 +// 5 add d2 +// 6 ctx e +function sampleDiff(): DiffData { + return { + file: 'src/foo.ts', + isBinary: false, + isImage: false, + hunks: [ + { + header: '@@ -1,4 +1,5 @@', + oldStart: 1, + oldLines: 4, + newStart: 1, + newLines: 5, + lines: [ + { type: 'context', content: 'a', oldLineNumber: 1, newLineNumber: 1 }, + { type: 'delete', content: 'b', oldLineNumber: 2 }, + { type: 'add', content: 'b2', newLineNumber: 2 }, + { type: 'context', content: 'c', oldLineNumber: 3, newLineNumber: 3 }, + { type: 'add', content: 'd1', newLineNumber: 4 }, + { type: 'add', content: 'd2', newLineNumber: 5 }, + { type: 'context', content: 'e', oldLineNumber: 4, newLineNumber: 6 }, + ], + }, + ], + }; +} + +// A diff whose single hunk exceeds MAX_RENDER_LINES (3000), so it renders +// truncated. Reversing must be disabled for the partially-shown hunk to avoid +// silently undoing the unseen tail. +function hugeDiff(): DiffData { + const lines = [ + { type: 'delete' as const, content: 'old', oldLineNumber: 1 }, + { type: 'add' as const, content: 'new', newLineNumber: 1 }, + ]; + for (let i = 0; i < 3100; i++) { + lines.push({ type: 'context' as const, content: `ctx${i}`, oldLineNumber: i + 2, newLineNumber: i + 2 } as never); + } + return { + file: 'src/big.ts', + isBinary: false, + isImage: false, + hunks: [{ header: '@@ -1,3101 +1,3101 @@', oldStart: 1, oldLines: 3101, newStart: 1, newLines: 3101, lines }], + }; +} + +// Dispatch a real contextmenu MouseEvent we can inspect afterwards (so we can +// assert preventDefault was / wasn't called). +function rightClick(el: Element): MouseEvent { + const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 20 }); + el.dispatchEvent(ev); + return ev; +} + +beforeEach(() => i18n.setLocale('en')); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('FileDiffView reverse context menu', () => { + it('reports the hunk for a changed line', () => { + const onReverse = vi.fn(); + const onReverseHunk = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse, onReverseHunk }); + + const lines = container.querySelectorAll('.diff-content .diff-line'); + expect(lines.length).toBe(7); + + // Right-click the added line of the modify pair (index 2) → hunk 0. + const ev = rightClick(lines[2]); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(true); + const arg = onReverse.mock.calls[0][0]; + expect(arg).toMatchObject({ commitHash: 'deadbeef', file: 'src/foo.ts', hunkIndex: 0 }); + expect(arg).not.toHaveProperty('blockLineIndices'); + expect(arg).not.toHaveProperty('isSingleLine'); + }); + + it('reports the same hunk regardless of which changed line is clicked', () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const lines = container.querySelectorAll('.diff-content .diff-line'); + + rightClick(lines[5]); // second add of the second block, still hunk 0 + expect(onReverse.mock.calls[0][0]).toMatchObject({ hunkIndex: 0 }); + }); + + it('offers Reverse Hunk on a context line', () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const lines = container.querySelectorAll('.diff-content .diff-line'); + + const ev = rightClick(lines[0]); // context line → still reverses the whole hunk + expect(onReverse).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(true); + const arg = onReverse.mock.calls[0][0]; + expect(arg).toMatchObject({ hunkIndex: 0 }); + expect(arg.selectedLineIndices).toBeUndefined(); + }); + + it('does not fire and hides the hunk reverse button for a wholly-added file', () => { + const onReverse = vi.fn(); + const onReverseHunk = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'A', onReverse, onReverseHunk }); + const lines = container.querySelectorAll('.diff-content .diff-line'); + + const ev = rightClick(lines[2]); + expect(onReverse).not.toHaveBeenCalled(); + expect(ev.defaultPrevented).toBe(false); + // canReverse is false → no per-hunk reverse button. + expect(container.querySelector('.hunk-hunk-btn')).toBeNull(); + }); + + it('does nothing without a commit hash (compare/uncommitted view)', () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), fileStatus: 'M', onReverse }); + const lines = container.querySelectorAll('.diff-content .diff-line'); + + rightClick(lines[2]); + expect(onReverse).not.toHaveBeenCalled(); + }); + + it('forwards the current text selection', () => { + vi.spyOn(window, 'getSelection').mockReturnValue({ toString: () => 'picked text' } as unknown as Selection); + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const lines = container.querySelectorAll('.diff-content .diff-line'); + + rightClick(lines[2]); + expect(onReverse.mock.calls[0][0]).toMatchObject({ selectionText: 'picked text' }); + }); + + it('renders a per-hunk reverse button that reverses the hunk on click', async () => { + const onReverse = vi.fn(); + const onReverseHunk = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse, onReverseHunk }); + + const btn = container.querySelector('.hunk-hunk-btn'); + expect(btn).not.toBeNull(); + + await fireEvent.click(btn!); + expect(onReverseHunk).toHaveBeenCalledTimes(1); + expect(onReverseHunk.mock.calls[0][0]).toEqual({ commitHash: 'deadbeef', file: 'src/foo.ts', hunkIndex: 0 }); + }); + + it('disables reverse on a truncated hunk (avoids reversing unseen lines)', () => { + const onReverse = vi.fn(); + const onReverseHunk = vi.fn(); + const { container } = render(FileDiffView, { diff: hugeDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse, onReverseHunk }); + + // The hunk is rendered partially, so no reverse button and right-click is a no-op. + expect(container.querySelector('.hunk-hunk-btn')).toBeNull(); + const firstChanged = container.querySelector('.diff-content .diff-line.diff-delete')!; + const ev = rightClick(firstChanged); + expect(onReverse).not.toHaveBeenCalled(); + expect(ev.defaultPrevented).toBe(false); + }); +}); + +describe('FileDiffView gutter line-selection', () => { + // The inline gutters, one per rendered line (indices match sampleDiff()). + function gutters(container: HTMLElement): NodeListOf { + return container.querySelectorAll('.diff-content .diff-line .line-gutter'); + } + + it('drag-selects two changed lines and reverses just those', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + // Drag from the first add (4) to the second add (5). + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + rightClick(container.querySelectorAll('.diff-content .diff-line')[5]); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(onReverse.mock.calls[0][0]).toMatchObject({ hunkIndex: 0, selectedLineIndices: [4, 5] }); + }); + + it('excludes context lines from a range that spans them', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + // Select indices 2..4 — index 3 is a context line and must be dropped. + await fireEvent.mouseDown(g[2]); + await fireEvent.mouseEnter(g[3]); + await fireEvent.mouseEnter(g[4]); + await fireEvent.mouseUp(window); + + rightClick(container.querySelectorAll('.diff-content .diff-line')[4]); + expect(onReverse.mock.calls[0][0].selectedLineIndices).toEqual([2, 4]); + }); + + it('shift-click extends the selection', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + await fireEvent.mouseDown(g[1]); + await fireEvent.mouseUp(window); + await fireEvent.mouseDown(g[4], { shiftKey: true }); + await fireEvent.mouseUp(window); + + rightClick(container.querySelectorAll('.diff-content .diff-line')[4]); + // 1..4 → changed lines are 1 (del), 2 (add), 4 (add); 3 is context. + expect(onReverse.mock.calls[0][0].selectedLineIndices).toEqual([1, 2, 4]); + }); + + it('Escape clears the selection (context right-click then reverses the whole hunk)', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + await fireEvent.keyDown(window, { key: 'Escape' }); + + // With the selection cleared, a context-line right-click reverses the whole + // hunk (no selected lines). + rightClick(container.querySelectorAll('.diff-content .diff-line')[6]); // context line + expect(onReverse).toHaveBeenCalledTimes(1); + expect(onReverse.mock.calls[0][0]).toMatchObject({ hunkIndex: 0 }); + expect(onReverse.mock.calls[0][0].selectedLineIndices).toBeUndefined(); + }); + + it('keeps an active selection when right-clicking the gutter (right-click does not reset it)', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + // Drag-select the two adds (4, 5). + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + // A right-click mousedown in the gutter must NOT collapse the selection. + await fireEvent.mouseDown(g[1], { button: 2 }); + + // Right-click a line INSIDE the selection so the selected-lines target is offered. + rightClick(container.querySelectorAll('.diff-content .diff-line')[4]); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(onReverse.mock.calls[0][0]).toMatchObject({ hunkIndex: 0, selectedLineIndices: [4, 5] }); + }); + + it('does not offer Reverse Selected Lines when right-clicking outside the selected region', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + // Drag-select the two adds (4, 5). + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + // Right-click a changed line NOT in the selection → falls back to whole-hunk. + rightClick(container.querySelectorAll('.diff-content .diff-line')[2]); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(onReverse.mock.calls[0][0]).toMatchObject({ hunkIndex: 0 }); + expect(onReverse.mock.calls[0][0].selectedLineIndices).toBeUndefined(); + }); + + it('left-clicking an already-selected gutter line deselects', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + // Plain left-click on a line already in the selection clears it. + await fireEvent.mouseDown(g[4]); + + const lines = container.querySelectorAll('.diff-content .diff-line'); + expect([...lines].some(l => l.classList.contains('line-selected'))).toBe(false); + + // A subsequent right-click yields no selected lines. + rightClick(lines[4]); + expect(onReverse.mock.calls[0][0].selectedLineIndices).toBeUndefined(); + }); + + it('left-clicking the code region deselects', async () => { + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse: vi.fn() }); + const g = gutters(container); + + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + const contents = container.querySelectorAll('.diff-content .diff-line .line-content'); + await fireEvent.mouseDown(contents[4], { button: 0 }); + + const lines = container.querySelectorAll('.diff-content .diff-line'); + expect([...lines].some(l => l.classList.contains('line-selected'))).toBe(false); + }); + + it('hunk header shows the Hunk N: Lines A-B label', () => { + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse: vi.fn() }); + const range = container.querySelector('.diff-hunk-range')!; + expect(range.textContent).toContain('Hunk 1'); + expect(range.textContent).toContain('Lines 1-5'); + }); + + it('renders a Reverse Lines button that reverses the selection on click', async () => { + const onReverse = vi.fn(); + const onReverseLines = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse, onReverseLines }); + const g = gutters(container); + + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + const btn = container.querySelector('.hunk-lines-btn'); + expect(btn).not.toBeNull(); + await fireEvent.click(btn!); + expect(onReverseLines).toHaveBeenCalledTimes(1); + expect(onReverseLines.mock.calls[0][0]).toMatchObject({ hunkIndex: 0, lineIndices: [4, 5] }); + }); + + it('applies the line-selected class to selected lines', async () => { + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse: vi.fn() }); + const g = gutters(container); + + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + const lines = container.querySelectorAll('.diff-content .diff-line'); + expect(lines[4].classList.contains('line-selected')).toBe(true); + expect(lines[5].classList.contains('line-selected')).toBe(true); + expect(lines[0].classList.contains('line-selected')).toBe(false); + }); + + it('clears the selection when switching to side-by-side (no stale invisible selection)', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + // Toggle to side-by-side (second button in the mode toggle). + const sbsBtn = container.querySelectorAll('.diff-mode-toggle button')[1]; + await fireEvent.click(sbsBtn); + + // Right-clicking a changed line in SBS reverses the hunk, not a stale selection. + const addLine = container.querySelector('.sbs-right .diff-line.diff-add')!; + rightClick(addLine); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(onReverse.mock.calls[0][0].selectedLineIndices).toBeUndefined(); + }); + + it('offers Reverse Hunk for a right-click anywhere in an SBS hunk (including empty rows)', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + + const sbsBtn = container.querySelectorAll('.diff-mode-toggle button')[1]; + await fireEvent.click(sbsBtn); + + // Right-click an empty placeholder row inside an SBS hunk → bubbles to the + // wrapper and offers Reverse Hunk. + const emptyRow = container.querySelector('.diff-sbs .diff-empty-line')!; + const ev = rightClick(emptyRow); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(true); + expect(onReverse.mock.calls[0][0]).toMatchObject({ hunkIndex: 0 }); + expect(onReverse.mock.calls[0][0].selectedLineIndices).toBeUndefined(); + }); +}); + +// A blank line whose content is the empty string — exercises the edge where +// selectedLinesText() returns '' for a single selected blank line. +function blankLineDiff(): DiffData { + return { + file: 'src/blank.ts', + isBinary: false, + isImage: false, + hunks: [ + { + header: '@@ -1,1 +1,2 @@', + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 2, + lines: [ + { type: 'add', content: '', newLineNumber: 1 }, // blank added line + { type: 'context', content: 'x', oldLineNumber: 1, newLineNumber: 2 }, + ], + }, + ], + }; +} + +// Two complete hunks, so a selection in one hunk can be tested against a +// right-click in the other. +function twoHunkDiff(): DiffData { + return { + file: 'src/two.ts', + isBinary: false, + isImage: false, + hunks: [ + { + header: '@@ -1,1 +1,1 @@', + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { type: 'delete', content: 'a', oldLineNumber: 1 }, + { type: 'add', content: 'a2', newLineNumber: 1 }, + ], + }, + { + header: '@@ -10,1 +10,1 @@', + oldStart: 10, + oldLines: 1, + newStart: 10, + newLines: 1, + lines: [ + { type: 'delete', content: 'b', oldLineNumber: 10 }, + { type: 'add', content: 'b2', newLineNumber: 10 }, + ], + }, + ], + }; +} + +describe('FileDiffView copy lines (gutter)', () => { + function gutters(container: HTMLElement): NodeListOf { + return container.querySelectorAll('.diff-content .diff-line .line-gutter'); + } + + it('sets copyLinesText to the joined content of the selection on a gutter right-click', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + // Select the two adds (4 = 'd1', 5 = 'd2'). + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + // Right-click in the GUTTER of a selected line. + rightClick(g[4]); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(onReverse.mock.calls[0][0].copyLinesText).toBe('d1\nd2'); + }); + + it('includes context lines in copyLinesText (copies everything selected)', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + // Select 2..4: 'b2' (add), 'c' (context), 'd1' (add) — context is kept here. + await fireEvent.mouseDown(g[2]); + await fireEvent.mouseEnter(g[3]); + await fireEvent.mouseEnter(g[4]); + await fireEvent.mouseUp(window); + + rightClick(g[3]); + expect(onReverse.mock.calls[0][0].copyLinesText).toBe('b2\nc\nd1'); + }); + + it('does NOT offer copy lines when right-clicking the code region', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + await fireEvent.mouseDown(g[4]); + await fireEvent.mouseEnter(g[5]); + await fireEvent.mouseUp(window); + + // Right-click in the CODE region (not the gutter) → no copy-lines text. + const contents = container.querySelectorAll('.diff-content .diff-line .line-content'); + rightClick(contents[4]); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(onReverse.mock.calls[0][0].copyLinesText).toBeUndefined(); + }); + + it('does NOT offer copy lines on a gutter right-click when nothing is selected', () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + rightClick(g[2]); + expect(onReverse.mock.calls[0][0].copyLinesText).toBeUndefined(); + }); + + it('still offers copy lines for a single blank selected line (empty string, not undefined)', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: blankLineDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + + // Select only the blank added line (index 0). + await fireEvent.mouseDown(g[0]); + await fireEvent.mouseUp(window); + + rightClick(g[0]); + expect(onReverse).toHaveBeenCalledTimes(1); + // Empty string must be passed (defined) so the parent's `!== undefined` gate + // still shows the menu item for a lone blank line. + expect(onReverse.mock.calls[0][0].copyLinesText).toBe(''); + }); + + it('does NOT offer copy lines when right-clicking a different hunk than the selection', async () => { + const onReverse = vi.fn(); + const { container } = render(FileDiffView, { diff: twoHunkDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const g = gutters(container); + expect(g.length).toBe(4); // hunk0: 0,1 hunk1: 2,3 + + // Select a line in hunk 0. + await fireEvent.mouseDown(g[1]); + await fireEvent.mouseUp(window); + + // Right-click the gutter of a line in hunk 1 → copy-lines gated out. + rightClick(g[2]); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(onReverse.mock.calls[0][0]).toMatchObject({ hunkIndex: 1 }); + expect(onReverse.mock.calls[0][0].copyLinesText).toBeUndefined(); + }); +}); diff --git a/webview-ui/src/lib/i18n/en.ts b/webview-ui/src/lib/i18n/en.ts index f972f00..c515fed 100644 --- a/webview-ui/src/lib/i18n/en.ts +++ b/webview-ui/src/lib/i18n/en.ts @@ -600,6 +600,12 @@ export const en: Record = { 'file.restoreStashFile': 'Restore file from stash', 'file.restoreStashFromSelected': 'Restore {count} selected files from stash', 'file.restoreStashFromFolder': 'Restore folder from stash', + 'file.reverseFile': 'Reverse File', + 'file.reverseHunk': 'Reverse Hunk', + 'file.reverseLines': 'Reverse Selected Lines', + 'file.lines': 'Lines', + 'file.copySelection': 'Copy', + 'file.copyLines': 'Copy Lines', // LFS 'lfs.locked': 'Locked by {owner}', diff --git a/webview-ui/src/lib/i18n/ko.ts b/webview-ui/src/lib/i18n/ko.ts index 3b44aaf..87d117d 100644 --- a/webview-ui/src/lib/i18n/ko.ts +++ b/webview-ui/src/lib/i18n/ko.ts @@ -601,6 +601,12 @@ export const ko: Record = { 'file.restoreStashFile': 'Stash에서 파일 복원', 'file.restoreStashFromSelected': '선택한 {count}개 파일을 Stash에서 복원', 'file.restoreStashFromFolder': '이 폴더를 Stash에서 복원', + 'file.reverseFile': '파일 변경 되돌리기', + 'file.reverseHunk': 'Hunk 변경 되돌리기', + 'file.reverseLines': '선택한 줄 되돌리기', + 'file.lines': '줄', + 'file.copySelection': '복사', + 'file.copyLines': '줄 복사', 'lfs.lock': 'LFS 잠금', 'lfs.unlock': 'LFS 잠금 해제', 'lfs.unlockForce': 'LFS 강제 잠금 해제', diff --git a/webview-ui/src/lib/i18n/zh.ts b/webview-ui/src/lib/i18n/zh.ts index 823eed0..7b6e6d6 100644 --- a/webview-ui/src/lib/i18n/zh.ts +++ b/webview-ui/src/lib/i18n/zh.ts @@ -601,6 +601,12 @@ export const zh: Record = { 'file.restoreStashFile': '从储藏恢复文件', 'file.restoreStashFromSelected': '从储藏恢复选中的 {count} 个文件', 'file.restoreStashFromFolder': '从储藏恢复文件夹', + 'file.reverseFile': '还原文件更改', + 'file.reverseHunk': '还原 Hunk 更改', + 'file.reverseLines': '还原选定行', + 'file.lines': '行', + 'file.copySelection': '复制', + 'file.copyLines': '复制行', 'lfs.lock': 'LFS 锁定', 'lfs.unlock': 'LFS 解锁', 'lfs.unlockForce': 'LFS 强制解锁', From 1479a122fab5e73e71fba65fab5c301a8f1a97fa Mon Sep 17 00:00:00 2001 From: sehlceris Date: Tue, 23 Jun 2026 19:14:26 -0700 Subject: [PATCH 3/5] feat(git): reverse hunks and lines of added/deleted files Whole-file additions and deletions now support the same reverse hunk / reverse selected lines affordances as modified files, instead of only the tree's "Reverse File" action. The webview no longer gates the reverse menu/buttons on file status. On the backend, the patch builder rewrites the whole-file header for partial selections: a `new file mode` / `deleted file mode` + `/dev/null` header only reverse-applies while one side stays empty, so a partial reverse (which leaves content on the formerly-empty side) is converted into an in-place modification header. Whole-hunk reverses keep the original header and still reverse-apply to a file delete/create. Claude-Session: https://claude.ai/code/session_016WeNfpnVAw4acXjBawtbWG --- .../reverse-changes.integration.test.ts | 47 ++++++++++++ src/git/__tests__/patch-builder.test.ts | 72 +++++++++++++++++++ src/git/patch-builder.ts | 50 ++++++++++++- .../components/commit/CommitDetails.svelte | 2 - .../src/components/commit/FileDiffView.svelte | 19 +++-- .../commit/__tests__/FileDiffView.test.ts | 69 +++++++++--------- 6 files changed, 214 insertions(+), 45 deletions(-) diff --git a/src/git/__tests__/integration/reverse-changes.integration.test.ts b/src/git/__tests__/integration/reverse-changes.integration.test.ts index b201e52..6eb7282 100644 --- a/src/git/__tests__/integration/reverse-changes.integration.test.ts +++ b/src/git/__tests__/integration/reverse-changes.integration.test.ts @@ -111,6 +111,53 @@ describe('GitService integration — reverseCommitChanges', () => { expect(existsSync(join(repo.path, 'new.txt'))).toBe(false); }); + it('reverses the whole hunk of a newly-created file, deleting it', async () => { + // Reversing every line of a wholly-added file's hunk is equivalent to + // reversing the file: the `new file mode` header reverse-applies to a delete. + const added = commit(repo.path, 'add multi', { 'created.txt': 'a\nb\nc\n' }); + await svc.reverseCommitChanges(added, 'created.txt', { hunkIndex: 0 }); + expect(existsSync(join(repo.path, 'created.txt'))).toBe(false); + }); + + it('reverses one added line of a newly-created file, keeping the rest', async () => { + const added = commit(repo.path, 'add multi', { 'created.txt': 'a\nb\nc\n' }); + const hunk = (await svc.showCommitDiff(added, 'created.txt'))[0].hunks[0]; + const idxB = hunk.lines.findIndex((l) => l.type === 'add' && l.content === 'b'); + expect(idxB).toBeGreaterThanOrEqual(0); + + await svc.reverseCommitChanges(added, 'created.txt', { hunkIndex: 0, lineIndices: [idxB] }); + + // The file survives with only the unselected lines — the whole-file-add + // header is rewritten to a modification so the partial reverse applies. + expect(existsSync(join(repo.path, 'created.txt'))).toBe(true); + expect(read(repo, 'created.txt')).toBe('a\nc\n'); + }); + + it('reverses the whole hunk of a deleted file, restoring it', async () => { + commit(repo.path, 'seed', { 'gone.txt': 'p\nq\nr\n' }); + runGit(repo.path, ['rm', 'gone.txt']); + const del = commit(repo.path, 'remove gone'); + expect(existsSync(join(repo.path, 'gone.txt'))).toBe(false); + + await svc.reverseCommitChanges(del, 'gone.txt', { hunkIndex: 0 }); + expect(read(repo, 'gone.txt')).toBe('p\nq\nr\n'); + }); + + it('reverses one deleted line of a deleted file, recreating it with just that line', async () => { + commit(repo.path, 'seed', { 'gone.txt': 'p\nq\nr\n' }); + runGit(repo.path, ['rm', 'gone.txt']); + const del = commit(repo.path, 'remove gone'); + + const hunk = (await svc.showCommitDiff(del, 'gone.txt'))[0].hunks[0]; + const idxQ = hunk.lines.findIndex((l) => l.type === 'delete' && l.content === 'q'); + expect(idxQ).toBeGreaterThanOrEqual(0); + + // Restoring only `q`'s deletion recreates the file holding just that line; + // the `deleted file mode` header still reverse-applies to a create. + await svc.reverseCommitChanges(del, 'gone.txt', { hunkIndex: 0, lineIndices: [idxQ] }); + expect(read(repo, 'gone.txt')).toBe('q\n'); + }); + it('throws when the file was not changed by the commit', async () => { await expect(svc.reverseCommitChanges(hash, 'does-not-exist.txt')).rejects.toThrow(); }); diff --git a/src/git/__tests__/patch-builder.test.ts b/src/git/__tests__/patch-builder.test.ts index 7b3fb4b..17e8f9d 100644 --- a/src/git/__tests__/patch-builder.test.ts +++ b/src/git/__tests__/patch-builder.test.ts @@ -197,6 +197,78 @@ index 4d6e807..12db783 100644 ); }); + // A wholly-added file: `new file mode` + `--- /dev/null` header, all-add hunk. + const ADDED = `diff --git a/new.txt b/new.txt +new file mode 100644 +index 0000000..83db48f +--- /dev/null ++++ b/new.txt +@@ -0,0 +1,3 @@ ++line1 ++line2 ++line3 +`; + + // A wholly-deleted file: `deleted file mode` + `+++ /dev/null` header, all-delete hunk. + const DELETED = `diff --git a/old.txt b/old.txt +deleted file mode 100644 +index 83db48f..0000000 +--- a/old.txt ++++ /dev/null +@@ -1,3 +0,0 @@ +-line1 +-line2 +-line3 +`; + + it('keeps the whole-file-add header when reversing the entire hunk (reverse-applies to a delete)', () => { + // Every line stays an addition, so the new side is still the whole file and + // the old side empty — the `new file mode` + `/dev/null` header is correct. + const patch = buildReversePatch(ADDED, 0); + expect(patch).toBe(ADDED); + }); + + it('rewrites the whole-file-add header into a modification for a partial reverse', () => { + // Reverse only `line2`; `line1`/`line3` demote to context, so the old side is + // no longer empty and the `/dev/null` header would be rejected by git. + const hunk = parseDiff(ADDED)[0].hunks[0]; + const idx = hunk.lines.findIndex((l) => l.type === 'add' && l.content === 'line2'); + const patch = buildReversePatch(ADDED, 0, [idx]); + expect(patch).toBe( + [ + 'diff --git a/new.txt b/new.txt', + 'index 0000000..83db48f 100644', // mode folded in from the dropped `new file mode` + '--- a/new.txt', // was `--- /dev/null` + '+++ b/new.txt', + '@@ -0,2 +1,3 @@', + ' line1', // unselected add → context + '+line2', // selected add → reversed away + ' line3', + '', + ].join('\n'), + ); + }); + + it('keeps the whole-file-delete header for both whole and partial reverses', () => { + // Deletions never land on the new side, so it stays empty and the + // `deleted file mode` + `/dev/null` header keeps reverse-applying to a create. + const hunk = parseDiff(DELETED)[0].hunks[0]; + const idx = hunk.lines.findIndex((l) => l.type === 'delete' && l.content === 'line2'); + const patch = buildReversePatch(DELETED, 0, [idx]); + expect(patch).toBe( + [ + 'diff --git a/old.txt b/old.txt', + 'deleted file mode 100644', + 'index 83db48f..0000000', + '--- a/old.txt', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-line2', // restored on reverse-apply; the other deletions are dropped + '', + ].join('\n'), + ); + }); + it('throws for an out-of-range hunk index', () => { expect(() => buildReversePatch(SAMPLE, 5)).toThrow(/not found/); }); diff --git a/src/git/patch-builder.ts b/src/git/patch-builder.ts index 7b41399..5e7379f 100644 --- a/src/git/patch-builder.ts +++ b/src/git/patch-builder.ts @@ -96,6 +96,53 @@ interface BodyLine { * the canonical form rather than echoing the (whitespace-identical) original. */ const NO_NEWLINE_MARKER = '\\ No newline at end of file'; +/** + * A whole-file add/delete diff carries a `new file mode`/`deleted file mode` + * line and a `/dev/null` side. That header reverses cleanly only while one side + * stays empty (reverse a pure add → delete the file; reverse a pure delete → + * recreate it). A PARTIAL reverse leaves content on the formerly-empty side, so + * the operation is really an in-place modification — and git then rejects the + * `/dev/null` header ("new file ... depends on old contents"). Rewrite those + * header lines into a normal modification header whenever a side is no longer + * empty: + * - old side now non-empty but header says newly added (`--- /dev/null`) → + * restore the real old path (mirrored from the `+++ b/...` side) and fold the + * `new file mode` into the index line. + * - new side now non-empty but header says deleted (`+++ /dev/null`) → + * restore the real new path (mirrored from the `--- a/...` side) and fold the + * `deleted file mode` into the index line. + * A whole-file reverse keeps its empty side, so this is a no-op for it (and for + * ordinary modification diffs, which have no `/dev/null` side at all). + */ +function normalizeWholeFileHeader(header: string[], oldCount: number, newCount: number): string[] { + const rewriteOld = oldCount > 0 && header.includes('--- /dev/null'); + const rewriteNew = newCount > 0 && header.includes('+++ /dev/null'); + if (!rewriteOld && !rewriteNew) { return header; } + + let mode = ''; + const out: string[] = []; + for (const line of header) { + const modeMatch = line.match(/^(?:new|deleted) file mode (\d+)$/); + if (modeMatch) { mode = modeMatch[1]; continue; } + if (rewriteOld && line === '--- /dev/null') { + const plus = header.find((l) => l.startsWith('+++ b/')); + out.push(plus ? '--- a/' + plus.slice('+++ b/'.length) : line); + } else if (rewriteNew && line === '+++ /dev/null') { + const minus = header.find((l) => l.startsWith('--- a/')); + out.push(minus ? '+++ b/' + minus.slice('--- a/'.length) : line); + } else { + out.push(line); + } + } + // The mode lived on the now-dropped `new/deleted file mode` line; a normal + // diff carries it on the index line, so re-attach it there if it's missing. + if (mode) { + const i = out.findIndex((l) => l.startsWith('index ')); + if (i !== -1 && !/ \d+$/.test(out[i])) { out[i] = out[i] + ' ' + mode; } + } + return out; +} + /** * Build a patch containing a single hunk from `rawFileDiff`, optionally narrowed * to a subset of that hunk's changed lines. The result is meant to be applied @@ -263,5 +310,6 @@ export function buildReversePatch(rawFileDiff: string, hunkIndex: number, lineIn } const headerLine = rewriteHunkHeader(hunk.headerLine, oldCount, newCount); - return [...header, headerLine, ...body].join('\n') + '\n'; + const finalHeader = normalizeWholeFileHeader(header, oldCount, newCount); + return [...finalHeader, headerLine, ...body].join('\n') + '\n'; } diff --git a/webview-ui/src/components/commit/CommitDetails.svelte b/webview-ui/src/components/commit/CommitDetails.svelte index bdd2adb..be8a0dc 100644 --- a/webview-ui/src/components/commit/CommitDetails.svelte +++ b/webview-ui/src/components/commit/CommitDetails.svelte @@ -1025,7 +1025,6 @@ onReverse={canReverseInThisView ? handleDiffReverse : undefined} onReverseHunk={canReverseInThisView ? handleHunkReverse : undefined} onReverseLines={canReverseInThisView ? handleLinesReverse : undefined} - fileStatus={files.find(f => f.path === sec.file)?.status} /> {/each}
@@ -1036,7 +1035,6 @@ onReverse={canReverseInThisView ? handleDiffReverse : undefined} onReverseHunk={canReverseInThisView ? handleHunkReverse : undefined} onReverseLines={canReverseInThisView ? handleLinesReverse : undefined} - fileStatus={files.find(f => f.path === selectedDiff.file)?.status} /> {/if} diff --git a/webview-ui/src/components/commit/FileDiffView.svelte b/webview-ui/src/components/commit/FileDiffView.svelte index b5ee2a7..f192815 100644 --- a/webview-ui/src/components/commit/FileDiffView.svelte +++ b/webview-ui/src/components/commit/FileDiffView.svelte @@ -36,9 +36,6 @@ stacked?: boolean; // Optional commit label shown in the toolbar (used by stacked per-commit sections). heading?: string; - // Git status letter for this file ('A'/'M'/'D'/'R'...). Whole add/delete - // files only offer "Reverse File" (from the tree), not hunk reverse. - fileStatus?: string; // When provided (committed view only), right-clicking a diff line offers to // reverse that whole hunk against the working tree. onReverse?: (target: ReverseTarget) => void; @@ -50,11 +47,14 @@ onReverseLines?: (target: { commitHash: string; file: string; hunkIndex: number; lineIndices: number[] }) => void; } - let { diff, commitHash, stacked = false, heading, fileStatus, onReverse, onReverseHunk, onReverseLines }: Props = $props(); + let { diff, commitHash, stacked = false, heading, onReverse, onReverseHunk, onReverseLines }: Props = $props(); - // Whether this diff supports reversing (committed view, modified file). Drives - // both the right-click menu and the per-hunk header reverse affordance. - const canReverse = $derived(!!onReverse && !!commitHash && fileStatus !== 'A' && fileStatus !== 'D'); + // Whether this diff supports reversing (committed view). Drives both the + // right-click menu and the per-hunk header reverse affordance. Whole-file + // additions/deletions reverse too: reversing every line of an added file's + // hunk removes it, and of a deleted file's hunk restores it (the backend's + // patch-builder rewrites the whole-file header for partial selections). + const canReverse = $derived(!!onReverse && !!commitHash); // A truncated diff renders only the first N lines of its final hunk (see // renderHunks). Reversing then would silently undo the unseen tail too, so we @@ -136,7 +136,6 @@ function handleLineContextMenu(e: MouseEvent, hunkIndex: number, lineIndex = -1) { if (!onReverse || !commitHash) return; // not reversible → native menu - if (fileStatus === 'A' || fileStatus === 'D') return; // whole add/delete → use tree's Reverse File const changed = selectedChangedIndices; // Only offer "Reverse Selected Lines" when the right-click lands on a line // that is part of the active selection; otherwise fall back to whole-hunk. @@ -650,8 +649,8 @@ } /* Outline the whole hunk on hover so the reverse extent is obvious. Inline only - when reversible (non-committed/added/deleted diffs get no misleading hint); - SBS outlines the hovered hunk in both panes (.sbs-hunk.hunk-hover below). */ + when reversible (compare/uncommitted diffs get no misleading hint); SBS + outlines the hovered hunk in both panes (.sbs-hunk.hunk-hover below). */ .diff-hunk.reversible:hover, .sbs-hunk.hunk-hover { outline: 1px solid var(--vscode-focusBorder, rgba(120, 120, 255, 0.4)); diff --git a/webview-ui/src/components/commit/__tests__/FileDiffView.test.ts b/webview-ui/src/components/commit/__tests__/FileDiffView.test.ts index ac7232c..540dd8f 100644 --- a/webview-ui/src/components/commit/__tests__/FileDiffView.test.ts +++ b/webview-ui/src/components/commit/__tests__/FileDiffView.test.ts @@ -76,7 +76,7 @@ describe('FileDiffView reverse context menu', () => { it('reports the hunk for a changed line', () => { const onReverse = vi.fn(); const onReverseHunk = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse, onReverseHunk }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse, onReverseHunk }); const lines = container.querySelectorAll('.diff-content .diff-line'); expect(lines.length).toBe(7); @@ -93,7 +93,7 @@ describe('FileDiffView reverse context menu', () => { it('reports the same hunk regardless of which changed line is clicked', () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const lines = container.querySelectorAll('.diff-content .diff-line'); rightClick(lines[5]); // second add of the second block, still hunk 0 @@ -102,7 +102,7 @@ describe('FileDiffView reverse context menu', () => { it('offers Reverse Hunk on a context line', () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const lines = container.querySelectorAll('.diff-content .diff-line'); const ev = rightClick(lines[0]); // context line → still reverses the whole hunk @@ -113,22 +113,27 @@ describe('FileDiffView reverse context menu', () => { expect(arg.selectedLineIndices).toBeUndefined(); }); - it('does not fire and hides the hunk reverse button for a wholly-added file', () => { + it('reverses a wholly-added/deleted file like any other (whole-file hunk/line reverse)', () => { + // Added/deleted files have no special gating: reversing every line of the + // hunk undoes the add/delete, and a partial selection reverses just those + // lines (the backend rewrites the whole-file header). So the affordances + // behave exactly as for a modified file. const onReverse = vi.fn(); const onReverseHunk = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'A', onReverse, onReverseHunk }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse, onReverseHunk }); const lines = container.querySelectorAll('.diff-content .diff-line'); const ev = rightClick(lines[2]); - expect(onReverse).not.toHaveBeenCalled(); - expect(ev.defaultPrevented).toBe(false); - // canReverse is false → no per-hunk reverse button. - expect(container.querySelector('.hunk-hunk-btn')).toBeNull(); + expect(onReverse).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(true); + expect(onReverse.mock.calls[0][0]).toMatchObject({ hunkIndex: 0 }); + // canReverse is true → the per-hunk reverse button renders. + expect(container.querySelector('.hunk-hunk-btn')).not.toBeNull(); }); it('does nothing without a commit hash (compare/uncommitted view)', () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), onReverse }); const lines = container.querySelectorAll('.diff-content .diff-line'); rightClick(lines[2]); @@ -138,7 +143,7 @@ describe('FileDiffView reverse context menu', () => { it('forwards the current text selection', () => { vi.spyOn(window, 'getSelection').mockReturnValue({ toString: () => 'picked text' } as unknown as Selection); const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const lines = container.querySelectorAll('.diff-content .diff-line'); rightClick(lines[2]); @@ -148,7 +153,7 @@ describe('FileDiffView reverse context menu', () => { it('renders a per-hunk reverse button that reverses the hunk on click', async () => { const onReverse = vi.fn(); const onReverseHunk = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse, onReverseHunk }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse, onReverseHunk }); const btn = container.querySelector('.hunk-hunk-btn'); expect(btn).not.toBeNull(); @@ -161,7 +166,7 @@ describe('FileDiffView reverse context menu', () => { it('disables reverse on a truncated hunk (avoids reversing unseen lines)', () => { const onReverse = vi.fn(); const onReverseHunk = vi.fn(); - const { container } = render(FileDiffView, { diff: hugeDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse, onReverseHunk }); + const { container } = render(FileDiffView, { diff: hugeDiff(), commitHash: 'deadbeef', onReverse, onReverseHunk }); // The hunk is rendered partially, so no reverse button and right-click is a no-op. expect(container.querySelector('.hunk-hunk-btn')).toBeNull(); @@ -180,7 +185,7 @@ describe('FileDiffView gutter line-selection', () => { it('drag-selects two changed lines and reverses just those', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); // Drag from the first add (4) to the second add (5). @@ -195,7 +200,7 @@ describe('FileDiffView gutter line-selection', () => { it('excludes context lines from a range that spans them', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); // Select indices 2..4 — index 3 is a context line and must be dropped. @@ -210,7 +215,7 @@ describe('FileDiffView gutter line-selection', () => { it('shift-click extends the selection', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); await fireEvent.mouseDown(g[1]); @@ -225,7 +230,7 @@ describe('FileDiffView gutter line-selection', () => { it('Escape clears the selection (context right-click then reverses the whole hunk)', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); await fireEvent.mouseDown(g[4]); @@ -243,7 +248,7 @@ describe('FileDiffView gutter line-selection', () => { it('keeps an active selection when right-clicking the gutter (right-click does not reset it)', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); // Drag-select the two adds (4, 5). @@ -262,7 +267,7 @@ describe('FileDiffView gutter line-selection', () => { it('does not offer Reverse Selected Lines when right-clicking outside the selected region', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); // Drag-select the two adds (4, 5). @@ -279,7 +284,7 @@ describe('FileDiffView gutter line-selection', () => { it('left-clicking an already-selected gutter line deselects', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); await fireEvent.mouseDown(g[4]); @@ -298,7 +303,7 @@ describe('FileDiffView gutter line-selection', () => { }); it('left-clicking the code region deselects', async () => { - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse: vi.fn() }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse: vi.fn() }); const g = gutters(container); await fireEvent.mouseDown(g[4]); @@ -313,7 +318,7 @@ describe('FileDiffView gutter line-selection', () => { }); it('hunk header shows the Hunk N: Lines A-B label', () => { - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse: vi.fn() }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse: vi.fn() }); const range = container.querySelector('.diff-hunk-range')!; expect(range.textContent).toContain('Hunk 1'); expect(range.textContent).toContain('Lines 1-5'); @@ -322,7 +327,7 @@ describe('FileDiffView gutter line-selection', () => { it('renders a Reverse Lines button that reverses the selection on click', async () => { const onReverse = vi.fn(); const onReverseLines = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse, onReverseLines }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse, onReverseLines }); const g = gutters(container); await fireEvent.mouseDown(g[4]); @@ -337,7 +342,7 @@ describe('FileDiffView gutter line-selection', () => { }); it('applies the line-selected class to selected lines', async () => { - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse: vi.fn() }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse: vi.fn() }); const g = gutters(container); await fireEvent.mouseDown(g[4]); @@ -352,7 +357,7 @@ describe('FileDiffView gutter line-selection', () => { it('clears the selection when switching to side-by-side (no stale invisible selection)', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); await fireEvent.mouseDown(g[4]); @@ -372,7 +377,7 @@ describe('FileDiffView gutter line-selection', () => { it('offers Reverse Hunk for a right-click anywhere in an SBS hunk (including empty rows)', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const sbsBtn = container.querySelectorAll('.diff-mode-toggle button')[1]; await fireEvent.click(sbsBtn); @@ -452,7 +457,7 @@ describe('FileDiffView copy lines (gutter)', () => { it('sets copyLinesText to the joined content of the selection on a gutter right-click', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); // Select the two adds (4 = 'd1', 5 = 'd2'). @@ -468,7 +473,7 @@ describe('FileDiffView copy lines (gutter)', () => { it('includes context lines in copyLinesText (copies everything selected)', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); // Select 2..4: 'b2' (add), 'c' (context), 'd1' (add) — context is kept here. @@ -483,7 +488,7 @@ describe('FileDiffView copy lines (gutter)', () => { it('does NOT offer copy lines when right-clicking the code region', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); await fireEvent.mouseDown(g[4]); @@ -499,7 +504,7 @@ describe('FileDiffView copy lines (gutter)', () => { it('does NOT offer copy lines on a gutter right-click when nothing is selected', () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: sampleDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); rightClick(g[2]); @@ -508,7 +513,7 @@ describe('FileDiffView copy lines (gutter)', () => { it('still offers copy lines for a single blank selected line (empty string, not undefined)', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: blankLineDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: blankLineDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); // Select only the blank added line (index 0). @@ -524,7 +529,7 @@ describe('FileDiffView copy lines (gutter)', () => { it('does NOT offer copy lines when right-clicking a different hunk than the selection', async () => { const onReverse = vi.fn(); - const { container } = render(FileDiffView, { diff: twoHunkDiff(), commitHash: 'deadbeef', fileStatus: 'M', onReverse }); + const { container } = render(FileDiffView, { diff: twoHunkDiff(), commitHash: 'deadbeef', onReverse }); const g = gutters(container); expect(g.length).toBe(4); // hunk0: 0,1 hunk1: 2,3 From af066242a9a55e8f7bfe3306f73f6cd0e313c6f0 Mon Sep 17 00:00:00 2001 From: sehlceris Date: Tue, 23 Jun 2026 22:46:03 -0700 Subject: [PATCH 4/5] test(git): cover reverse-patch edge cases and merge/root commits Add tests for the previously-uncovered paths in the reverse-changes feature: - patch-builder: no-hunk diffs, space-stripped blank context lines, unparseable hunk headers, the whole-file-delete header rewrite, and a no-EOF deleted-file partial reverse. - git-service: reverseCommitChanges against a root commit (empty-tree diff) and a merge commit (first non-empty parent diff), plus the no-changes-to-reverse case on a merge. Raises patch coverage on patch-builder.ts and git-service.ts; remaining uncovered branches are unreachable defensive fallbacks. Claude-Session: https://claude.ai/code/session_01F1XM63RwFX5AN5KrAcbmyN --- .../reverse-changes.integration.test.ts | 63 +++++++++- src/git/__tests__/patch-builder.test.ts | 114 ++++++++++++++++++ 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/src/git/__tests__/integration/reverse-changes.integration.test.ts b/src/git/__tests__/integration/reverse-changes.integration.test.ts index 6eb7282..d0ebd7a 100644 --- a/src/git/__tests__/integration/reverse-changes.integration.test.ts +++ b/src/git/__tests__/integration/reverse-changes.integration.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { GitService } from '../../git-service'; -import { TempRepo, commit, createTempRepo, runGit } from './helpers'; +import { TempRepo, commit, createTempRepo, head, runGit, writeFile } from './helpers'; // Two edits far enough apart (line 2 and line 14) that git keeps them in // separate hunks rather than merging them. @@ -161,6 +161,67 @@ describe('GitService integration — reverseCommitChanges', () => { it('throws when the file was not changed by the commit', async () => { await expect(svc.reverseCommitChanges(hash, 'does-not-exist.txt')).rejects.toThrow(); }); + + it('reverses a change introduced by the root commit (diffs against the empty tree)', async () => { + // The root commit has no parent, so its diff is taken against the empty tree + // via `git show`. Use a fresh repo whose root commit is still HEAD so the + // reverse applies cleanly; reversing the root's addition removes the file. + const rootRepo = createTempRepo(); + try { + const rootSvc = new GitService(rootRepo.path); + const root = commit(rootRepo.path, 'root', { 'r.txt': 'one\ntwo\n' }); + expect(existsSync(join(rootRepo.path, 'r.txt'))).toBe(true); + + await rootSvc.reverseCommitChanges(root, 'r.txt'); + expect(existsSync(join(rootRepo.path, 'r.txt'))).toBe(false); + } finally { + rootRepo.cleanup(); + } + }); +}); + +// A merge commit has 2+ parents, so the diff is taken against the first parent +// whose change for the file is non-empty (mirroring showCommitDiff). +describe('GitService integration — reverseCommitChanges on a merge commit', () => { + let repo: TempRepo; + let svc: GitService; + + beforeEach(() => { + repo = createTempRepo(); + svc = new GitService(repo.path); + }); + afterEach(() => repo.cleanup()); + + // Builds: main(base → main work) and feature(base → feature work), then an + // "evil merge" that ALSO edits m.txt while merging. The merge commit's diff + // for m.txt against its first parent (main work) is therefore non-empty. + function buildEvilMerge(): string { + commit(repo.path, 'base', { 'm.txt': 'x\n', 'side.txt': 's\n' }); + runGit(repo.path, ['checkout', '-b', 'feature']); + commit(repo.path, 'feature work', { 'feature.txt': 'f\n' }); + runGit(repo.path, ['checkout', 'main']); + commit(repo.path, 'main work', { 'main.txt': 'mm\n' }); + runGit(repo.path, ['merge', '--no-ff', '--no-commit', 'feature']); + writeFile(repo.path, 'm.txt', 'x\ny\n'); // change folded into the merge itself + runGit(repo.path, ['add', '-A']); + runGit(repo.path, ['commit', '--no-edit']); + return head(repo.path); + } + + it('reverses a change made by the merge commit itself', async () => { + const mergeHash = buildEvilMerge(); + expect(runGit(repo.path, ['rev-list', '--parents', '-n1', mergeHash]).trim().split(' ').length).toBe(3); + + await svc.reverseCommitChanges(mergeHash, 'm.txt'); + // The merge added the `y` line; reversing restores the first parent's content. + expect(read(repo, 'm.txt')).toBe('x\n'); + }); + + it('throws for a file the merge left identical to both parents', async () => { + const mergeHash = buildEvilMerge(); + // side.txt is unchanged everywhere, so every parent diff is empty. + await expect(svc.reverseCommitChanges(mergeHash, 'side.txt')).rejects.toThrow(/No changes to reverse/); + }); }); // Files WITHOUT a trailing newline exercise the "\ No newline at end of file" diff --git a/src/git/__tests__/patch-builder.test.ts b/src/git/__tests__/patch-builder.test.ts index 17e8f9d..8238a2d 100644 --- a/src/git/__tests__/patch-builder.test.ts +++ b/src/git/__tests__/patch-builder.test.ts @@ -269,6 +269,120 @@ index 83db48f..0000000 ); }); + it('rewrites a whole-file-delete header into a modification when the new side becomes non-empty', () => { + // Real git never emits a `+++ /dev/null` diff with surviving new-side + // content, but the header normalizer defends against it symmetrically with + // the add case. Craft a `deleted file` diff that carries a context line so a + // partial reverse leaves `newCount > 0`, forcing the `+++ /dev/null` rewrite. + const DELETED_WITH_CONTEXT = `diff --git a/old.txt b/old.txt +deleted file mode 100644 +index 83db48f..0000000 +--- a/old.txt ++++ /dev/null +@@ -1,3 +0,0 @@ +-line1 + line2 +-line3 +`; + // Reverse only line1 (index 0); line3's deletion is dropped, line2 stays as + // context → the new side is non-empty, so `+++ /dev/null` must be rewritten. + const patch = buildReversePatch(DELETED_WITH_CONTEXT, 0, [0]); + expect(patch).toBe( + [ + 'diff --git a/old.txt b/old.txt', + 'index 83db48f..0000000 100644', // mode folded in from `deleted file mode` + '--- a/old.txt', + '+++ b/old.txt', // was `+++ /dev/null` + '@@ -1,2 +0,1 @@', + '-line1', // restored on reverse-apply + ' line2', // kept context — the now-non-empty new side + '', + ].join('\n'), + ); + }); + + it('keeps a blank (space-stripped) context line in the middle of a hunk', () => { + // A blank context line whose leading ' ' was stripped must be preserved as a + // single-space context line, not skipped (only the trailing split artifact is). + const blankCtx = `diff --git a/f b/f +index 1..2 100644 +--- a/f ++++ b/f +@@ -1,3 +1,3 @@ + top + +-mid ++MID +`; + const patch = buildReversePatch(blankCtx, 0); + expect(patch).toBe( + [ + 'diff --git a/f b/f', + 'index 1..2 100644', + '--- a/f', + '+++ b/f', + '@@ -1,3 +1,3 @@', + ' top', + ' ', // blank line preserved as a space-only context line + '-mid', + '+MID', + '', + ].join('\n'), + ); + }); + + it('passes through a hunk header it cannot parse', () => { + // A malformed `@@` header (no line numbers) can't be recounted, so it is + // emitted verbatim rather than mangled. + const malformed = `diff --git a/f b/f +index 1..2 100644 +--- a/f ++++ b/f +@@ malformed @@ +-a ++b +`; + const patch = buildReversePatch(malformed, 0); + expect(patch.split('\n')).toContain('@@ malformed @@'); + expect(patch).toContain('-a'); + expect(patch).toContain('+b'); + }); + + it('throws for a diff that contains no hunks', () => { + // Header-only diffs (e.g. a pure mode/rename change) have no hunk to reverse. + const noHunk = 'diff --git a/f b/f\nold mode 100644\nnew mode 100755\n'; + expect(() => buildReversePatch(noHunk, 0)).toThrow(/not found/); + }); + + it('keeps the no-newline marker when reversing one line of a deleted no-EOF file', () => { + // The deleted file ended without a trailing newline. Restoring only its last + // line must reproduce that line, still unterminated, on the recreated file. + const deletedNoEof = `diff --git a/f b/f +deleted file mode 100644 +index 1234567..0000000 100644 +--- a/f ++++ /dev/null +@@ -1,2 +0,0 @@ +-p +-q +\\ No newline at end of file +`; + const patch = buildReversePatch(deletedNoEof, 0, [1]); + expect(patch).toBe( + [ + 'diff --git a/f b/f', + 'deleted file mode 100644', + 'index 1234567..0000000 100644', + '--- a/f', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-q', // restored on reverse-apply... + '\\ No newline at end of file', // ...still unterminated + '', + ].join('\n'), + ); + }); + it('throws for an out-of-range hunk index', () => { expect(() => buildReversePatch(SAMPLE, 5)).toThrow(/not found/); }); From 6eb5944748260779d6e169210ca71be9002d9893 Mon Sep 17 00:00:00 2001 From: sehlceris Date: Wed, 24 Jun 2026 18:58:21 -0700 Subject: [PATCH 5/5] refactor(git): unify commit-file parent selection for diff and reverse showCommitDiff selected a merge parent by parsed hunk presence while rawCommitFileDiff selected by raw non-emptiness. For a merge commit where an earlier parent yields a hunkless mode-only/rename-only diff and a later parent carries the content hunks, the two diverged: the UI showed the later parent while reverse built its patch from the earlier one, throwing "Hunk N not found" on a partial reverse or reversing an unexpected mode/rename change on a whole-file reverse. Extract a shared commitFileDiff helper returning both raw and parsed diff so the displayed diff and the reversed patch can never select different parents. Add an integration test covering the hunkless-first-parent case. Claude-Session: https://claude.ai/code/session_01DvR8D2L1Q8o9bTo8jh2J4P --- .../reverse-changes.integration.test.ts | 39 +++++++++++ src/git/git-service.ts | 68 +++++++++---------- 2 files changed, 70 insertions(+), 37 deletions(-) diff --git a/src/git/__tests__/integration/reverse-changes.integration.test.ts b/src/git/__tests__/integration/reverse-changes.integration.test.ts index d0ebd7a..7ce37f4 100644 --- a/src/git/__tests__/integration/reverse-changes.integration.test.ts +++ b/src/git/__tests__/integration/reverse-changes.integration.test.ts @@ -222,6 +222,45 @@ describe('GitService integration — reverseCommitChanges on a merge commit', () // side.txt is unchanged everywhere, so every parent diff is empty. await expect(svc.reverseCommitChanges(mergeHash, 'side.txt')).rejects.toThrow(/No changes to reverse/); }); + + // Regression: parent selection must agree between the displayed diff and the + // reversed patch. The first parent's diff for m.txt is mode-only (non-empty + // raw, but ZERO hunks); the second parent's carries the real content hunk. + // Selecting on raw-non-emptiness would pick parent 1 and reverse the wrong + // (mode) change / throw "Hunk 0 not found"; selecting on parsed hunks (as + // showCommitDiff does) picks parent 2 — both paths must land on parent 2. + function buildHunklessFirstParentMerge(): string { + commit(repo.path, 'base', { 'm.txt': 'x\n' }); + runGit(repo.path, ['checkout', '-b', 'feature']); + commit(repo.path, 'feature content', { 'm.txt': 'y\n' }); // real content change + runGit(repo.path, ['checkout', 'main']); // main stays at base for m.txt + runGit(repo.path, ['merge', '--no-ff', '--no-commit', 'feature']); + // Resolve to base's CONTENT but flip the executable bit, so vs parent 1 + // (main = base) only the mode differs, while vs parent 2 (feature) the + // content differs. update-index sets the recorded mode regardless of + // core.fileMode, keeping the diff deterministic. + writeFile(repo.path, 'm.txt', 'x\n'); + runGit(repo.path, ['add', '-A']); + runGit(repo.path, ['update-index', '--chmod=+x', 'm.txt']); + runGit(repo.path, ['commit', '--no-edit']); + return head(repo.path); + } + + it('reverses against the same parent the UI shows when an earlier parent is hunkless', async () => { + const mergeHash = buildHunklessFirstParentMerge(); + + // The displayed diff comes from parent 2 (the content-bearing parent). + const diff = await svc.showCommitDiff(mergeHash, 'm.txt'); + expect(diff[0].hunks.length).toBeGreaterThan(0); + const hunk = diff[0].hunks[0]; + expect(hunk.lines.some((l) => l.type === 'delete' && l.content === 'y')).toBe(true); + expect(hunk.lines.some((l) => l.type === 'add' && l.content === 'x')).toBe(true); + + // Reversing hunk 0 must use that SAME parent — restoring feature's content. + // Against the hunkless first parent this would throw "Hunk 0 not found". + await svc.reverseCommitChanges(mergeHash, 'm.txt', { hunkIndex: 0 }); + expect(read(repo, 'm.txt')).toBe('y\n'); + }); }); // Files WITHOUT a trailing newline exercise the "\ No newline at end of file" diff --git a/src/git/git-service.ts b/src/git/git-service.ts index a2cf768..a82fdf7 100644 --- a/src/git/git-service.ts +++ b/src/git/git-service.ts @@ -1093,63 +1093,57 @@ export class GitService { return this.showStashDiff(hash, stashParents, file); } - const parents = await this.commitParents(hash); + if (file) { + return (await this.commitFileDiff(hash, file)).parsed; + } + // Overview (no specific file). + const parents = await this.commitParents(hash); if (parents.length === 0) { // Root commit: diff against empty tree. - const args = ['show', '--no-color', '--format=', hash]; - if (file) args.push('--', file); - const raw = await this.exec(args); - return parseDiff(raw); + return parseDiff(await this.exec(['show', '--no-color', '--format=', hash])); } - - if (parents.length > 1 && file) { - // Octopus / regular merge with a specific file: find the first parent - // whose diff for this file is non-empty. The first-parent-only default - // hides changes that came in from parent 2..N. - for (const parent of parents) { - this.assertSafeRef(parent, 'diff'); - const raw = await this.exec(['diff', '--no-color', `${parent}..${hash}`, '--', file]); - const parsed = parseDiff(raw); - if (parsed.length > 0 && parsed[0].hunks.length > 0) { - return parsed; - } - } - return []; - } - - // Single-parent commit, or merge overview without a specific file. - const args = ['diff', '--no-color', `${hash}^..${hash}`]; - if (file) args.push('--', file); - const raw = await this.exec(args); - return parseDiff(raw); + // Single-parent commit, or merge overview (first-parent diff). + return parseDiff(await this.exec(['diff', '--no-color', `${hash}^..${hash}`])); } - /** Raw unified diff for one file in a commit, against the appropriate parent. - * Mirrors showCommitDiff's parent selection so the patch we reverse matches - * exactly what the user is looking at. */ - private async rawCommitFileDiff(hash: string, file: string): Promise { + /** + * Diff for a single file in a commit, against the appropriate parent, returned + * as both the raw unified text and its parsed form. Centralising the parent + * selection here guarantees the displayed diff ({@link showCommitDiff}) and the + * patch we reverse ({@link reverseCommitChanges}) can never pick different + * parents — see the merge-commit case below. + */ + private async commitFileDiff(hash: string, file: string): Promise<{ raw: string; parsed: DiffData[] }> { this.assertSafeRef(hash, 'diff'); this.assertSafePath(file, 'diff'); const parents = await this.commitParents(hash); if (parents.length === 0) { // Root commit: diff against the empty tree. - return this.exec(['show', '--no-color', '--format=', hash, '--', file]); + const raw = await this.exec(['show', '--no-color', '--format=', hash, '--', file]); + return { raw, parsed: parseDiff(raw) }; } if (parents.length > 1) { - // Merge: use the first parent whose diff for this file is non-empty, - // matching showCommitDiff (first-parent-only would hide later parents). + // Octopus / regular merge: find the first parent whose diff for this file + // carries real content hunks. The first-parent-only default hides changes + // that came in from parent 2..N. We test on parsed hunks (not raw + // non-emptiness) so a mode-only / rename-only diff from an earlier parent + // doesn't shadow the later parent that actually holds the content. for (const parent of parents) { this.assertSafeRef(parent, 'diff'); const raw = await this.exec(['diff', '--no-color', `${parent}..${hash}`, '--', file]); - if (raw.trim()) { return raw; } + const parsed = parseDiff(raw); + if (parsed.length > 0 && parsed[0].hunks.length > 0) { + return { raw, parsed }; + } } - return ''; + return { raw: '', parsed: [] }; } - return this.exec(['diff', '--no-color', `${hash}^..${hash}`, '--', file]); + const raw = await this.exec(['diff', '--no-color', `${hash}^..${hash}`, '--', file]); + return { raw, parsed: parseDiff(raw) }; } /** @@ -1170,7 +1164,7 @@ export class GitService { this.assertSafeRef(hash, 'apply'); this.assertSafePath(file, 'apply'); - const raw = await this.rawCommitFileDiff(hash, file); + const { raw } = await this.commitFileDiff(hash, file); if (!raw.trim()) { throw new GitError(`No changes to reverse for ${file} in ${hash.substring(0, 7)}`, null, []); }