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].
This commit is contained in:
Bohdan Triapitsyn
2026-06-14 10:58:23 +03:00
parent 1c281df3b7
commit f645d57c93
26 changed files with 811 additions and 34 deletions
+16
View File
@@ -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');
});
});
+3
View File
@@ -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'],