Skip to content

How to Squash Commits in Git

Lesson 4 of 7Intermediate9 min readModern Git Workflows · RebasingVerified: Git 2.43.0 on Ubuntu 24.04; every command and output in this lesson was run

Squashing combines several commits into one. In an interactive rebase you mark a commit squash or fixup, and Git melds it into the commit above it.

This is squashing on your own branch, before anyone sees it. It is a different operation from squash merging, which collapses a branch as it lands on the target. The two are frequently confused, and the distinction is covered below.

Fold in corrections. “fix typo” and “address review comment” are not steps in a story; they are corrections to a step. Folding them into the commit they correct leaves the story intact.

Remove noise. Debugging commits, commented-out experiments, “wip” checkpoints.

Make each commit reviewable. One coherent change per commit, rather than the sequence in which you happened to arrive at it.

Make git bisect meaningful. A commit that is a complete change is a useful bisect step; a “wip” commit that does not build is not.

Keep git blame informative. A line attributed to “Add input validation” explains itself. One attributed to “fix” does not.

Both meld a commit into the one above. They differ in what happens to the message.

squash (s)fixup (f)
Changes combinedYesYes
MessageEditor opens with both messagesKeeps only the first commit’s message
Use whenBoth messages contain informationThe second is a correction with nothing to say

fixup is the more common choice in practice, because most commits being folded in are corrections.

There are two variants worth knowing: fixup -C keeps this commit’s message instead of the previous one’s, and fixup -c does the same but opens the editor so you can adjust it.

A branch with a correction that belongs to an earlier commit:

Terminal window
git log --oneline
f195a48 WIP debug output
c413def Add validation
1aff95b fix typo
de4df70 Add parser
9fc05fd Initial commit
Terminal window
git rebase -i HEAD~4
pick de4df70 Add parser
pick 1aff95b fix typo
pick c413def Add validation
pick f195a48 WIP debug output

Fold the typo fix into the parser commit, and drop the debug commit:

pick de4df70 Add parser
fixup 1aff95b fix typo
pick c413def Add validation
drop f195a48 WIP debug output
Successfully rebased and updated refs/heads/main.
Terminal window
git log --oneline
00039a8 Add validation
b02ce4b Add parser
9fc05fd Initial commit

Verify the folded change is genuinely present rather than lost:

Terminal window
git show HEAD~1:parser.py
p
typo fix

To collapse an entire branch to a single commit, mark every line after the first as fixup:

pick de4df70 Add parser
fixup 1aff95b fix typo
fixup c413def Add validation
fixup f195a48 WIP debug output

Then reword the survivor to describe the whole change.

An alternative that avoids the todo list entirely, using a soft reset:

Terminal window
git reset --soft main
git commit -m "Add input parser with validation"

What it doesMoves the branch pointer back to the named commit while leaving the index and working tree exactly as they are.

Why we run itAll the branch's changes end up staged as one set, ready to be committed as a single new commit. It is often quicker than editing a long todo list.

Expected resultNo output. git status shows every change from the branch staged and ready to commit.

--soft is the key. It moves only the branch ref; --mixed would unstage everything, and --hard would discard the work entirely.

Working out later which commit a fix belongs to is harder than recording it at the time.

Terminal window
git commit --fixup=1d51084

This creates a commit whose message is fixup! Add parser — a marker naming its target. Later:

Terminal window
git rebase -i --autosquash HEAD~4

Git produces the todo list already arranged:

pick 1d51084 Add parser
fixup 3b54d21 fixup! Add parser
pick febcef5 Add validation
pick f347f23 Add tests

The fixup has been moved next to its target and its verb changed. Save without editing and the history collapses correctly.

Make it the default:

Terminal window
git config --global rebase.autoSquash true

git commit --squash=<commit> is the equivalent that keeps both messages.

These are different operations with similar names, and conflating them causes real confusion.

Squash in a rebaseSquash merge
Commandgit rebase -i with squash/fixupgit merge --squash, or a platform button
What is rewrittenYour branchNothing — a new commit is added to the target
WhenBefore sharing, usuallyAt integration time
How many commits resultHowever many you chooseAlways exactly one
Force push neededYesNo
Who does itThe branch authorWhoever merges

Squash rebasing is about shaping your branch. Squash merging is about what main receives. You can do either, both, or neither.

If your team squash-merges everything, squashing your own branch first buys you little — main gets one commit regardless. It can still be worth doing so reviewers see a clean series, but the final history is the same.

If your team merges or rebases branches whole, squashing beforehand is how you control what lands. Squash Merging covers the integration side.

You rarely want to collapse everything. More often a branch has two or three coherent changes, each of which accumulated its own corrections.

The todo list handles this naturally — group the lines and mark the corrections:

pick a1b2c3d Add parser interface
fixup e4f5g6h fix parser typo
fixup i7j8k9l parser: handle nulls
pick m1n2o3p Add validation
fixup q4r5s6t validation: fix off-by-one
pick u7v8w9x Add tests

Three commits result, each carrying its own corrections. This is usually a better outcome than one giant commit, and it costs no more effort than squashing everything.

If the corrections are not adjacent to their targets, either move the lines first or let --autosquash do it.

When you use squash, Git opens an editor containing every message it is combining:

# This is a combination of 3 commits.
# This is the 1st commit message:
Add parser
# This is the commit message #2:
fix typo
# This is the commit message #3:
parser: handle nulls

Everything not commented out becomes the final message. The default — all three concatenated — is almost never what you want.

Replace it with a single message describing the combined change:

Add parser with null handling
Parses the input into tokens. Returns None on empty input rather than
raising, which the caller in report.py relies on.

Squashing is not automatically an improvement.

Deliberately structured branches. “Add the interface”, “add the implementation”, “switch the caller”, “delete the old code” is far more reviewable as four commits than as one. A reviewer can verify the refactor is behaviour-preserving by looking at it in isolation.

Large changes. Collapsing a 3,000-line branch into one commit produces something nobody can review or bisect usefully.

Separable mechanical and behavioural changes. A rename across forty files, then the logic change. Folded together, the real change is invisible among the noise.

When git blame matters. Finer commits carry more explanation per line.

Multi-author branches. Squashing collapses attribution to one author. Co-authored-by: trailers can preserve credit, but only if someone adds them.

The rule of thumb: squash corrections, keep steps. If a commit represents a decision someone made, it is probably a step. If it fixes a mistake in the commit before it, it is a correction.

Squashing a branch you have pushed is routine, provided nobody else is using it.

Terminal window
git rebase -i main
# … mark fixups, save …
git push --force-with-lease

--force-with-lease refuses if the remote has moved since your last fetch, which protects against overwriting a colleague’s push. It is not a substitute for knowing whether anyone is working on the branch — it only detects new commits, not the fact that someone has a local copy of the old ones.

Squashing commits already on a shared mainline is a different proposition entirely. Rewriting main means every clone in existence is now inconsistent with it, and everyone must reset. That is an incident response, not a cleanup, and is covered in When Not to Rebase.

Squashing changes history, not code. Confirm that:

Terminal window
git branch backup # before starting
git rebase -i main
git diff backup HEAD # after

Empty output means the squash preserved the result exactly. Any output means something was lost — most likely a conflict resolved incorrectly during the replay.

Marking the wrong line. squash/fixup fold upwards. Mark the commit being absorbed, not its target.

Squashing a shared branch. Every commit gets a new ID; anyone who pulled it is orphaned.

Using git reset --hard to squash. Discards the work. --soft moves the pointer and keeps everything staged.

Accepting the combined message unedited. A squash produces a message containing both originals, often with “wip” in it. Write a real one.

Squashing before review rather than after. If reviewers commented on individual commits, rewriting during review destroys the anchors. Squash before opening, or after approval.

Squashing everything by reflex. A well-structured branch is worth more than a tidy one.

Forgetting --force-with-lease. After squashing a pushed branch, the remote copy has diverged.

Squashing asks: were these separate decisions, or one decision and its corrections?

Separate decisions deserve separate commits — a reviewer can evaluate each, and git blame explains each line. A decision plus its corrections is one commit that took a few attempts, and the attempts are not worth preserving.

  • squash keeps both messages for editing; fixup discards the absorbed commit’s message.
  • Both fold into the commit above them in the todo list.
  • git reset --soft <base> followed by a commit squashes an entire branch without the todo list.
  • --hard in that position destroys the work; --soft is the only safe form.
  • git commit --fixup=<id> plus git rebase -i --autosquash records and applies fold-in intent.
  • Squash rebasing rewrites your branch; squash merging adds one commit to the target.
  • Corrections are worth squashing; deliberate steps usually are not.
  • git diff backup HEAD must be empty afterwards.
  1. Create a repository and four commits: a real change, a “fix typo” for it, another real change, and a “wip” commit.
  2. Back it up: git branch backup.
  3. Run git rebase -i HEAD~4. Mark the typo commit fixup and the wip commit drop.
  4. Confirm two commits remain, and that the typo fix is inside the first: git show HEAD~1:<file>.
  5. Run git diff backup HEAD. Predict the output.
  6. Reset: git reset --hard backup.
  7. Now try the autosquash route. Note the first commit’s ID, make a change, and commit it with git commit --fixup=<that-id>.
  8. Run git rebase -i --autosquash HEAD~5 and inspect the generated list before saving. Where did Git place the fixup, and what verb did it use?
  9. Finally, squash everything with git reset --soft backup~4 && git commit -m "One commit". Confirm the working tree still contains all the changes.

Step 5 should print nothing. Step 8 shows autosquash doing the arranging for you — which is why recording the intent at commit time is worth the habit.

Squashing, reordering and amending are all history editing. The next lesson is the decision framework for choosing between them — and for knowing when to revert instead.