From e43561b1e8b2bc9e59c9a3c44325365b0429b020 Mon Sep 17 00:00:00 2001 From: Taylor Bombay Date: Tue, 8 Sep 2026 18:58:15 +0000 Subject: [PATCH 1/2] feat(git): add unstaged hunk discard - Add IPC and Git service support for reversing unstaged hunks - Add confirmation UI and coverage for preserving staged and other changes --- src/main/gitService.ts | 17 +++++++ src/main/main.ts | 12 +++++ src/main/preload.ts | 2 + src/renderer/App.history.test.tsx | 21 +++++++++ src/renderer/App.tsx | 45 ++++++++++++++++-- src/renderer/AppTestHarness.tsx | 1 + src/renderer/lineStaging.integration.test.ts | 48 ++++++++++++++++++++ src/shared/ipc.ts | 1 + src/shared/types.ts | 1 + 9 files changed, 143 insertions(+), 5 deletions(-) diff --git a/src/main/gitService.ts b/src/main/gitService.ts index f91b7e7..9f246ca 100644 --- a/src/main/gitService.ts +++ b/src/main/gitService.ts @@ -1013,6 +1013,23 @@ export class GitService { ], undefined, validation.patch); } + async discardHunk(request: GitHunkRequest): Promise { + if (request.side !== "unstaged") { + return this.createOperationFailure(request.repoPath, "Only unstaged hunks can be discarded."); + } + const validation = await this.validateHunkRequest(request, "unstaged"); + if ("error" in validation) { + return this.createOperationFailure(request.repoPath, validation.error); + } + + return this.runGitOperation(request.repoPath, [ + "apply", + "--reverse", + "--whitespace=nowarn", + "-" + ], undefined, validation.patch); + } + async unstageHunk(request: GitHunkRequest): Promise { const validation = await this.validateHunkRequest(request, "staged"); if ("error" in validation) { diff --git a/src/main/main.ts b/src/main/main.ts index b75e9d1..99e73ac 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -795,6 +795,18 @@ ipcMain.handle(IPC_CHANNELS.unstageFiles, async (event, request: CoordinatedRequ ); }); +ipcMain.handle(IPC_CHANNELS.discardHunk, async (event, request: CoordinatedRequest) => { + return runExclusiveGitOperation( + async () => { + if ((await vcsRouter.resolveKind(request.repoPath)) !== "git") { + return createOperationFailure(request.repoPath, "Discard Hunk is available only for Git repositories."); + } + return gitService.discardHunk(request); + }, + repositoryOperationOptions(event, request.operationId, request.repoPath) + ); +}); + ipcMain.handle(IPC_CHANNELS.stageHunk, async (event, request: CoordinatedRequest) => { return runExclusiveGitOperation( async () => (await vcsRouter.serviceForRepo(request.repoPath)).stageHunk(request), diff --git a/src/main/preload.ts b/src/main/preload.ts index ae8ac65..9ef6239 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -244,6 +244,8 @@ const api: GitheadApi = { ipcRenderer.invoke(IPC_CHANNELS.stageFiles, request) as ReturnType, unstageFiles: (request: CoordinatedRequest) => ipcRenderer.invoke(IPC_CHANNELS.unstageFiles, request) as ReturnType, + discardHunk: (request: CoordinatedRequest) => + ipcRenderer.invoke(IPC_CHANNELS.discardHunk, request) as ReturnType, stageHunk: (request: CoordinatedRequest) => ipcRenderer.invoke(IPC_CHANNELS.stageHunk, request) as ReturnType, unstageHunk: (request: CoordinatedRequest) => diff --git a/src/renderer/App.history.test.tsx b/src/renderer/App.history.test.tsx index 63c8a5c..6b292ff 100644 --- a/src/renderer/App.history.test.tsx +++ b/src/renderer/App.history.test.tsx @@ -1935,6 +1935,27 @@ describe("App", { timeout: 10_000 }, () => { expect(screen.queryByRole("button", { name: "Wrap diff lines" })).toBeNull(); }); + it("confirms hunk discard and reloads the remaining changes", async () => { + const user = userEvent.setup(); + const file = createStatusFile("src/App.tsx", { isUnstaged: true, worktreeStatus: "M" }); + const diff = createTextDiff(file.path, "discard-me"); + vi.mocked(githead.getRepoSummary).mockResolvedValue(createSummary({ files: [file] })); + vi.mocked(githead.getFileDiff).mockResolvedValueOnce(diff).mockResolvedValue(createTextDiff(file.path, "remaining-hunk")); + render(); + await user.click(await screen.findByRole("option", { name: /src\/App\.tsx/ })); + await user.click(await screen.findByRole("button", { name: "Discard Hunk" })); + expect(githead.discardHunk).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(githead.discardHunk).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Discard Hunk" })); + await user.click(screen.getByRole("button", { name: "Discard changes" })); + await waitFor(() => expect(githead.discardHunk).toHaveBeenCalledWith({ + repoPath, path: file.path, side: "unstaged", patch: `${diff.text}\n`, operationId: expect.any(String) + })); + expect(await screen.findByText("remaining-hunk")).toBeTruthy(); + expect(githead.stageHunk).not.toHaveBeenCalled(); + }); + it("keeps an unstaged file selected and reloads its remaining diff after staging a hunk", async () => { const user = userEvent.setup(); const initialFile = createStatusFile("src/App.tsx", { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 254a0ab..d003279 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5098,15 +5098,19 @@ export function App({ initialAppSettings = null }: { initialAppSettings?: AppSet ); }, [runRepoOperation]); - const applySelectedHunk = useCallback(async (patch: string): Promise => { + const applySelectedHunk = useCallback(async (patch: string, discard = false): Promise => { const current = stateRef.current; const selection = current.selection; - if (!selection || current.diffChanged) { + if (!selection || current.diffChanged || (discard && selection.side !== "unstaged")) { return; } const repoPath = current.repoPath; - const result = selection.side === "unstaged" + const result = discard + ? await runRepoOperation("Discarding hunk", selection, (operationId) => + window.githead.discardHunk({ repoPath, path: selection.path, side: selection.side, patch, operationId }) + ) + : selection.side === "unstaged" ? await runRepoOperation("Staging hunk", selection, (operationId) => window.githead.stageHunk({ repoPath, @@ -10489,7 +10493,7 @@ function StatusView({ onUnstageFiles: (paths: string[], selection?: FileSelection) => void; onRefreshDiff: () => void; onDownloadImage: () => void; - onApplyHunk: (patch: string) => void; + onApplyHunk: (patch: string, discard?: boolean) => void; onContextAction: (file: GitStatusFile, side: GitDiffSide, kind: ContextActionKind, paths?: string[]) => void; onUpdateSubmodules: (path?: string) => void; onSyncSubmodules: () => void; @@ -10523,7 +10527,8 @@ function StatusView({ ? { side: selectedSide, disabled: disabled || diffChanged, - onApply: onApplyHunk + onApply: onApplyHunk, + onDiscard: selectedSide === "unstaged" ? (patch) => onApplyHunk(patch, true) : undefined } : undefined ), [canApplyHunks, diffChanged, disabled, onApplyHunk, selectedSide]); @@ -11168,6 +11173,7 @@ function DiffPanel({ } interface DiffHunkAction { + onDiscard?: ((patch: string) => void) | undefined; side: GitDiffSide; disabled: boolean; onApply: (patch: string) => void; @@ -11184,6 +11190,8 @@ const DiffRows = memo(function DiffRows({ truncated: boolean; hunkAction?: DiffHunkAction | undefined; }): ReactNode { + const [discardTarget, setDiscardTarget] = useState<{ patch: string; filePath: string; text: string } | null>(null); + const discardTargetCurrent = discardTarget?.filePath === filePath && discardTarget.text === text; const sessionRef = useRef | null>(null); const { value: processedValue, @@ -11233,6 +11241,21 @@ const DiffRows = memo(function DiffRows({ return (
{!processed ? : null} + { if (!open) setDiscardTarget(null); }}> + + + Discard this hunk? + The changes in this hunk will be reverted. Other hunks and staged changes will be kept. + + + + + + + {groups.map((group, groupIndex) => { const groupKey = `${groupIndex}:${group.kind}:${group.rows[0]?.text ?? ""}`; const rowViews = group.rows.flatMap((row, rowIndex) => { @@ -11259,6 +11282,18 @@ const DiffRows = memo(function DiffRows({