TLDR: When multiple features land in the same files, Python-split the diff by hunks,
git apply --cached --recounteach group, and commit.--recountis 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:
git diff <file> > /tmp/file.patch— dump the raw diffgit diff <file> | grep -n '^@@'— list hunk positions, map each to a feature- 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,--recounthandles it git apply --cached --recount /tmp/group1.patch→git commit- 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.