- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You want a bug fix merged this afternoon. It's sitting in the same commit as a refactor you want to argue about first. The agent called that commit Update checkout, and it touches five files. Two of them fix a formatting bug a customer can see today and pin it with a test. The other three move a hard-coded tax rate behind an interface and rewrite the test to match. The commit gives you two buttons: take all of it, or none.
A coding agent lands here easily, because the task said "clean up checkout" and everything it touched along the way went into one commit. Until this year, pulling it apart meant an interactive rebase: mark the commit edit, reset it, re-stage in pieces, commit twice, continue.
Git 2.54, released in April 2026, added an experimental command for this: git history split. You pick hunks the way you do with git add -p, Git writes them into a new parent commit, and every branch built on top is moved to the rewritten history. No to-do list, no rebase in progress. Below you build a throwaway repo with one agent-style commit and split it, then fix messages with reword and amend an old commit with fixup.
Build the mess on purpose
You need Git 2.54 or newer for split and reword, and 2.55 for fixup. Check with git --version. The files are small PHP classes, but you don't need PHP or PHPUnit installed. Git only sees text.
First, the starting point on main. Money::format has a real bug: 1205 cents comes out as 12.5, because the cents are never zero-padded.
mkdir -p ~/code/split-demo && cd ~/code/split-demo
git init -q -b main && mkdir src tests
cat > src/Money.php <<'EOF'
<?php
final class Money
{
public static function format(int $cents): string
{
return intdiv($cents, 100) . '.' . $cents % 100;
}
}
EOF
cat > src/Checkout.php <<'EOF'
<?php
final class Checkout
{
public function total(int $cents): int
{
return (int) round($cents * 1.19);
}
}
EOF
cat > tests/CheckoutTest.php <<'EOF'
<?php
use PHPUnit\Framework\TestCase;
final class CheckoutTest extends TestCase
{
public function testAddsVat(): void
{
$this->assertSame(119, (new Checkout())->total(100));
}
}
EOF
git add -A && git commit -qm "Initial checkout"
Now play the agent. On a task branch, one commit fixes the padding, adds a regression test, extracts a TaxRate interface, makes Checkout depend on it and rewrites the Checkout test. Then a second, smaller commit lands on top with a message that says nothing.
git switch -qc agent/checkout
cat > src/Money.php <<'EOF'
<?php
final class Money
{
public static function format(int $cents): string
{
$whole = intdiv($cents, 100);
return sprintf('%d.%02d', $whole, $cents % 100);
}
}
EOF
cat > tests/MoneyTest.php <<'EOF'
<?php
use PHPUnit\Framework\TestCase;
final class MoneyTest extends TestCase
{
public function testPadsCents(): void
{
$this->assertSame('12.05', Money::format(1205));
}
}
EOF
cat > src/TaxRate.php <<'EOF'
<?php
interface TaxRate
{
public function multiplier(): float;
}
EOF
cat > src/Checkout.php <<'EOF'
<?php
final class Checkout
{
public function __construct(private TaxRate $tax) {}
public function total(int $cents): int
{
return (int) round($cents * $this->tax->multiplier());
}
}
EOF
cat > tests/CheckoutTest.php <<'EOF'
<?php
use PHPUnit\Framework\TestCase;
final class CheckoutTest extends TestCase
{
public function testAppliesTheTaxRate(): void
{
$tax = new class implements TaxRate {
public function multiplier(): float
{
return 1.19;
}
};
$this->assertSame(119, (new Checkout($tax))->total(100));
}
}
EOF
git add -A && git commit -qm "Update checkout"
echo "Checkout needs a TaxRate." > README.md
git add README.md && git commit -qm "more fixes"
Run git show --stat HEAD~1 and you see the problem: five files, one message, two unrelated changes.
Before rewriting anything, mark where you started with a tag:
git tag before-split
Use a tag, not a branch. By default git history moves every local branch that descends from the commit you rewrite (docs), so a backup branch at the tip would be moved along with everything else. The docs only talk about moving branches, so a tag stays where you put it.
Split it with git history split
Point split at the giant commit. It is one below the tip, which is the case that used to need a rebase:
git history split HEAD~1
Git walks the commit's hunks in path order and asks about each one, with the same keys as git add -p. Answering y moves a hunk into the new commit. n leaves it where it is, and d leaves that hunk and the rest of its file (git-add docs). For this commit:
-
src/Checkout.php:d -
src/Money.php:y -
src/TaxRate.php:d -
tests/CheckoutTest.php:d -
tests/MoneyTest.php:y
Then your editor opens twice. In the source, both start from the agent's original message. The first one is for the split-out commit, the hunks you picked, which becomes the new parent: write Fix zero-padding in Money::format. The second is for what stays in the original commit: write Extract TaxRate port from Checkout. Authorship stays with the original commit's author.
When the concerns sit in separate files, a pathspec gets you there without the d presses. Only matching files are offered, and everything else stays in the original commit:
git history split HEAD~1 -- src/Money.php tests/MoneyTest.php
Notice what you didn't split out: the Checkout test rewrite. The old test calls new Checkout() with no argument, which breaks the moment the constructor needs a TaxRate. A commit holding the refactor and the old test would leave the suite red. The rewritten test goes with the change that forced it. Group hunks by what a reviewer would approve together.

One commit in, two commits out: the fix becomes the parent and the refactor sits on top of it.
Now check that nothing was lost:
git log --oneline main..agent/checkout
git diff before-split agent/checkout
The log shows three commits where there were two. The diff should print nothing. Split only redistributes hunks: the docs say the original commit "stays intact, except that its parent will be the newly split-out commit", so the code at the tip is unchanged.
Doing this the first time on a real branch? Add --dry-run. Git writes the new objects but moves no refs, and prints the ref updates in the format git update-ref reads. If you want only the current branch moved, pass --update-refs=head.
Reword and fixup without a rebase
The more fixes commit is still on top. git history reword opens your editor on any commit's message and rewrites it in place. It doesn't touch your working tree or index, and it even works in a bare repository (GitHub's 2.54 notes). Run it and replace more fixes with Document the TaxRate dependency in README:
git history reword HEAD
For HEAD alone, git commit --amend does the same job. reword is for a commit three or four back, where amend can't reach.
Then review finds a gap: the fix has no test for amounts under 1.00. Git 2.55 added git history fixup, which folds your staged changes into an earlier commit and replays the rest on top:
cat > tests/MoneyTest.php <<'EOF'
<?php
use PHPUnit\Framework\TestCase;
final class MoneyTest extends TestCase
{
public function testPadsCents(): void
{
$this->assertSame('12.05', Money::format(1205));
$this->assertSame('0.07', Money::format(7));
}
}
EOF
git add tests/MoneyTest.php
git history fixup 'agent/checkout^{/Fix zero}'
The ^{/Fix zero} suffix finds the newest commit on the branch whose message matches, the same form the man page uses. The fix commit keeps its message and author; add --reedit-message if you want to edit the message. Git 2.56 added git history drop, which removes a commit and replays its descendants onto its parent (GitHub's 2.56 notes).
If you already pushed agent/checkout, these are rewrites like any rebase: push with git push --force-with-lease. To throw the whole exercise away, git reset --hard before-split while on the branch takes you back. That's fine here, since the repo is a throwaway.
What git history refuses to do
The man page is direct about the limits, and most of them are on purpose:
- It won't touch histories that contain merge commits. Use
git rebase --rebase-mergesfor those. - Any operation that would end in a merge conflict is refused. History rewrites aren't meant to leave you in a half-finished state, so it aborts instead of stopping midway.
- Githooks don't run "at the current point in time". If a
commit-msghook checks your message format, it won't see the messages you typed during the split. - The source calls the hunk picker with editing disabled, so
eisn't offered. When a fix and a refactor share one hunk, trysto break it into smaller hunks, or fall back togit rebase -i.
Picking every hunk or no hunk is invalid too, since one of the two commits would be empty. And as of 2.56 the whole command is still marked experimental, with a warning that the behavior may change.
For reordering a range or moving it onto a new base, git rebase is still the tool. git history covers one targeted edit at a time.
See the turns before you split, in Hermes IDE
Choosing which hunks belong together is the hard part of a split, and an agent's single commit doesn't tell you the order it worked in.
I build Hermes IDE, so weigh this section with that in mind. Hermes runs each agent (Claude Code, Codex and others) in its own terminal, and it saves every agent turn, so you can diff or restore any single turn. The Review Desk (⌘G, or Ctrl+Shift+G on Windows and Linux) shows those changes by turn or by file, with risk flags.

The Review Desk in By turn view: two turns (T1: 3 files, T2: 1 file), turn 1's diff of src/db.ts, and a commit message drafted from the turns.
If the agent did the padding fix in one turn and the refactor in the next, the by-turn view shows you the pathspec for git history split before you type it. And if you open the Review Desk before the agent commits, you can stage and commit from there file by file, and there's nothing to split. A line comment on a turn goes back to the agent that wrote it, so "move the test rewrite in with the refactor" reaches the right terminal.
Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Download it, and if the by-turn view saves you a split, star it on GitHub.
If this was useful
The refactor half of that commit, pulling a hard-coded tax rate out of Checkout and behind a TaxRate interface, is the small version of what Decoupled PHP is about: keep the business rules in plain PHP and push the framework and the details to the edge. That's also the change you most want in its own commit, because it's the one reviewers argue about.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)