From f645d57c93e733953e3fcbfcc460d9addb297efd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 14 Jun 2026 10:58:23 +0300 Subject: [PATCH] Stage, unstage, and discard individual diff hunks Add per-hunk staging, unstaging, and discarding to the Changes diff view, so a single change region inside a file can be acted on in isolation instead of forcing whole-file stage/revert. The change is wired end-to-end across the web server, the shared UI runtime API contract, and the VS Code extension, with Electron inheriting the web path unchanged (it boots the server in-process). Server ------ - New `applyHunk(directory, filePath, { patch, action })` in packages/web/server/lib/git/service.js. It resolves the repository context and validates the file path with the same helpers used by stageFiles/unstageFiles (resolveGitFileContext + validateRepositoryFilePaths), then writes the single-hunk patch to a temporary file in the OS temp dir (never inside the repo, so it cannot show up as an untracked file) and runs `git apply` with flags chosen per action: stage -> git apply --cached (working tree -> index) unstage -> git apply --cached --reverse (index -> working tree) discard -> git apply --reverse (revert in working tree) A `git apply --check` runs first with the same flags, so a stale hunk that no longer applies fails with a clear "Hunk no longer applies - refresh and try again" message instead of leaving a partial mutation. The patch's target path is parsed and must match the requested file (with /dev/null tolerated for new/deleted files), preventing a patch from silently targeting a different path. The whole operation runs inside withGitIndexMutationQueue to avoid racing with concurrent stage/unstage. The temp file is removed in a finally block. - New `POST /api/git/apply-hunk` route in routes.js, registered alongside stage/unstage. Validates directory, path, non-empty patch, and action before delegating. - DOCUMENTATION.md updated with the new service entry. Patch extraction ---------------- - packages/ui/src/lib/diff/patchFileDiff.ts gains splitPatchIntoHunks(patch) and extractHunkPatch(patch, hunkIndex). They keep the original file header (diff --git / index / --- / +++) and emit exactly one @@ hunk per standalone patch, which is what `git apply` expects. Each emitted patch is guaranteed to end with a trailing newline (without it git apply reports "corrupt patch"). Runtime API contract -------------------- - GitAPI (packages/ui/src/lib/api/types.ts) gains optional stageGitHunk / unstageGitHunk / revertGitHunk, matching the stageGitFiles? / unstageGitFiles? precedent so runtimes that do not support it degrade gracefully. - gitApi.ts delegates to the registered runtime git API, falling back to gitApiHttp, exactly like the existing whole-file helpers. - gitApiHttp.ts posts to /api/git/apply-hunk. - Web runtime composes the three methods in packages/web/src/api/git.ts. VS Code parity -------------- - packages/vscode/src/gitService.ts adds applyGitHunk(), implemented natively with the existing execGit helper + a temp patch file + `git apply` (--cached / --cached --reverse / --reverse), mirroring the server's --check-first safety and temp-file cleanup. - bridge-git-runtime.ts handles the new api:git/apply-hunk bridge message; webview/api/git.ts sends it. VS Code users get identical stage/unstage/discard-hunk behavior. UI -- - New DiffHunkActions component renders a compact per-hunk strip above each expanded file diff in the Changes view. Each hunk chip shows its +additions / -deletions counts and offers: working scope -> Stage + Discard staged scope -> Unstage Clicking extracts that hunk's standalone patch via extractHunkPatch(patch, hunkIndex) and calls the runtime git API. Because the chip index comes directly from fileDiff.hunks[] and the patch is sliced in the same order, the hunk the user sees is always the hunk that gets applied. While any action is in flight all buttons disable to prevent conflicting concurrent mutations; the per-hunk spinner reflects in-flight state. - DiffView wires DiffHunkActions into InlineDiffViewer (text diffs only; binary/image and full-file-content modes are excluded since they have no patch). MultiFileDiffEntry passes directory/staged through and handles onHunkApplied by bumping the diff reload nonce (so the file's diff re-fetches and the affected hunk disappears) and refreshing git status (so file counts and the staged/changed scope update). Hunk actions are therefore available wherever the default patch-context diff is shown. i18n ---- - 10 new keys (diffView.hunk.*) added to all 9 locales (en, es, fr, ko, pl, pt-BR, uk, zh-CN, zh-TW), including stage/unstage/discard labels, tooltips with the hunk index, a stale-hunk error message, and an unsupported-runtime fallback. Tests ----- - packages/ui/src/lib/diff/patchFileDiff.test.ts covers splitHunks/extractHunkPatch: multi-hunk split, header preservation, single-hunk and empty patches, out-of-range indices. - service.test.js adds an applyHunk suite that builds real temp repos with two separate hunks and verifies: staging one hunk leaves the other unstaged, discarding reverts only the targeted hunk in the working tree, unstaging removes only one hunk from the index, and a retargeted patch (different file path) is rejected. Also covers invalid-action / missing-hunk-header validation. - packages/web/src/api/git.test.ts mock completed with the new methods (and previously-missing exports that prevented the test from loading) and asserts the three hunk methods are exposed. - routes.test.js continues to pass under bun. CHANGELOG updated under [Unreleased]. --- CHANGELOG.md | 1 + .../src/components/views/DiffHunkActions.tsx | 158 ++++++++++++++++++ packages/ui/src/components/views/DiffView.tsx | 96 +++++++---- packages/ui/src/lib/api/types.ts | 3 + .../ui/src/lib/diff/patchFileDiff.test.ts | 83 +++++++++ packages/ui/src/lib/diff/patchFileDiff.ts | 51 ++++++ packages/ui/src/lib/gitApi.ts | 18 ++ packages/ui/src/lib/gitApiHttp.ts | 37 ++++ packages/ui/src/lib/i18n/messages/en.ts | 9 + packages/ui/src/lib/i18n/messages/es.ts | 9 + packages/ui/src/lib/i18n/messages/fr.ts | 9 + packages/ui/src/lib/i18n/messages/ko.ts | 9 + packages/ui/src/lib/i18n/messages/pl.ts | 9 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 9 + packages/ui/src/lib/i18n/messages/uk.ts | 9 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 9 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 9 + packages/vscode/src/bridge-git-runtime.ts | 17 ++ packages/vscode/src/gitService.ts | 52 ++++++ packages/vscode/webview/api/git.ts | 12 ++ packages/web/server/lib/git/DOCUMENTATION.md | 1 + packages/web/server/lib/git/routes.js | 27 +++ packages/web/server/lib/git/service.js | 69 ++++++++ packages/web/server/lib/git/service.test.js | 120 +++++++++++++ packages/web/src/api/git.test.ts | 16 ++ packages/web/src/api/git.ts | 3 + 26 files changed, 811 insertions(+), 34 deletions(-) create mode 100644 packages/ui/src/components/views/DiffHunkActions.tsx create mode 100644 packages/ui/src/lib/diff/patchFileDiff.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe88da5..37534f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. - Sessions: selected rows now highlight across the full sidebar gutter. - Comments: inline file/diff comment drafts now stay in place when focus changes. - Git/Diff: redesigned the Changes diff view with faster multi-file rendering, expandable hunk separators, a full-file loading toggle, compact responsive controls, and a unified changed/staged context panel workflow. +- Git/Diff: individual diff hunks can now be staged, unstaged, or discarded directly from the Changes view via `git apply`. - GitHub: GitHub settings can now use credentials from the `gh` CLI when available (thanks to @tomzx). - Settings/MCP: importing MCP snippets from OpenCode config works again (thanks to @youzini). - Usage: added Cursor plan as a usage-tracking provider.. diff --git a/packages/ui/src/components/views/DiffHunkActions.tsx b/packages/ui/src/components/views/DiffHunkActions.tsx new file mode 100644 index 00000000..12388e40 --- /dev/null +++ b/packages/ui/src/components/views/DiffHunkActions.tsx @@ -0,0 +1,158 @@ +import React from 'react'; + +import type { FileDiffMetadata } from '@pierre/diffs'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/icon/Icon'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import { extractHunkPatch } from '@/lib/diff/patchFileDiff'; + +type HunkAction = 'stage' | 'unstage' | 'discard'; + +interface DiffHunkActionsProps { + patch: string; + fileDiff: FileDiffMetadata | undefined; + directory: string; + filePath: string; + staged: boolean; + onApplied: (action: HunkAction) => void; +} + +export const DiffHunkActions = React.memo(({ + patch, + fileDiff, + directory, + filePath, + staged, + onApplied, +}) => { + const { t } = useI18n(); + const { git } = useRuntimeAPIs(); + const [busyKey, setBusyKey] = React.useState(null); + const [error, setError] = React.useState(null); + + const hunks = fileDiff?.hunks; + if (!hunks || hunks.length === 0 || !patch) { + return null; + } + + const run = async (hunkIndex: number, action: HunkAction) => { + const hunkPatch = extractHunkPatch(patch, hunkIndex); + if (!hunkPatch) { + setError(t('diffView.hunk.unavailable')); + return; + } + + const key = `${hunkIndex}:${action}`; + setBusyKey(key); + setError(null); + try { + if (action === 'stage') { + if (!git.stageGitHunk) throw new Error(t('diffView.hunk.unsupported')); + await git.stageGitHunk(directory, filePath, hunkPatch); + } else if (action === 'unstage') { + if (!git.unstageGitHunk) throw new Error(t('diffView.hunk.unsupported')); + await git.unstageGitHunk(directory, filePath, hunkPatch); + } else { + if (!git.revertGitHunk) throw new Error(t('diffView.hunk.unsupported')); + await git.revertGitHunk(directory, filePath, hunkPatch); + } + onApplied(action); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : String(actionError)); + } finally { + setBusyKey((current) => (current === key ? null : current)); + } + }; + + return ( +
+
+ + {t('diffView.hunk.label')} + + {hunks.map((hunk, index) => { + const additions = hunk.additionLines; + const deletions = hunk.deletionLines; + return ( +
+ + {String(index + 1).padStart(2, '0')} + + {additions > 0 ? ( + + +{additions} + + ) : null} + {deletions > 0 ? ( + + −{deletions} + + ) : null} + {staged ? ( + + ) : ( + <> + + + + )} +
+ ); + })} +
+ {error ? ( +
+ + {error} +
+ ) : null} +
+ ); +}); diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 89dbe2b1..0ac1dae4 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -26,6 +26,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle'; import type { DiffViewMode } from '@/components/chat/message/types'; import { PierreDiffViewer } from './PierreDiffViewer'; +import { DiffHunkActions } from './DiffHunkActions'; import { useDeviceInfo } from '@/lib/device'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Icon } from "@/components/icon/Icon"; @@ -481,51 +482,67 @@ const InlineImageDiffViewer = React.memo(({ }); interface InlineDiffViewerProps { - filePath: string; - diff: DiffData; - renderSideBySide: boolean; - wrapLines: boolean; + filePath: string; + diff: DiffData; + renderSideBySide: boolean; + wrapLines: boolean; + directory: string; + staged: boolean; + onHunkApplied: (action: 'stage' | 'unstage' | 'discard') => void; } -const InlineDiffViewer = React.memo(({ - filePath, - diff, - renderSideBySide, - wrapLines, +const InlineDiffViewer = React.memo(({ + filePath, + diff, + renderSideBySide, + wrapLines, + directory, + staged, + onHunkApplied, }) => { - const language = React.useMemo( - () => getLanguageFromExtension(filePath) || 'text', - [filePath] - ); + const language = React.useMemo( + () => getLanguageFromExtension(filePath) || 'text', + [filePath] + ); - if (diff.isBinary) { - return ; - } + if (diff.isBinary) { + return ; + } - if (isImageFile(filePath)) { - return ( + if (isImageFile(filePath)) { + return ( - ); - } - - return ( -
- -
); + } + + return ( +
+ {diff.patch && diff.fileDiff ? ( + + ) : null} + +
+ ); }); interface MultiFileDiffEntryProps { @@ -573,6 +590,7 @@ const MultiFileDiffEntry = React.memo(({ }, [directory, file.path]) ); const setDiff = useGitStore((state) => state.setDiff); + const fetchStatus = useGitStore((state) => state.fetchStatus); const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout); const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); @@ -693,6 +711,13 @@ const MultiFileDiffEntry = React.memo(({ handleSelect(); }, [handleOpenChange, handleSelect, isExpanded]); + const handleHunkApplied = React.useCallback(() => { + setDiffRetryNonce((nonce) => nonce + 1); + if (directory) { + void fetchStatus(directory, git); + } + }, [directory, fetchStatus, git]); + return (
@@ -847,6 +872,9 @@ const MultiFileDiffEntry = React.memo(({ diff={diffData} renderSideBySide={renderSideBySide} wrapLines={wrapLines} + directory={directory} + staged={staged} + onHunkApplied={handleHunkApplied} /> ) : null}
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 1351a913..7a648776 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -473,6 +473,9 @@ export interface GitAPI { stageGitFiles?(directory: string, filePaths: string[]): Promise; unstageGitFile(directory: string, filePath: string): Promise; unstageGitFiles?(directory: string, filePaths: string[]): Promise; + stageGitHunk?(directory: string, filePath: string, patch: string): Promise; + unstageGitHunk?(directory: string, filePath: string, patch: string): Promise; + revertGitHunk?(directory: string, filePath: string, patch: string): Promise; isLinkedWorktree(directory: string): Promise; getGitBranches(directory: string): Promise; deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>; diff --git a/packages/ui/src/lib/diff/patchFileDiff.test.ts b/packages/ui/src/lib/diff/patchFileDiff.test.ts new file mode 100644 index 00000000..4a3ebea8 --- /dev/null +++ b/packages/ui/src/lib/diff/patchFileDiff.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { extractHunkPatch, splitPatchIntoHunks } from "./patchFileDiff"; + +const SAMPLE_PATCH = `diff --git a/foo.txt b/foo.txt +index 1111111..2222222 100644 +--- a/foo.txt ++++ b/foo.txt +@@ -1,4 +1,5 @@ + line1 ++added-top + line2 + line3 + line4 +@@ -10,3 +11,4 @@ + line10 +-deleted-mid + line11 ++added-bottom +`; + +describe("splitPatchIntoHunks", () => { + test("splits a multi-hunk patch into standalone per-hunk patches", () => { + const hunks = splitPatchIntoHunks(SAMPLE_PATCH); + expect(hunks.length).toBe(2); + + expect(hunks[0]).toContain("diff --git a/foo.txt b/foo.txt"); + expect(hunks[0]).toContain("--- a/foo.txt"); + expect(hunks[0]).toContain("+++ b/foo.txt"); + expect(hunks[0]).toContain("@@ -1,4 +1,5 @@"); + expect(hunks[0]).toContain("+added-top"); + expect(hunks[0]).not.toContain("@@ -10,3 +11,4 @@"); + expect(hunks[0]).not.toContain("added-bottom"); + + expect(hunks[1]).toContain("@@ -10,3 +11,4 @@"); + expect(hunks[1]).toContain("-deleted-mid"); + expect(hunks[1]).toContain("+added-bottom"); + expect(hunks[1]).not.toContain("added-top"); + }); + + test("each hunk keeps the file header so it applies on its own", () => { + const hunks = splitPatchIntoHunks(SAMPLE_PATCH); + for (const hunk of hunks) { + expect(hunk.startsWith("diff --git a/foo.txt b/foo.txt\n")).toBe(true); + expect(hunk.match(/^--- a\/foo.txt$/m)).not.toBeNull(); + expect(hunk.match(/^\+\+\+ b\/foo.txt$/m)).not.toBeNull(); + expect(hunk.match(/^@@\s/m)).not.toBeNull(); + } + }); + + test("returns [] for an empty patch or a patch without hunks", () => { + expect(splitPatchIntoHunks("")).toEqual([]); + expect(splitPatchIntoHunks("diff --git a/foo b/foo\n--- a/foo\n+++ b/foo\n")).toEqual([]); + }); + + test("handles a single-hunk patch", () => { + const single = `diff --git a/a b/a +--- a/a ++++ b/a +@@ -1,1 +1,2 @@ + a ++b +`; + const hunks = splitPatchIntoHunks(single); + expect(hunks.length).toBe(1); + expect(hunks[0]).toContain("+b"); + }); +}); + +describe("extractHunkPatch", () => { + test("returns the standalone patch for the requested index", () => { + const second = extractHunkPatch(SAMPLE_PATCH, 1); + expect(second).not.toBeNull(); + expect(second).toContain("@@ -10,3 +11,4 @@"); + expect(second).toContain("diff --git a/foo.txt b/foo.txt"); + }); + + test("returns null for out-of-range or invalid indices", () => { + expect(extractHunkPatch(SAMPLE_PATCH, -1)).toBeNull(); + expect(extractHunkPatch(SAMPLE_PATCH, 2)).toBeNull(); + expect(extractHunkPatch(SAMPLE_PATCH, 1.5)).toBeNull(); + expect(extractHunkPatch("", 0)).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/diff/patchFileDiff.ts b/packages/ui/src/lib/diff/patchFileDiff.ts index 85cb3c6f..263ec1c7 100644 --- a/packages/ui/src/lib/diff/patchFileDiff.ts +++ b/packages/ui/src/lib/diff/patchFileDiff.ts @@ -142,3 +142,54 @@ const joinPatchLines = (lines: Array<{ text: string; newline: boolean }>): strin const emptyFileDiff = (file: string): FileDiffMetadata => parseDiffFromFile({ name: file, contents: '' }, { name: file, contents: '' }); + +/** + * Split a unified diff patch for a single file into standalone per-hunk patches. + * + * Each returned patch preserves the original file header (everything before the + * first `@@` hunk header) plus exactly one hunk, producing a patch that can be + * fed to `git apply` on its own. + * + * Returns an empty array when no hunk headers are present. + */ +export const splitPatchIntoHunks = (patch: string): string[] => { + if (!patch) return []; + + const lines = patch.split(/\r?\n/); + const hunkHeaderRegex = /^@@\s/; + const headerLines: string[] = []; + let firstHunk = 0; + while (firstHunk < lines.length && !hunkHeaderRegex.test(lines[firstHunk] ?? '')) { + headerLines.push(lines[firstHunk]); + firstHunk += 1; + } + + if (firstHunk >= lines.length) { + return []; + } + + const hunks: string[][] = []; + for (let index = firstHunk; index < lines.length; index += 1) { + const line = lines[index]; + if (hunkHeaderRegex.test(line ?? '')) { + hunks.push([...headerLines, line]); + } else if (hunks.length > 0) { + hunks[hunks.length - 1].push(line ?? ''); + } + } + + return hunks.map((hunkLines) => hunkLines.join('\n')) + .filter((hunk) => hunk.trim().length > 0) + .map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`)); +}; + +/** + * Extract a standalone patch for a single hunk by zero-based index. + * + * Returns `null` when the index is out of range or the patch has no hunks. + */ +export const extractHunkPatch = (patch: string, hunkIndex: number): string | null => { + if (!Number.isInteger(hunkIndex) || hunkIndex < 0) return null; + const hunks = splitPatchIntoHunks(patch); + return hunks[hunkIndex] ?? null; +}; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index c584767d..e2c36895 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -168,6 +168,24 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P return gitHttp.unstageGitFiles(directory, filePaths); } +export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise { + const runtime = getRuntimeGit(); + if (runtime?.stageGitHunk) return runtime.stageGitHunk(directory, filePath, patch); + return gitHttp.stageGitHunk(directory, filePath, patch); +} + +export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise { + const runtime = getRuntimeGit(); + if (runtime?.unstageGitHunk) return runtime.unstageGitHunk(directory, filePath, patch); + return gitHttp.unstageGitHunk(directory, filePath, patch); +} + +export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise { + const runtime = getRuntimeGit(); + if (runtime?.revertGitHunk) return runtime.revertGitHunk(directory, filePath, patch); + return gitHttp.revertGitHunk(directory, filePath, patch); +} + export async function isLinkedWorktree(directory: string): Promise { const runtime = getRuntimeGit(); if (runtime) return runtime.isLinkedWorktree(directory); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index f4a5a5fb..adfa6830 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -289,6 +289,43 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P } } +export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise { + await applyGitHunk(directory, filePath, patch, 'stage'); +} + +export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise { + await applyGitHunk(directory, filePath, patch, 'unstage'); +} + +export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise { + await applyGitHunk(directory, filePath, patch, 'discard'); +} + +async function applyGitHunk( + directory: string, + filePath: string, + patch: string, + action: 'stage' | 'unstage' | 'discard', +): Promise { + if (!filePath) { + throw new Error('path is required to apply a git hunk'); + } + if (typeof patch !== 'string' || !patch.trim()) { + throw new Error('patch is required to apply a git hunk'); + } + + const response = await runtimeFetch(buildUrl(`${API_BASE}/apply-hunk`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath, patch, action }), + }); + + if (!response.ok) { + const message = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(message.error || 'Failed to apply git hunk'); + } +} + export async function isLinkedWorktree(directory: string): Promise { if (!directory) { return false; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index ca48034d..78f8ed1a 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1215,6 +1215,15 @@ export const dict = { 'diffView.actions.enableLineWrap': 'Enable line wrap', 'diffView.actions.openFileInEditorAtChange': 'Open this file in editor at change', 'diffView.actions.openFileAtFirstChangedLine': 'Open this file at first changed line', + 'diffView.hunk.label': 'Hunks', + 'diffView.hunk.stage': 'Stage', + 'diffView.hunk.unstage': 'Unstage', + 'diffView.hunk.discard': 'Discard', + 'diffView.hunk.stageTitle': 'Stage hunk {index}', + 'diffView.hunk.unstageTitle': 'Unstage hunk {index}', + 'diffView.hunk.discardTitle': 'Discard hunk {index}', + 'diffView.hunk.unavailable': 'This hunk is no longer available. Refresh the diff and try again.', + 'diffView.hunk.unsupported': 'Staging individual hunks is not supported in this runtime.', 'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan', 'rightSidebar.contextNotesTodo.empty.selectProject': 'Select a project to add notes and todos.', 'rightSidebar.contextNotesTodo.notes.title': 'Quick notes - {project}', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 3f0dfc38..931e3ef0 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1181,6 +1181,15 @@ export const dict: Record = { "diffView.actions.enableLineWrap": "Activar ajuste de línea", "diffView.actions.openFileInEditorAtChange": "Abrir este archivo en el editor en el cambio", "diffView.actions.openFileAtFirstChangedLine": "Abrir este archivo en la primera línea modificada", + "diffView.hunk.label": "Fragmentos", + "diffView.hunk.stage": "Preparar", + "diffView.hunk.unstage": "Quitar", + "diffView.hunk.discard": "Descartar", + "diffView.hunk.stageTitle": "Preparar fragmento {index}", + "diffView.hunk.unstageTitle": "Quitar fragmento {index} de la preparación", + "diffView.hunk.discardTitle": "Descartar fragmento {index}", + "diffView.hunk.unavailable": "Este fragmento ya no está disponible. Actualice el diff e inténtelo de nuevo.", + "diffView.hunk.unsupported": "Preparar fragmentos individuales no es compatible en este entorno.", "rightSidebar.contextNotesTodo.plan.defaultTitle": "Plan", "rightSidebar.contextNotesTodo.empty.selectProject": "Selecciona un proyecto para añadir notas y tareas pendientes.", "rightSidebar.contextNotesTodo.notes.title": "Notas rápidas — {project}", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 92bfe923..64221666 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1088,6 +1088,15 @@ export const dict = { 'diffView.actions.enableLineWrap': 'Activer le retour à la ligne', 'diffView.actions.openFileInEditorAtChange': 'Ouvrez ce fichier dans l\'éditeur lors du changement', 'diffView.actions.openFileAtFirstChangedLine': 'Ouvrez ce fichier à la première ligne modifiée', + 'diffView.hunk.label': 'Sections', + 'diffView.hunk.stage': 'Préparer', + 'diffView.hunk.unstage': 'Retirer', + 'diffView.hunk.discard': 'Annuler', + 'diffView.hunk.stageTitle': 'Préparer la section {index}', + 'diffView.hunk.unstageTitle': 'Retirer la section {index} de la préparation', + 'diffView.hunk.discardTitle': 'Annuler la section {index}', + 'diffView.hunk.unavailable': "Cette section n'est plus disponible. Actualisez le diff et réessayez.", + 'diffView.hunk.unsupported': "La préparation de sections individuelles n'est pas prise en charge dans cet environnement.", 'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan', 'rightSidebar.contextNotesTodo.empty.selectProject': 'Sélectionnez un projet pour ajouter des notes et des tâches.', 'rightSidebar.contextNotesTodo.notes.title': 'Notes rapides - {project}', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 7b41e83f..07b33e96 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1218,6 +1218,15 @@ export const dict: Record = { 'diffView.actions.enableLineWrap': '줄 바꿈 켜기', 'diffView.actions.openFileInEditorAtChange': '변경 위치에서 이 파일을 에디터로 열기', 'diffView.actions.openFileAtFirstChangedLine': '첫 변경 줄에서 이 파일 열기', + 'diffView.hunk.label': '허크', + 'diffView.hunk.stage': '스테이지', + 'diffView.hunk.unstage': '스테이지 해제', + 'diffView.hunk.discard': '취소', + 'diffView.hunk.stageTitle': '허크 {index} 스테이지', + 'diffView.hunk.unstageTitle': '허크 {index} 스테이지 해제', + 'diffView.hunk.discardTitle': '허크 {index} 취소', + 'diffView.hunk.unavailable': '이 허크는 더 이상 사용할 수 없습니다. diff를 새로고침 후 다시 시도하세요.', + 'diffView.hunk.unsupported': '개별 허크 스테이징은 이 환경에서 지원되지 않습니다.', 'rightSidebar.contextNotesTodo.plan.defaultTitle': '플랜', 'rightSidebar.contextNotesTodo.empty.selectProject': '메모와 Todo를 추가할 프로젝트를 선택하세요.', 'rightSidebar.contextNotesTodo.notes.title': '빠른 메모 - {project}', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8ba97414..91abcbfa 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1415,6 +1415,15 @@ export const dict: Record = { 'diffView.actions.loadFullFiles': 'Wczytaj pełne pliki', 'diffView.actions.disableFullFiles': 'Nie wczytuj pełnych plików', 'diffView.actions.openFileAtFirstChangedLine': 'Otwórz plik na pierwszej zmienionej linii', + 'diffView.hunk.label': 'Fragmenty', + 'diffView.hunk.stage': 'Przygotuj', + 'diffView.hunk.unstage': 'Cofnij', + 'diffView.hunk.discard': 'Odrzuć', + 'diffView.hunk.stageTitle': 'Przygotuj fragment {index}', + 'diffView.hunk.unstageTitle': 'Cofnij przygotowanie fragmentu {index}', + 'diffView.hunk.discardTitle': 'Odrzuć fragment {index}', + 'diffView.hunk.unavailable': 'Ten fragment nie jest już dostępny. Odśwież diff i spróbuj ponownie.', + 'diffView.hunk.unsupported': 'Przygotowywanie pojedynczych fragmentów nie jest obsługiwane w tym środowisku.', 'diffView.actions.openFileInEditorAtChange': 'Otwórz plik w edytorze na zmianie', 'diffView.actions.renderAnyway': 'Renderuj mimo to', 'diffView.actions.retry': 'Ponów', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index b86430ca..db35d4f9 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1181,6 +1181,15 @@ export const dict: Record = { "diffView.actions.enableLineWrap": "Ativar ajuste de linha", "diffView.actions.openFileInEditorAtChange": "Abrir este arquivo no editor nesta alteração", "diffView.actions.openFileAtFirstChangedLine": "Abrir este arquivo na primeira linha alterada", + "diffView.hunk.label": "Trechos", + "diffView.hunk.stage": "Preparar", + "diffView.hunk.unstage": "Remover", + "diffView.hunk.discard": "Descartar", + "diffView.hunk.stageTitle": "Preparar trecho {index}", + "diffView.hunk.unstageTitle": "Remover trecho {index} da preparação", + "diffView.hunk.discardTitle": "Descartar trecho {index}", + "diffView.hunk.unavailable": "Este trecho não está mais disponível. Atualize o diff e tente novamente.", + "diffView.hunk.unsupported": "A preparação de trechos individuais não é suportada neste ambiente.", "rightSidebar.contextNotesTodo.plan.defaultTitle": "Plano", "rightSidebar.contextNotesTodo.empty.selectProject": "Selecione um projeto para adicionar notas e tarefas pendentes.", "rightSidebar.contextNotesTodo.notes.title": "Notas rápidas — {project}", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c6ab56b1..c0bec51b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1181,6 +1181,15 @@ export const dict: Record = { "diffView.actions.enableLineWrap": "Увімкнути перенос рядків", "diffView.actions.openFileInEditorAtChange": "Відкрити цей файл у редакторі на зміні", "diffView.actions.openFileAtFirstChangedLine": "Відкрити цей файл у першому зміненому рядку", + "diffView.hunk.label": "Шматки", + "diffView.hunk.stage": "Додати", + "diffView.hunk.unstage": "Прибрати", + "diffView.hunk.discard": "Відкинути", + "diffView.hunk.stageTitle": "Додати шматок {index} до індексу", + "diffView.hunk.unstageTitle": "Прибрати шматок {index} з індексу", + "diffView.hunk.discardTitle": "Відкинути шматок {index}", + "diffView.hunk.unavailable": "Цей шматок більше недоступний. Оновіть diff і спробуйте знову.", + "diffView.hunk.unsupported": "Додавання окремих шматків до індексу не підтримується в цьому середовищі.", "rightSidebar.contextNotesTodo.plan.defaultTitle": "План", "rightSidebar.contextNotesTodo.empty.selectProject": "Виберіть проєкт, щоб додати нотатки та завдання.", "rightSidebar.contextNotesTodo.notes.title": "Швидкі нотатки - {project}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 25968a68..f02b0d90 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1181,6 +1181,15 @@ export const dict: Record = { 'diffView.actions.enableLineWrap': '开启自动换行', 'diffView.actions.openFileInEditorAtChange': '在编辑器中打开此文件并定位变更', 'diffView.actions.openFileAtFirstChangedLine': '在首个变更行打开此文件', + 'diffView.hunk.label': '代码块', + 'diffView.hunk.stage': '暂存', + 'diffView.hunk.unstage': '取消暂存', + 'diffView.hunk.discard': '放弃', + 'diffView.hunk.stageTitle': '暂存代码块 {index}', + 'diffView.hunk.unstageTitle': '取消暂存代码块 {index}', + 'diffView.hunk.discardTitle': '放弃代码块 {index}', + 'diffView.hunk.unavailable': '此代码块已不可用。请刷新差异后重试。', + 'diffView.hunk.unsupported': '此运行环境不支持暂存单个代码块。', 'rightSidebar.contextNotesTodo.plan.defaultTitle': '计划', 'rightSidebar.contextNotesTodo.empty.selectProject': '请选择一个项目以添加笔记和待办事项。', 'rightSidebar.contextNotesTodo.notes.title': '快速笔记 - {project}', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index d54125fa..ddf0c830 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1191,6 +1191,15 @@ export const dict: Record = { 'diffView.actions.enableLineWrap': '開啟自動換行', 'diffView.actions.openFileInEditorAtChange': '在編輯器中開啟此檔案並定位變更', 'diffView.actions.openFileAtFirstChangedLine': '在首個變更行開啟此檔案', + 'diffView.hunk.label': '程式碼區塊', + 'diffView.hunk.stage': '暫存', + 'diffView.hunk.unstage': '取消暫存', + 'diffView.hunk.discard': '捨棄', + 'diffView.hunk.stageTitle': '暫存程式碼區塊 {index}', + 'diffView.hunk.unstageTitle': '取消暫存程式碼區塊 {index}', + 'diffView.hunk.discardTitle': '捨棄程式碼區塊 {index}', + 'diffView.hunk.unavailable': '此程式碼區塊已不可用。請重新整理差異後重試。', + 'diffView.hunk.unsupported': '此執行環境不支援暫存個別程式碼區塊。', 'rightSidebar.contextNotesTodo.plan.defaultTitle': '計畫', 'rightSidebar.contextNotesTodo.empty.selectProject': '請選擇一個專案以新增筆記和待辦事項。', 'rightSidebar.contextNotesTodo.notes.title': '快速筆記 - {project}', diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts index e02cd5d4..9cba677a 100644 --- a/packages/vscode/src/bridge-git-runtime.ts +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -251,6 +251,23 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput return { id, type, success: true, data: { success: true } }; } + case 'api:git/apply-hunk': { + const { directory, path: filePath, patch, action } = (payload || {}) as { + directory?: string; + path?: string; + patch?: string; + action?: 'stage' | 'unstage' | 'discard'; + }; + if (!directory || !filePath || typeof patch !== 'string' || !patch.trim()) { + return { id, type, success: false, error: 'Directory, path, and patch are required' }; + } + if (action !== 'stage' && action !== 'unstage' && action !== 'discard') { + return { id, type, success: false, error: 'action must be stage, unstage, or discard' }; + } + await gitService.applyGitHunk(directory, filePath, patch, action); + return { id, type, success: true, data: { success: true } }; + } + case 'api:git/commit': { const { directory, message, addAll, files, stageFiles } = (payload || {}) as { directory?: string; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index af887a09..8ef2c0c2 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -2321,6 +2321,58 @@ export async function unstageGitFiles(directory: string, filePaths: string[]): P } } +const HUNK_ACTION_ARGS: Record<'stage' | 'unstage' | 'discard', string[]> = { + stage: ['--cached'], + unstage: ['--cached', '--reverse'], + discard: ['--reverse'], +}; + +/** + * Apply a single-hunk patch to stage, unstage, or discard it. + * The patch is written to a temp file and applied with `git apply`. + */ +export async function applyGitHunk( + directory: string, + filePath: string, + patch: string, + action: 'stage' | 'unstage' | 'discard', +): Promise { + if (!filePath) { + throw new Error('path is required'); + } + if (typeof patch !== 'string' || !patch.trim()) { + throw new Error('patch is required'); + } + if (!/^@@\s/m.test(patch)) { + throw new Error('patch does not contain a hunk header'); + } + + const flags = HUNK_ACTION_ARGS[action]; + const tmpDir = os.tmpdir(); + const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`); + + try { + await fs.promises.writeFile(tmpPath, patch, 'utf8'); + + const check = await execGit(['apply', ...flags, '--check', tmpPath], directory); + if (check.exitCode !== 0) { + const detail = (check.stderr || '').trim(); + throw new Error( + detail + ? `Hunk no longer applies — refresh and try again.\n${detail}` + : 'Hunk no longer applies — refresh and try again.' + ); + } + + const apply = await execGit(['apply', ...flags, tmpPath], directory); + if (apply.exitCode !== 0) { + throw new Error(apply.stderr || 'Failed to apply git hunk'); + } + } finally { + await fs.promises.rm(tmpPath, { force: true }).catch(() => {}); + } +} + // ============== Commit Operations ============== export interface GitCommitResult { diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 0a4479ba..4ade53d5 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -87,6 +87,18 @@ export const createVSCodeGitAPI = (): GitAPI => ({ await sendBridgeMessage('api:git/unstage', { directory, paths: filePaths }); }, + stageGitHunk: async (directory: string, filePath: string, patch: string): Promise => { + await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'stage' }); + }, + + unstageGitHunk: async (directory: string, filePath: string, patch: string): Promise => { + await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'unstage' }); + }, + + revertGitHunk: async (directory: string, filePath: string, patch: string): Promise => { + await sendBridgeMessage('api:git/apply-hunk', { directory, path: filePath, patch, action: 'discard' }); + }, + isLinkedWorktree: async (directory: string): Promise => { return sendBridgeMessage('api:git/worktree-type', { directory }); }, diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 31d3be6d..4fb14dda 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -33,6 +33,7 @@ The following functions are exported and used by the web server: - `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes. - `stageFile(directory, filePath)`: Add one file path to the index. - `unstageFile(directory, filePath)`: Remove one file path from the index while preserving working-tree content. +- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). The patch is written to a temp file; a `--check` runs first so a stale hunk fails with a clear "refresh and try again" error instead of a partial mutation. The patch target path must match the requested file. ### Branch Operations - `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches). diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index c6b19796..25fd5bba 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -436,6 +436,33 @@ export function registerGitRoutes(app) { } }); + app.post('/api/git/apply-hunk', async (req, res) => { + const { applyHunk } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { path: filePath, patch, action } = req.body || {}; + if (!filePath || typeof filePath !== 'string') { + return res.status(400).json({ error: 'path parameter is required' }); + } + if (typeof patch !== 'string' || !patch.trim()) { + return res.status(400).json({ error: 'patch is required' }); + } + if (action !== 'stage' && action !== 'unstage' && action !== 'discard') { + return res.status(400).json({ error: 'action must be stage, unstage, or discard' }); + } + + await applyHunk(directory, filePath, { patch, action }); + res.json({ success: true }); + } catch (error) { + console.error('Failed to apply git hunk:', error); + res.status(500).json({ error: error.message || 'Failed to apply git hunk' }); + } + }); + app.post('/api/git/pull', async (req, res) => { const { pull } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index eba6ff29..fe2899a4 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -2555,6 +2555,75 @@ export async function revertFile(directory, filePath, options = {}) { }); } +const HUNK_ACTION_FLAGS = { + stage: ['--cached'], + unstage: ['--cached', '--reverse'], + discard: ['--reverse'], +}; + +const extractPatchTargetPath = (patch) => { + const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+(?:[ab]\/)?([^\s\t]+)/gm)]; + const realTargets = matches + .map((match) => match[1]) + .filter((value) => value && value !== '/dev/null'); + return realTargets[0] || null; +}; + +const writeTempPatchFile = async (patch) => { + const tmpDir = os.tmpdir(); + const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`); + await fsp.writeFile(tmpPath, patch, 'utf8'); + return tmpPath; +}; + +export async function applyHunk(directory, filePath, options = {}) { + const action = options?.action; + if (!action || !HUNK_ACTION_FLAGS[action]) { + throw new Error('Invalid hunk action'); + } + const patch = typeof options?.patch === 'string' ? options.patch : ''; + if (!patch.trim()) { + throw new Error('patch is required to apply a hunk'); + } + if (!/^@@\s/m.test(patch)) { + throw new Error('patch does not contain a hunk header'); + } + + return withGitIndexMutationQueue(directory, async () => { + const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); + const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot); + validateRepositoryFilePaths(repoRoot, [fileContext.repoPath]); + + const targetPath = extractPatchTargetPath(patch); + if (targetPath && targetPath !== fileContext.repoPath && targetPath !== filePath) { + throw new Error('patch target path does not match the requested file'); + } + + const flags = HUNK_ACTION_FLAGS[action]; + let tmpPath = null; + try { + tmpPath = await writeTempPatchFile(patch); + + try { + await git.raw(['apply', ...flags, '--check', tmpPath]); + } catch (checkError) { + const text = parseGitErrorText(checkError); + throw new Error( + text + ? `Hunk no longer applies — refresh and try again.\n${text}` + : 'Hunk no longer applies — refresh and try again.' + ); + } + + await git.raw(['apply', ...flags, tmpPath]); + } finally { + if (tmpPath) { + await fsp.rm(tmpPath, { force: true }).catch(() => {}); + } + } + }); +} + export async function collectDiffs(directory, files = []) { const results = []; for (const filePath of files) { diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index e1ce0e6c..e38e100b 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -17,6 +17,8 @@ import { revertCommit, stageFiles, unstageFiles, + applyHunk, + getDiff, } from './service.js'; // --------------------------------------------------------------------------- @@ -122,6 +124,124 @@ describe('git index path validation', () => { }); }); +// --------------------------------------------------------------------------- +// applyHunk (per-hunk stage / unstage / discard) +// --------------------------------------------------------------------------- + +/** Minimal unified-diff splitter: returns standalone per-hunk patches. */ +const splitHunks = (patch) => { + const lines = patch.split(/\r?\n/); + const headerEnd = lines.findIndex((line) => /^@@\s/.test(line)); + if (headerEnd === -1) return []; + const header = lines.slice(0, headerEnd); + const hunks = []; + for (let i = headerEnd; i < lines.length; i += 1) { + const line = lines[i]; + if (/^@@\s/.test(line)) hunks.push([...header, line]); + else if (hunks.length > 0) hunks[hunks.length - 1].push(line); + } + return hunks.map((hunk) => hunk.join('\n')) + .filter((hunk) => hunk.trim().length > 0) + .map((hunk) => (hunk.endsWith('\n') ? hunk : `${hunk}\n`)); +}; + +const writeFile = (repo, name, contents) => + fs.promises.writeFile(path.join(repo, name), contents, 'utf8'); + +// Build a 20-line file so changes on line 1 and line 20 stay in separate hunks +// (default 3-line diff context would merge closer edits into one hunk). +const makeFile = (first, last) => + [first, ...Array.from({ length: 18 }, (_, i) => `line${i + 2}`), last].join('\n') + '\n'; +const ORIGINAL_FILE = makeFile('line1', 'line20'); +const EDITED_FILE = makeFile('TOP', 'BOTTOM'); + +const readWorking = (repo) => fs.promises.readFile(path.join(repo, 'file.txt'), 'utf8').then((c) => c.replace(/\r\n/g, '\n')); +const readStaged = async (git) => (await git.raw(['show', ':file.txt'])).replace(/\r\n/g, '\n'); + +describe('applyHunk', () => { + it('rejects an invalid action or a patch without a hunk header', async () => { + const { tmpDir } = await createTempRepo(); + await expect(applyHunk(tmpDir, 'file.txt', { patch: '@@ -1 +1 @@\n a\n', action: 'bogus' })).rejects.toThrow( + 'Invalid hunk action' + ); + await expect(applyHunk(tmpDir, 'file.txt', { patch: 'no hunk here', action: 'stage' })).rejects.toThrow( + 'hunk header' + ); + }); + + it('stages a single hunk while leaving the rest unstaged', async () => { + if (!canRunGit()) return; + const { tmpDir, git } = await createTempRepo(); + await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE); + await git.add('file.txt'); + await git.commit('Initial'); + + await writeFile(tmpDir, 'file.txt', EDITED_FILE); + const diff = await getDiff(tmpDir, { path: 'file.txt' }); + const hunks = splitHunks(diff); + expect(hunks.length).toBe(2); + + await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'stage' }); + + expect(await readStaged(git)).toBe(makeFile('TOP', 'line20')); + expect(await readWorking(tmpDir)).toBe(EDITED_FILE); + }); + + it('discards a single hunk from the working tree', async () => { + if (!canRunGit()) return; + const { tmpDir, git } = await createTempRepo(); + await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE); + await git.add('file.txt'); + await git.commit('Initial'); + + await writeFile(tmpDir, 'file.txt', EDITED_FILE); + const diff = await getDiff(tmpDir, { path: 'file.txt' }); + const hunks = splitHunks(diff); + expect(hunks.length).toBe(2); + + await applyHunk(tmpDir, 'file.txt', { patch: hunks[1], action: 'discard' }); + + expect(await readWorking(tmpDir)).toBe(makeFile('TOP', 'line20')); + }); + + it('unstages a single hunk from the index', async () => { + if (!canRunGit()) return; + const { tmpDir, git } = await createTempRepo(); + await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE); + await git.add('file.txt'); + await git.commit('Initial'); + + await writeFile(tmpDir, 'file.txt', EDITED_FILE); + await git.add('file.txt'); + + const stagedDiff = await getDiff(tmpDir, { path: 'file.txt', staged: true }); + const hunks = splitHunks(stagedDiff); + expect(hunks.length).toBe(2); + + await applyHunk(tmpDir, 'file.txt', { patch: hunks[0], action: 'unstage' }); + + // Only the first hunk (line1 -> TOP) was reverted in the index; + // the second hunk (BOTTOM) stays staged. + expect(await readStaged(git)).toBe(makeFile('line1', 'BOTTOM')); + }); + + it('rejects a patch whose target path does not match the requested file', async () => { + if (!canRunGit()) return; + const { tmpDir, git } = await createTempRepo(); + await writeFile(tmpDir, 'file.txt', ORIGINAL_FILE); + await git.add('file.txt'); + await git.commit('Initial'); + await writeFile(tmpDir, 'file.txt', makeFile('CHANGED', 'line20')); + + const diff = await getDiff(tmpDir, { path: 'file.txt' }); + const [hunk] = splitHunks(diff); + const retargeted = hunk.replace(/file\.txt/g, 'other.txt'); + await expect(applyHunk(tmpDir, 'file.txt', { patch: retargeted, action: 'stage' })).rejects.toThrow( + 'patch target path does not match' + ); + }); +}); + // --------------------------------------------------------------------------- // getStatus // --------------------------------------------------------------------------- diff --git a/packages/web/src/api/git.test.ts b/packages/web/src/api/git.test.ts index ae41a475..8fa3bee9 100644 --- a/packages/web/src/api/git.test.ts +++ b/packages/web/src/api/git.test.ts @@ -10,6 +10,9 @@ vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({ stageGitFiles: vi.fn(), unstageGitFile: vi.fn(), unstageGitFiles: vi.fn(), + stageGitHunk: vi.fn(), + unstageGitHunk: vi.fn(), + revertGitHunk: vi.fn(), isLinkedWorktree: vi.fn(), getGitBranches: vi.fn(), deleteGitBranch: vi.fn(), @@ -55,6 +58,16 @@ vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({ stash: vi.fn(), stashPop: vi.fn(), getConflictDetails: vi.fn(), + checkoutCommit: vi.fn(), + cherryPick: vi.fn(), + revertCommit: vi.fn(), + resetToCommit: vi.fn(), + getCommitFileDiff: vi.fn(), + previewGitWorktree: vi.fn(), + getGitWorktreeBootstrapStatus: vi.fn(), + discoverGitCredentials: vi.fn(), + getGlobalGitIdentity: vi.fn(), + getRemoteUrl: vi.fn(), })); describe('createWebGitAPI', () => { @@ -64,5 +77,8 @@ describe('createWebGitAPI', () => { expect(typeof api.stageGitFiles).toBe('function'); expect(typeof api.unstageGitFiles).toBe('function'); + expect(typeof api.stageGitHunk).toBe('function'); + expect(typeof api.unstageGitHunk).toBe('function'); + expect(typeof api.revertGitHunk).toBe('function'); }); }); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 76fc85ee..41d57577 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -15,6 +15,9 @@ export const createWebGitAPI = (): GitAPI => ({ stageGitFiles: gitApiHttp.stageGitFiles, unstageGitFile: gitApiHttp.unstageGitFile, unstageGitFiles: gitApiHttp.unstageGitFiles, + stageGitHunk: gitApiHttp.stageGitHunk, + unstageGitHunk: gitApiHttp.unstageGitHunk, + revertGitHunk: gitApiHttp.revertGitHunk, isLinkedWorktree: gitApiHttp.isLinkedWorktree, getGitBranches: gitApiHttp.getGitBranches, deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],