TLDR: When multiple features land in the same files, Python-split the diff by hunks, git apply --cached --recount each group, and commit. --recount is the flag that makes hand-filtered patches safe.

the setup

I was building on my gym tracker PWA — a dumb little side project — and one session got… very productive.

Three features landed at once: a Day B plan overhaul, a cardio countdown timer with auto-log, and tap-to-type weight input.

Problem: all three touched the same 4 files. And one click-handler hunk had two features' lines sitting right next to each other.

Ship Fast means atomic commits. Shipping one blob was not an option.

the wall

git add -p is git's built-in interactive hunk picker — it'll let you stage exactly the lines you want, one hunk at a time. But it requires a real terminal to drive it. An agent, a CI script, any non-interactive committer — locked out. (And I wasn't going to stage hundreds of lines by hand.)

So what? GREAT question.

what I tried first

I hand-filtered the mixed hunk. Opened the patch file, pulled out the other feature's + lines with my eyes, saved it, ran git apply --cached.

Git spat it right back.

Of course it did. The @@ header says this hunk touches N lines. The moment you drop some + lines, the count is wrong — and git refuses the patch on principle.

the fix that worked

One flag changes everything: --recount.

It tells git to recompute the line counts from the actual patch body instead of trusting the @@ header. Suddenly, hand-filtered hunks are valid.

The full recipe:

  1. git diff <file> > /tmp/file.patch — dump the raw diff
  2. git diff <file> | grep -n '^@@' — list hunk positions, map each to a feature
  3. Python-split the patch: keep the diff --git / --- / +++ header lines at the top, then pull in only your first feature's hunks. For a mixed hunk, drop the other feature's + lines textually — don't touch the @@ counts, --recount handles it
  4. git apply --cached --recount /tmp/group1.patchgit commit
  5. Repeat for each middle group. The last group needs no patch at all — just git add -A, it stages whatever remains

That's it. Three features became three commits: 46543e5, 4f7eeaf, dbd13f2. Each independently revertable. Each independently parseable months from now.

the gotcha — and why the boundary has to be semantic

Here's the thing the recipe doesn't tell you: know when NOT to use it.

Truly coupled changes belong in the same commit. A data structure change and the rendering code that depends on it — split those and you've introduced the broken intermediate state that atomic commits exist to prevent. You've created the gap, not avoided it.

Mechanical hunk-splitting serves meaning. The split point should be semantic — what actually belongs together — not just wherever the hunk boundaries happen to fall.

That distinction is worth more than the recipe itself.