Skip to content

feat(diff): apply and revert hunks, lines and files from outside the working tree - #69

Merged
noahbclarkson merged 9 commits into
noahbclarkson:mainfrom
adehad:feat/diff-apply-revert
Aug 9, 2026
Merged

feat(diff): apply and revert hunks, lines and files from outside the working tree#69
noahbclarkson merged 9 commits into
noahbclarkson:mainfrom
adehad:feat/diff-apply-revert

Conversation

@adehad

@adehad adehad commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes #70.

Stacked on #68 (a comment cleanup), because both touch rgitui_diff/src/lib.rs and 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:

Scope When
The hunk Default — the hunk under the cursor, or every hunk the selection spans
The selected lines When a line selection exists, matching partial staging
The whole file From the header's File menu, selection ignored

Rebindable, declared in the commands! registry in the diff namespace, context DiffViewer && !modal && !TextInput:

Command Default
diff::ApplySelection a, shift-a
diff::RevertSelection r, shift-r
diff::ApplyFile alt-a
diff::RevertFile alt-r

alt-a/alt-r follow the existing alt-s/alt-u convention for the fixed-scope variant. Bare a and r are free in that context: the viewer's select-all is secondary-a, and the only other bare r is rebase::RebaseReword, which sits behind modal. the_default_keymap_has_no_conflicts and the_shipped_defaults_shadow_only_these pass unchanged — the new bindings introduce no shadowing.

Not only history: cross-branch is the point

DiffSource gains Compare { 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 constructs Compare yet because there is no compare view in the UI; the plumbing is here so that view is additive when it arrives.

Why not repo.apply or git apply --3way

Both obvious routes are wrong for this case, which is worth stating since the module is doing something less obvious instead:

  • libgit2's apply is a plain patch applier with no three-way fallback: it matches hunk context and fails outright when it does not line up. A dirty working tree is the normal case here, and an uncommitted edit anywhere in a hunk's three-line context window defeats it.
  • git apply --3way merges into the index, and refuses with does not match index whenever 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.rs reconstructs the three sides itself — base (the file on the side the patch starts from), target (base with exactly the selected hunk or lines applied, computed from the diff rather than by matching context, so it is exact by construction), and ours (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.

  • 51 in worktree_patch, asserting file contents after the operation rather than just Ok: 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.
  • 15 view-level tests driving a real DiffViewer through ViewTest: sources that cannot be staged emit apply/revert and never a staging request; Worktree and Index still 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, because rgitui_diff sits below rgitui_workspace and no action handler exists inside this crate's harness — a keystroke test here would assert nothing. That follows the pattern main's own staging_requests_after_stage_then_unstage already uses.

On #64

worktree_patch.rs needed no argsafe work and the reason is structural: it never shells out. The only user-controlled strings are Compare's endpoints, which go to repo.revparse_single rather than a command line. It also passes None for DiffOptions and filters deltas by exact Path equality in Rust, so the pathspec-fnmatch class of bug #64 fixed cannot occur — that is strictly tighter than a pathspec with disable_pathspec_match(true).

Verification

cargo test --workspace                                 -> 1215 passed, 6 ignored
cargo clippy --workspace --all-targets -- -D warnings  -> clean
cargo fmt --all -- --check                             -> clean
cargo build --package rgitui                           -> ok

Windows 11, pinned 1.94.1 toolchain. docs/KEYBINDINGS.md and docs/keymap.schema.json are re-blessed for the four new commands.

🤖 Generated with Claude Code

adehad and others added 8 commits August 9, 2026 18:52
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
noahbclarkson force-pushed the feat/diff-apply-revert branch from cde0d4f to 705abf1 Compare August 9, 2026 07:06
@noahbclarkson
noahbclarkson merged commit 70aca2d into noahbclarkson:main Aug 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Diffs from a commit, stash or ref pair cannot be applied to the working tree

2 participants