Staging a subset of your changes is a routine part of making clean commits. Git's built-in tool for it is git add -p — you walk through each hunk and answer y/n/s. That works at a keyboard, but it is interactive by design: you cannot drive it from a shell script, a Makefile, or an automated coding agent that needs to stage a precise set of changes without a human at the prompt.
The traditional non-interactive answer is filterdiff from the patchutils suite:
git diff path | filterdiff --hunks=1,3 | git apply --cached
filterdiff selects whole hunks by their position in the diff. The problem: a single hunk often contains several unrelated change runs separated by a few context lines. If you only want one of them, filterdiff cannot help — the whole hunk is in or out.
hunkpick
hunkpick is a small Rust CLI that fills that gap. It is a pure stdin → stdout filter: it reads a unified diff, emits the subset you asked for, and leaves applying it to you (git apply --cached). It never calls git to produce the diff, so it works with any diff source — git, Mercurial, SVN, or plain diff -u.
What it adds over filterdiff:
-
Auto-split into sub-hunks. Each hunk is decomposed into minimal sub-hunks, one per contiguous run of
+/-lines, each addressable by a stable per-file index. -
Per-file addressing. In a multi-file diff selectors are prefixed with the path (
path:1,3,path:*) so they stay unambiguous; for a single-file diff the prefix is optional and you can write the bare1,3used in the examples below. -
Content ids. Every sub-hunk carries a
@<id>derived from the file path and its changed lines only — not the context or the@@line numbers. The id survives a re-diff even when staging a neighbour renumbers indices or rewrites surrounding context, so an agent can capture it once and keep using it across a staging loop. -
Machine-readable listing.
list --jsongives structured output for tooling. -
Built-in verification. The result diff is checked for internal consistency by default;
--verify-result-diff-gitadditionally runsgit apply --check. - A single cross-platform binary, including Windows — no Unix toolchain to install.
List, then select
# See the addressable sub-hunks
git diff src/main.rs | hunkpick list
src/main.rs
[1] 6860edc905028034 @@ -1,5 +1,5 @@ +1 -1 - let a = 0;
[2] 5bb69992111224fd @@ -6,5 +6,5 @@ +1 -1 - let d = 0;
[3] 369095db02d2feec @@ -11,3 +11,3 @@ +1 -1 - let h = 0;
Each [n] is a per-file index; the @<id> after it is the content id (below). The preview is the first changed line of the sub-hunk.
# Stage sub-hunks 1 and 3, skipping the middle one
git diff src/main.rs | hunkpick select 1,3 | git apply --cached
Sub-hunks 1 and 3 are now staged; sub-hunk 2 is left in the working tree — exactly the kind of split filterdiff cannot express, because all three live in one hunk.
Splitting one file's changes into several commits
The content id is what makes an automated staging loop reliable. Bare indices renumber as you stage, but a @<id> stays valid across the re-diff — capture the listing once and keep using the ids:
# 1. Capture the ids once.
git diff src/indicator.js | hunkpick list
src/indicator.js
[1] 3a02b1b8b5f9bd02 @@ -1,3 +1,3 @@ +1 -1 - const a = 0;
[2] 0759ce5f8024a473 @@ -4,2 +4,2 @@ +1 -1 - const b = 0;
[3] 31aa062fa4795c80 @@ -6,2 +6,2 @@ +1 -1 - const c = 0;
[4] 0d33a7656bae5ea9 @@ -8,3 +8,3 @@ +1 -1 - const d = 0;
# 2. Stage and commit each group by @id, re-running git diff each round.
git diff src/indicator.js | hunkpick select @3a02b1b8b5f9bd02 | git apply --cached
git commit -m "fix: ..."
git diff src/indicator.js | hunkpick select @0759ce5f8024a473 @31aa062fa4795c80 | git apply --cached
git commit -m "feat: ..."
# 3. Whatever is left is the last group.
git diff src/indicator.js | hunkpick select '*' | git apply --cached
git commit -m "chore: ..."
Three commits: fix gets sub-hunk 1, feat gets 2 and 3, and chore gets the remaining 4 via *. Between steps the bare indices would renumber, but each @<id> stays valid across the re-diff, so the listing captured once keeps working.
hunkpick vs filterdiff
| Capability | filterdiff | hunkpick |
|---|---|---|
| Select whole hunks | ✅ | ✅ |
| Works with any diff source | ✅ | ✅ |
| Address sub-hunks by per-file index | ❌ | ✅ |
| Auto-split hunks at change-run boundaries | ❌ | ✅ |
| Stable content ids across re-diffs | ❌ | ✅ |
| Machine-readable listing (JSON) | ❌ | ✅ |
| Built-in result verification | ❌ | ✅ |
| Split an addition-only block by line range | ❌ | ✅ |
Install
cargo install hunkpick
# or a prebuilt binary:
cargo binstall hunkpick
Links: GitHub · crates.io · docs.rs.
Disclosure: this project was developed with the help of an AI coding assistant, and this article was drafted with LLM assistance and edited by the author.
Top comments (2)
The hunk-splitting problem is real — I ran into exactly this when building an agent that needed to stage only the API boundary changes from a larger diff. filterdiff works for coarse-grained filtering but breaks down when a single hunk mixes business logic with the interface you care about.
How are you handling the case where a hunk has no clean split point? Do you fall back to rejecting it entirely, or do you split on context lines and let the caller deal with partial hunks?
Neither, really. Auto-split already separates edits that have context between them (the common case). To cut inside a contiguous
+/-run,<idx>@lo-hisplits on the addition side — at a boundary between two adjacent+lines — and that works for replacements too, not just pure insertions. When a run has no such boundary (a single changed line, or additions separated by a deletion), it stays a single atomic sub-hunk — not rejected, not handed back partial.Auto-split at context gaps
The main mechanism. Each hunk is split into minimal sub-hunks at the context gaps between change runs — exactly the "several unrelated edits in one hunk, separated by context" case. Nothing explicit to do.
A hunk with two edits separated by the context line
c:hunkpick listalready sees two addressable sub-hunks:select 1takes the first edit,select 2the second — each with recomputed@@headers and non-overlapping ranges.Cutting inside a run:
<idx>@lo-hiTo cut inside a contiguous run, you address the added lines by number and cut at the boundary between two adjacent
+. It works whether the run is a pure insertion or a replacement.Pure insertion — two new functions added as one
+block (added lines numbered 1–6):select 1@1-3gives the first function,select 1@4-6the second; each applies on its own (the second part'snew_startis 14, since lines 11–13 are the first function).Replacement — a run that both deletes and adds, e.g.
a,b → A,B:Applied in sequence they stage
AthenB(verified withgit apply). The deletions ride along with the first piece (lo == 1); later pieces are plain insertions.When a run is atomic
A run stays one indivisible sub-hunk only when it has no addition|addition boundary to cut at:
-a +A, or a lone-a/+a) — you can't apply half a line;+x -y +z) — cutting to take just+zwould fall on the deletion, so hunkpick returns an explicit error.Two more limits specific to replacements: the deletions are indivisible — they can't be split across pieces or addressed on the deletion side (hunkpick only cuts on the addition side), and to reproduce the full edit the piece carrying
lo == 1(which holds the deletions) has to be among the ones you apply. In every case the run is still addressable and stageable as a whole (select <idx>); it's just not subdivided. That's not a rejection or a partial hunk.Most of these are limits of hunkpick's current selectors, not of diffs or git: git will apply separated deletions, insertions, and even a split replacement as independent patches — I checked. The only genuinely indivisible case is a single changed line. I'm planning to generalize the selector — cutting on the deletion side and addressing individual changed lines — to lift these limits; what remains are the fundamental ones: a line is the atom, and a unified diff doesn't record which deletion pairs with which addition.
split --at: manual cut pointsplit <idx> --at <line>gives manual control over the cut point when the auto granularity isn't what you want (e.g. merging adjacent runs into one piece). Note:splitcuts only on a context line — a cut on a+/-line is an error. So it doesn't help where there's no context; it just lets you pick a specific context boundary instead of the automatic one.Validation
In every case the result is validated: each emitted sub-hunk has recomputed
@@headers and non-overlapping old-file ranges, and is checked to apply (internal consistency by default;--verify-result-diff-gitadditionally runsgit apply --check). A cut that wouldn't produce an independently applicable patch returns an explicit error, never a partial hunk.