DEV Community

Vitaly Ostanin
Vitaly Ostanin

Posted on

hunkpick: a non-interactive git add -p for scripts and coding agents

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
Enter fullscreen mode Exit fullscreen mode

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 bare 1,3 used 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 --json gives structured output for tooling.
  • Built-in verification. The result diff is checked for internal consistency by default; --verify-result-diff-git additionally runs git 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
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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;
Enter fullscreen mode Exit fullscreen mode
# 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: ..."
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
motedb profile image
mote

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?

Collapse
 
vitaly-ostanin profile image
Vitaly Ostanin • Edited

Neither, really. Auto-split already separates edits that have context between them (the common case). To cut inside a contiguous +/- run, <idx>@lo-hi splits 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:

--- a/f
+++ b/f
@@ -1,5 +1,5 @@
 a
-b
+B
 c
-d
+D
 e
Enter fullscreen mode Exit fullscreen mode

hunkpick list already sees two addressable sub-hunks:

f
  [1] 3012b68bbf85f5cf @@ -1,3 +1,3 @@  +1 -1  -b
  [2] 27a52a5ca2ff7dc3 @@ -4,2 +4,2 @@  +1 -1  -d
Enter fullscreen mode Exit fullscreen mode

select 1 takes the first edit, select 2 the second — each with recomputed @@ headers and non-overlapping ranges.

Cutting inside a run: <idx>@lo-hi

To 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):

--- a/f
+++ b/f
@@ -10,0 +11,6 @@
+function foo() {
+  return 1;
+}
+function bar() {
+  return 2;
+}
Enter fullscreen mode Exit fullscreen mode

select 1@1-3 gives the first function, select 1@4-6 the second; each applies on its own (the second part's new_start is 14, since lines 11–13 are the first function).

Replacement — a run that both deletes and adds, e.g. a,b → A,B:

--- a/f
+++ b/f
@@ -1,2 +1,2 @@
-a
-b
+A
+B
Enter fullscreen mode Exit fullscreen mode
select 1@1-1  ->  -a -b +A
select 1@2-2  ->  +B
Enter fullscreen mode Exit fullscreen mode

Applied in sequence they stage A then B (verified with git 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 single changed line (-a +A, or a lone -a / +a) — you can't apply half a line;
  • additions separated by a deletion (+x -y +z) — cutting to take just +z would 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 point

split <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: split cuts 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-git additionally runs git apply --check). A cut that wouldn't produce an independently applicable patch returns an explicit error, never a partial hunk.