feat(diff): apply and revert hunks, lines and files from outside the working tree - #69
Merged
Merged
Conversation
adehad
force-pushed
the
feat/diff-apply-revert
branch
from
August 3, 2026 12:12
51ee8ae to
cde0d4f
Compare
Historical diffs are inert. PR noahbclarkson#65 stopped a commit diff pretending to be unstaged working-tree content, which closed the hazard but left the other half of the bug: there is still nothing you can do with a hunk you are looking at in another commit, stash, or branch. Sublime Merge's answer is to write a .patch and apply it; git's is `git apply` / `git apply -R`. Add that operation at the git layer. `apply_worktree_patch` takes a file, a `WorktreePatchSource`, a `WorktreePatchScope` (whole file, one hunk, or a line selection) and a direction, and rewrites that file on disk. The source models a *pair of revisions*, not a commit. `Commit(oid)` resolves to first-parent → itself and `Compare { from, to }` to any two revparse-able revisions, and both then travel identical code. Applying across branches is therefore the same operation as applying a commit's hunk, generated from the correct tree pair — not from index→workdir, which is what would make a cross-branch apply silently target the wrong content. Neither obvious applier works here: * `repo.apply(.., ApplyLocation::WorkDir, ..)` matches hunk context literally and has no three-way fallback. Any uncommitted edit inside a hunk's context window defeats it, and a dirty working tree is the normal case for this feature — you compare against another branch precisely because you are mid-change. `libgit2s_own_apply_refuses_the_case_the_merge_handles` pins that. * `git apply --3way` merges into the *index* and refuses with "does not match index" whenever the working-tree file differs from its index entry — exactly the dirty tree the fallback exists for. It also leaves conflict markers and an unmerged index entry behind. So reconstruct both sides and let libgit2 merge them: `base` is the file on the starting side, `target` is `base` with just the selected hunk or lines rewritten (computed from the diff, so exact by construction), and `ours` is the working tree. A three-way merge of the three keeps unrelated local edits and conflicts only on genuine overlap, without touching the index. The rewrite itself is pure and unit-tested; within a run of adjacent -/+ lines the i-th removal pairs with the i-th insertion, matching the side-by-side view, because reading git's removals-then- insertions order literally puts a kept line on the wrong side of an applied one. Three failure modes get three messages: already applied (the merge folds back to what is on disk), a conflicting local edit (the merge conflicts and the file is dirty), and a context mismatch (the merge conflicts and the file is clean, so the file itself has moved on). Nothing is written in any of them. Unlike staging, these operations edit files on disk, so each returns a byte snapshot of what it overwrote and emits `WorktreePatchApplied`; `restore_worktree_files_at` puts those bytes back. A snapshot is exact even when the forward operation went through a merge, which a reverse patch would not be. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR noahbclarkson#65 made a commit diff stop lying about being unstaged, and the `DiffSource` enum it introduced suppressed the staging affordances that did the damage. What it left behind is a diff you can look at and nothing else: no button, no menu. The user's case is comparing against another branch and pulling the difference into their files, so an inert panel is only half a fix. Generalise `staging_action()` into `DiffSource::operations()`, the one place that decides what a source supports. Stage for the working tree, Unstage for the index, and Apply/Revert for everything else — a commit, a stash entry, or the new `Compare { from, to }` variant, which is how a branch-comparison view would describe its content. No source returns an empty list, so none is inert, and `reject_operation` (was `reject_staging`) now covers all four operations from that same list rather than a second hand-written table. `Compare` exists even though no comparison UI does yet, because the alternative is worse: treat "the difference between two revisions" as a commit diff and the patch gets generated from the wrong pair of trees. `DiffSource::patch_source` resolves both it and `Commit` to the tree pair the git layer wants, so whenever a compare view lands the apply path already serves it correctly. Three granularities, following the rule staging already uses: * the hunk by default — one button per operation in every hunk header, unified and side-by-side, both routed through `DiffViewerEvent::for_hunk` so a button and a keystroke cannot disagree; * a manual line selection when the user has turned on partial mode; * the whole file, from a new "File" menu in the viewer header. The hunk headers cannot express whole-file and the sidebar's Staged/Unstaged lists only cover staging, so this is the only home for it — and it is why the menu carries apply/revert and nothing else. Partial mode now works on committed content. It was blocked because line-level staging was meaningless there; line-level *apply* is not. The staging route stays shut, which `partial_mode_on_a_commit_diff_still_refuses_to_stage` pins. A selection spanning several hunks becomes one line-scoped request rather than one request per hunk: unlike staging, these read a file off disk and write it back, so two in flight over the same file would race. `apply_selection`, `revert_selection`, `apply_file` and `revert_file` join the staging entry points as the viewer's public commands; the next commit declares the actions that dispatch to them. The view tests drive those methods rather than keystrokes for the reason the staging ones already do: the bindings live in `rgitui_workspace`, which sits above this crate. The workspace maps the request to `patch_worktree_at`, taking the tree pair from the displayed source, and rejects any request the source does not offer — the same backstop the staging path got, now needed in both directions because apply and revert write to disk. On success `WorktreePatchApplied` carries the overwritten bytes into a new `UndoAction::RestoreWorktreeFiles`, the first undo action that restores content instead of issuing a reversing git command; a reverse patch would not be exact after a three-way merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The apply/revert affordances arrived as buttons and a menu, which leaves
the feature mouse-only and absent from the shortcut reference. Declaring
them in the `commands!` registry is what puts them on a keystroke, in
`docs/KEYBINDINGS.md`, and under the user's control in `keymap.json`.
Four commands in the `diff` namespace, split the way staging already is —
selection-scoped and fixed-scope:
* `diff::ApplySelection` / `diff::RevertSelection` on `a` and `r`
(`shift-a` / `shift-r` too, matching `s`/`shift-s`), acting on the hunk
under the cursor, the hunks the row selection spans, or the selected
lines in partial mode.
* `diff::ApplyFile` / `diff::RevertFile` on `alt-a` and `alt-r`, the
whole file regardless of the selection. Alt for the fixed-scope
variant is the convention `alt-s`/`alt-u` already set.
Bare `a` and `r` are free in `DiffViewer && !modal && !TextInput`: the
viewer's own `a` binding is `secondary-a` for select-all, and the only
other bare `r` in the registry is the rebase editor's reword, which sits
behind `modal` and so never competes. They are also the letters that read
as apply and revert, and they are live exactly where `s` and `u` are inert.
`a` and `r` join the list in `ambiguous_letters_resolve_to_one_action_per_
context` and the ownership table in
`the_overloaded_letters_are_owned_by_the_expected_views`, so a later block
cannot take either letter without saying so.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The `worktree_patch` docs were written as a case for the design: "Why not `repo.apply(..)`", "Why not `git apply --3way`", what staging does by contrast, what stops a cross-branch apply being a special case. The next reader has no memory of the alternatives being weighed, so that framing is rot; the facts inside it are not — libgit2's apply has no three-way fallback, and `git apply --3way` merges into the index and refuses when the working tree differs from it are both non-obvious dependency behaviours someone would otherwise rediscover against a dirty working tree. Keep the constraints, drop the argument. Same treatment for `WorktreePatchSource`, `WorktreeFileSnapshot` and `GitProjectEvent::WorktreePatchApplied`, whose docs justified themselves against staging rather than saying what they hold. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
noahbclarkson
force-pushed
the
feat/diff-apply-revert
branch
from
August 9, 2026 07:06
cde0d4f to
705abf1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #70.
Stacked on #68 (a comment cleanup), because both touch
rgitui_diff/src/lib.rsand would otherwise conflict on whichever merged second. The first commit here is #68's.Adds apply and revert for diff content that comes from outside the working tree — a commit, a stash entry, or a pair of revisions — at the granularity the user is working at.
#65 stopped commit diffs from offering staging they could not honour. This gives them something they can: the hunk-level equivalents of cherry-pick and revert.
What a user gets
On a diff from a commit, stash or ref pair, the hunk headers offer Apply and Revert where a working-tree diff offers Stage and Unstage, and the header gains a File menu for whole-file operations. Three scopes, following what is selected:
Rebindable, declared in the
commands!registry in thediffnamespace, contextDiffViewer && !modal && !TextInput:diff::ApplySelectiona,shift-adiff::RevertSelectionr,shift-rdiff::ApplyFilealt-adiff::RevertFilealt-ralt-a/alt-rfollow the existingalt-s/alt-uconvention for the fixed-scope variant. Bareaandrare free in that context: the viewer's select-all issecondary-a, and the only other barerisrebase::RebaseReword, which sits behindmodal.the_default_keymap_has_no_conflictsandthe_shipped_defaults_shadow_only_thesepass unchanged — the new bindings introduce no shadowing.Not only history: cross-branch is the point
DiffSourcegainsCompare { from, to }, and apply/revert resolve every non-mutable source to a tree pair through one path. So applying from a branch comparison brings that branch's content into the working tree, and reverting restores the other side — the same code as a commit hunk, not a special case. Nothing constructsCompareyet because there is no compare view in the UI; the plumbing is here so that view is additive when it arrives.Why not
repo.applyorgit apply --3wayBoth obvious routes are wrong for this case, which is worth stating since the module is doing something less obvious instead:
git apply --3waymerges into the index, and refuses withdoes not match indexwhenever the working-tree file differs from its index entry — precisely the dirty tree the fallback exists for. On failure it leaves conflict markers plus an unmerged index entry behind, which is a worse state to hand back than a refusal.Instead
worktree_patch.rsreconstructs the three sides itself —base(the file on the side the patch starts from),target(basewith exactly the selected hunk or lines applied, computed from the diff rather than by matching context, so it is exact by construction), andours(the working tree now) — and asks libgit2 for a three-way merge. Unrelated local edits merge cleanly, an overlapping edit conflicts, and the index is never touched.Safety
These write to files on disk, unlike staging, so each operation records an undo entry carrying the file bytes it will restore — including recording a file as absent when the apply created it, so undo deletes it again. A reverse patch would not restore the original exactly when the forward operation merged, which is why the bytes are stored rather than the patch.
Failures are distinct, actionable messages rather than one generic error: content that will not merge, an operation that has already been applied, and a conflicting local edit each say what happened and what to do.
Tests
1215 passing, up from 1146 on
main.worktree_patch, asserting file contents after the operation rather than justOk: apply onto a clean tree, apply with unrelated local edits present, apply something already applied, revert, whole-file and line-subset patches, and direction correctness for a ref pair.DiffViewerthroughViewTest: sources that cannot be staged emit apply/revert and never a staging request;WorktreeandIndexstill emit exactly their staging request, which is the guard against a vacuously passing suite.Note on the view tests: they call the public methods rather than simulating
a/r, becausergitui_diffsits belowrgitui_workspaceand no action handler exists inside this crate's harness — a keystroke test here would assert nothing. That follows the patternmain's ownstaging_requests_after_stage_then_unstagealready uses.On #64
worktree_patch.rsneeded noargsafework and the reason is structural: it never shells out. The only user-controlled strings areCompare's endpoints, which go torepo.revparse_singlerather than a command line. It also passesNoneforDiffOptionsand filters deltas by exactPathequality in Rust, so the pathspec-fnmatch class of bug #64 fixed cannot occur — that is strictly tighter than a pathspec withdisable_pathspec_match(true).Verification
Windows 11, pinned 1.94.1 toolchain.
docs/KEYBINDINGS.mdanddocs/keymap.schema.jsonare re-blessed for the four new commands.🤖 Generated with Claude Code