The fixup + autosquash pattern, wrapped in a few hard guardrails so a coding agent can rewrite its own history without losing work or leaving a mess.
Written by: Daniel Degasperi
I’ve been pairing with AI coding agents a lot lately, and they’re genuinely good at writing code. Where they fall apart is Git.
Picture the moment: the agent is three commits deep into a feature branch, and a change it just made really belongs in an earlier commit. Maybe a reviewer asked for it. Maybe the agent discovered, while wiring up commit three, that the cleaner fix lives back in commit one. A careful engineer would fold the change into the commit it belongs to. The agent, with raw terminal access and no plan, does something else. Sometimes it stacks another commit — fix, then fix 2, then actually fix. Sometimes, when it really panics, it reaches for git reset --hard and takes my uncommitted work with it.
Neither outcome is acceptable. One leaves a commit history nobody can review; the other destroys work. So I went looking for a workflow that lets an agent correct its own mistakes without either failure mode. It turns out Git has had the answer built in for years — it just needs the right wrapper to be safe in an agent’s hands.
The native solution: fixup and autosquash
The pattern is git commit --fixup paired with git rebase --autosquash. If you’ve never used it, here’s the human version.
You’re on a feature branch and realize a change belongs in an earlier commit — a reviewer flagged something you did three commits ago, or you’ve just found that the right home for a tweak is the commit that introduced the code. You make the change, stage it, and instead of writing a fresh commit message, you tell Git which commit this patch belongs to:
git add src/auth/guard.php
git commit --fixup=<sha-of-the-original-commit>
That creates a commit titled fixup! <original subject>. It just sits there, marked. You can stack as many of these as you like while you work. Then, when you’re ready to tidy up:
git rebase -i --autosquash <base>
Git reorders the fixup! commits next to their targets and melts each one into the original. The history comes out linear and clean, as if the mistake never happened. The pull request reads like the work was done right the first time.
This is the right shape for an agent. It’s additive while you work — you only ever add commits, never rewrite — and the rewrite happens once, deliberately, at the end. Nothing destructive until the cleanup step.
Here’s the difference on a real branch. Each correction was made with --fixup, so instead of junk like fix 2, the log carries marked fixup! commits — each one already pointing at the commit it belongs to (they inherit the target’s subject). Two corrections landed on the auth guard, one on the login endpoint:
$ git log --oneline
e4f5a6b fixup! feat: add auth guard
d3c2b1a fixup! feat: add login endpoint
b8a7c6d fixup! feat: add auth guard
9f2e1d0 feat: wire up session middleware
7c6b5a4 feat: add login endpoint
0f2bdc5 feat: add auth guard
That marking is the whole point: git rebase --autosquash only recognizes commits whose subject starts with fixup! (or squash!/amend!). A plain fix 2 commit would be left untouched — it’s the fixup! prefix that tells Git where the patch belongs. One squash later, the fixups have melted into the commits they belonged to:
$ git log --oneline
1a2b3c4 feat: wire up session middleware
5d6e7f8 feat: add login endpoint
9a0b1c2 feat: add auth guard
Three clean commits. The false starts are gone — not hidden, folded into the change that should have contained them all along.
Why agents can’t just run those commands
If the pattern is so clean, why not hand the agent the two commands and walk away? Because each one has a sharp edge that a human steps over without thinking and an agent walks straight into.
- Targeting the right commit. The agent has to identify which commit the change belongs to. It’s usually good at this — it can reason about which commit introduced the code it’s touching. But “usually” isn’t “always”, and copying a raw hash out of
git loggives it no way to catch itself when it guesses wrong. - The editor trap.
git rebase -iopens an interactive editor — usually Vim. A human types:wq. An agent, depending on how its terminal is wired, can sit there feeding text into an editor it doesn’t know how to exit. - The detached-HEAD conflict. This is the real one — it’s where I’ve watched agents loop, hallucinate, or panic-reset. When an autosquash hits a merge conflict, Git stops mid-rebase and leaves the agent somewhere it has no map for. That’s dangerous enough to earn its own section, below.
So the raw commands aren’t the deliverable. The deliverable is a small tool that wraps them and removes every one of those edges. I built it as a skill — a single bash helper the agent calls by subcommand — and the design is mostly about guardrails. It’s open source: ddegasperi/skill-git-fixup.
Three guardrails that do the heavy lifting
Keyword targeting instead of a raw hash. The agent doesn’t pass a hash; it passes a word from the commit message. The tool searches the log itself and resolves the match:
git-fixup find auth
# -> 0f2bdc5 feat: add auth guard
If the keyword matches nothing, or matches several commits, the tool refuses and says so with distinct exit codes. The agent never acts on an ambiguous target — and when it’s confident, it can still pass the SHA directly. Either way, the safety net is in the tool, not in the agent’s memory of a hash.
Headless execution. The editor trap closes with one environment variable:
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>
GIT_SEQUENCE_EDITOR=true tells Git to accept the autosquash plan silently — true is a command that exits 0 without touching anything. No editor ever opens. The agent cannot get stuck in Vim because Vim is never invoked.
A branch-based safety model. This is the guardrail I care about most. The tool rewrites history only on feature branches. On main or the repository’s default branch, every rewriting command refuses outright. And it never rewrites below the merge-base with the default branch — the commit where the branch first forked off — so shared history is physically out of reach.
The merge-base is the hard floor. Everything at or below the point where the branch forked off the default branch is off-limits to every rewriting command. The agent can churn its own in-flight commits all it likes; the history other people have already pulled cannot be touched.
On a feature branch the agent is free — it can squash and force-push (with --force-with-lease, never a bare --force) as much as it needs, because the blast radius is contained to a branch that’s still in flight. And the uncommitted work I was worried about at the start? A dirty tree is auto-stashed before any rewrite and restored afterward, so the git reset --hard failure mode simply can’t happen through this tool.
Two more subcommands extend the same design. Reviewer feedback is often about a commit’s message, not its code — a vague subject, the wrong scope. Targeted by keyword just like a fixup, a reword command rewrites the message in place through an amend! commit that the same headless squash folds in — no editor, no trailing “reword commit message” noise. And when the agent wants to look before it leaps, a preview subcommand prints the exact autosquash plan as a dry run — which fixups fold into which commits — without executing anything.
Put together, the agent operates in a sandbox where the worst it can do is make a mess of its own feature branch — and the tool is designed to clean that up too.
The hard part: conflicts — retreat, then isolate
The guardrails above handle the easy path. The interesting engineering is in the conflict path, because that detached-HEAD state is exactly where agents break.
Here’s what that state actually is. When the rebase halts, Git leaves you in detached HEAD — HEAD pointing at a raw commit instead of any branch, conflict markers in the files, history half-rewritten into neither the old shape nor the new one. The agent’s model of “where am I” breaks — and the looping, the hallucinating, the panic-reset all follow from that. This is the one failure mode the whole design is built to avoid.
My first instinct was to teach the agent to resolve conflicts in place — read the <<<<<<< markers, edit, git add, git rebase --continue. I’m glad I didn’t. A batch autosquash can queue several commits, and resolving one conflict can surface the next. That’s a cascade the agent has to track across turns, in a repository state that matches neither the old history nor the new one. It’s the worst possible place to ask an agent to reason.
So the tool does the opposite of pushing forward. On the first conflict during a batch squash, it retreats:
git rebase --abort
That single command restores the branch to exactly its pre-squash state — every fixup! commit still present, nothing rewritten, working tree clean. The agent is back on solid ground before it has to reason about anything. From there the tool isolates resolution to one commit at a time, through a resolve loop that’s safe to re-run as many times as it takes:
squash
│
┌───────┴────────┐
clean conflict
│ │
push ✓ git rebase --abort (automatic — back to a safe state)
│
resolve ◄──────────────┐
│ │
┌─────────┴─────────┐ │
clean conflict │
│ │ │
push ✓ edit the named files
(normal file tools)
└────────────┘
The contract for the agent becomes dead simple: run resolve; if it reports a conflict, fix the files it names and run resolve again. Each pause isolates a single commit being replayed, so every conflict is small and local. The agent resolves it with the same file-editing tools it uses for everything else — no rebase incantations, no half-finished state to navigate.
That reframing is the whole trick. An open-ended, stateful, editor-driven operation becomes a repeatable command that either succeeds or tells you exactly which files to touch. That’s something an agent can do reliably, turn after turn.
A note on building it honestly
I’ll admit one thing, because it’s the kind of bug that’s easy to hide and worth showing. When I tested the conflict path, the resolve step refused to continue — my branch guard saw that HEAD was detached during the rebase and assumed it wasn’t on a feature branch. The guard meant to protect me was blocking the recovery.
The fix was to read the branch being rebased from Git’s own rebase metadata rather than asking “what branch am I on right now.” I’d never have found that by reasoning about it; I found it by running the whole thing end-to-end in a throwaway repository and watching it fail. Tooling for agents needs the same testing discipline as the code the agents write — maybe more, because the agent will trust the tool completely.
Takeaway
The shift that mattered wasn’t a clever Git command. It was deciding that the agent should never be left holding a half-rewritten repository. Make the workflow additive while working, gate the rewrite behind a branch check, run it headless, and on any trouble retreat to a known-good state and isolate the problem down to one small change.
The principle, in one line: never hand an agent a stateful, half-finished operation it has to reason its way out of. Give it commands that either succeed or leave it exactly where it started.
Do that, and you get two wins at once. The agent has a safe space to correct its own mistakes without me babysitting it. And when I open the pull request, I don’t see the sausage-making — the fix 2 commits, the false starts, the panic. I see a clean, linear history that reads like the work was done carefully the first time.
It reminds me a little of cooking a long recipe. The kitchen can be chaos while you’re in it. What matters is that what you carry to the table looks like you knew what you were doing all along.
Get the skill
The tool is on GitHub: ddegasperi/skill-git-fixup. Clone it into your agent’s skills directory and it’s ready to use. It ships with a contract test suite — bash tests/run-tests.sh exercises every guardrail, exit code, and the conflict path against throwaway repos, so you can confirm it behaves before you trust it with your history.