Using fixup commits and autosquash during review Jump to heading

Review produces corrections, and corrections have to go somewhere. Amend the original commit and the reviewer loses the ability to see what changed since their comment. Add a commit called “address review feedback” and the branch accumulates commits that mean nothing in six months. The fixup mechanism gives you both: each correction is a visible commit during review, attached by name to the commit it belongs to, and a single command folds them all in before merge. This is the workflow behind Squash & Fixup Strategies.

When to use this approach Jump to heading

  • Your branch is a series of meaningful commits you want to keep, rather than one blob to be squashed.
  • Reviewers come back more than once, so they need to see incremental changes.
  • The history that lands on the trunk should read as if the code was written correctly the first time.
  • You are the only person on the branch, since the final autosquash rewrites it.
  • If your platform squash-merges every pull request into one commit regardless, the reviewer benefit still applies but the history benefit does not.

Step 1 — Enable autosquash so the flag is never forgotten Jump to heading

# Honour fixup! and squash! markers automatically in every interactive rebase
git config --global rebase.autoSquash true

# And keep uncommitted work out of the way during the rebase
git config --global rebase.autoStash true
git config rebase.autoSquash      # expect: true

What changed: git rebase -i now reorders and marks fixup! commits by itself. Without this, the mechanism still works but requires remembering --autosquash at exactly the moment you are thinking about something else.

Step 2 — Create a fixup that names its target Jump to heading

# Find the commit the correction belongs to
git log --oneline origin/main..HEAD
#   c3d4e5f  feat: export settlements as NDJSON
#   b2c3d4e  refactor: rename Ledger to Journal
#   a1b2c3d  feat: add settlement_batch table

# Make the correction, then attach it by SHA
git add src/settlement/export.ts
git commit --fixup b2c3d4e
# The subject is generated, and it names the target
git log --oneline -1
#   9a8b7c6  fixup! refactor: rename Ledger to Journal

What changed: a new commit exists whose subject line binds it to b2c3d4e. Nothing has been rewritten, so a reviewer looking at the branch sees exactly what you changed in response to their comment.

Corrections stay attached to what they correctThree original commits sit on the branch. Three fixup commits are appended, each whose subject names one of the originals. Nothing is rewritten yet, so review can see both the original work and each correction separately.the branch during reviewa1b2c3d feat: batch tableb2c3d4e refactor: renamec3d4e5f feat: NDJSON9a8b7c6 fixup! refactor…original work, unchanged and still reviewablethe reviewer commented here…and this commit is untouched by the correction…and this is the answer, visible as its own commitnothing has been rewritten — the reviewer's earlier reading is still valid

When the correction touches the same lines as an existing commit, --fixup=amend:<sha> records that the fixup should replace content rather than add to it, and --fixup=reword:<sha> targets only the message.

Step 3 — Let reviewers see the incremental corrections Jump to heading

# Push the fixups like ordinary commits — no force needed
git push

# What the reviewer looks at: only what changed since their last review
git range-diff origin/main...@{u} origin/main...HEAD

What changed: the branch grew by one commit and every previously-read commit still has the same SHA, so the review tool shows a genuine incremental diff rather than “everything changed”.

# List outstanding fixups so none is forgotten before merge
git log --oneline origin/main..HEAD | grep '^[0-9a-f]* fixup!' || echo "none pending"

SAFETY WARNING — do not merge a branch with fixup! commits still on it. If the platform merges without squashing, commits literally named fixup! … land on the trunk permanently, and the correction is separated from what it corrects for the rest of the repository’s life. Add a check that fails when a branch contains an unsquashed fixup, so this cannot happen by accident.

#!/bin/sh
# ci/no-pending-fixups.sh — fail a pull request that still has fixups
if git log --format=%s "origin/main..HEAD" | grep -q '^\(fixup\|squash\)!'; then
  echo "branch still contains fixup! commits — run: git rebase -i --autosquash origin/main" >&2
  exit 1
fi

Step 4 — Fold everything in with one autosquash Jump to heading

# One command reorders every fixup next to its target and marks it
git rebase -i --autosquash origin/main

The todo list arrives already arranged — you confirm rather than edit:

pick   a1b2c3d  feat: add settlement_batch table
pick   b2c3d4e  refactor: rename Ledger to Journal
fixup  9a8b7c6  fixup! refactor: rename Ledger to Journal
pick   c3d4e5f  feat: export settlements as NDJSON
fixup  4d5e6f7  fixup! feat: export settlements as NDJSON
# Verify: the fixups are gone and the tree is unchanged
git log --oneline origin/main..HEAD      # three commits, no fixup! subjects
git diff @{1} HEAD                       # no output: content identical to before

# Publish the rewritten branch
git push --force-with-lease
Autosquash reorders and folds in one passBefore the rebase, three original commits are followed by two fixup commits appended at the end. Autosquash moves each fixup next to its target and marks it, so the result is three commits with the corrections folded in and no fixup subjects remaining.beforefeat: batch tablerefactor: renamefeat: NDJSONfixup! refactor: renamefixup! feat: NDJSON--autosquashtodo list, generatedpick feat: batch tablepick refactor: renamefixup fixup! refactor…pick feat: NDJSONfixup fixup! feat…afterfeat: batch tablerefactor: renamefeat: NDJSONcorrections folded in Which SHAs survive between review roundsAmending rewrites every commit on the branch, so no SHA the reviewer has read remains valid. Adding a fixup commit leaves every existing SHA untouched, so the review tool can show only what is new since the last round.amenda1b → 7c2b2c → 8d3c3d → 9e4every SHA the reviewer read is gonecomment threads detach; the tool re-shows the whole branchfixupa1b unchangedb2c unchangedc3d unchanged+ 9a8 fixup!threads stay attached; the tool shows only the new committhe single rewrite happens after approval, when nobody holds a half-finished reading

What the reviewer sees at each stage Jump to heading

The mechanism’s value is entirely about timing. During review the branch only grows, so every SHA a reviewer has already read stays valid and their tool can show a genuine “since you last looked” diff. The rewrite happens exactly once, after approval, when nobody is holding a half-finished reading of the branch. Compare that with amending: each amendment invalidates every SHA on the branch, so a reviewer returning to a comment thread finds it attached to a commit that no longer exists, and the tool falls back to showing the whole branch again. The corrections are the same either way; what differs is whether the reviewer can tell which comment produced which change.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not just amend the commit each time? Jump to heading

Amending rewrites immediately, so a reviewer who already looked at the branch cannot see what changed in response to their comment — the branch simply becomes different. A fixup commit is additive: the correction is visible as its own commit during review, and the rewrite happens once at the end when nobody is mid-read.

What is the difference between fixup and squash? Jump to heading

Both fold a commit into an earlier one; the difference is the message. fixup! discards the fixup’s own message and keeps the target’s, which is what you want for a correction. squash! opens an editor to combine both messages, which is right when the follow-up genuinely adds information the original message lacked.

Does this work if the platform squash-merges anyway? Jump to heading

It still helps, but less. If everything collapses into one commit at merge, the final history is the same either way. The value during review is unchanged though: a reviewer can see which comment produced which change, rather than re-reading a branch that has silently mutated between visits.