TechEarl

How to Squash Git Commits (Interactive Rebase and Friends)

Squash Git commits into one with interactive rebase: change pick to squash or fixup, fold into the line above, abort cleanly, or flatten a whole branch with git merge --squash. The whole workflow, including the force-push step you cannot skip.

Ishan Karunaratne⏱️ 9 min readUpdated
Share thisCopied
Squash Git commits into one with interactive rebase: change pick to squash or fixup, fold commits into the line above, or flatten a branch with git merge --squash.

To squash the last few Git commits into one, run an interactive rebase, then change pick to squash (or fixup) on the commits you want to fold:

bash
git rebase -i HEAD~5

That opens your editor with the last five commits listed oldest-first. Each line starts with pick. Leave the first line as pick, change the lines below it to squash or fixup, save, and Git collapses them into the commit above. If those commits were already pushed, you then have to force push the rewritten branch. That is the whole job. The rest of this page is the detail: the squash-vs-fixup difference, the one rule people get backwards, how to bail out, and the non-interactive shortcuts for flattening an entire branch.

If you arrived here from a wider "I made a mess, get me back" search, the undo things in Git guide is the hub for resets, reverts, and amends; this page is specifically about collapsing several commits into one.

The one rule: you squash into the line above

This trips up almost everyone the first time. squash and fixup do not act on the line they sit on as a standalone instruction. They fold that commit into the commit on the line above it. So the editor buffer looks like this:

bash
pick   a1b2c3d  Add login form
squash 4d5e6f7  Fix typo in label
squash 7g8h9i0  Tidy up the markup

That produces one commit. The first line stays pick because there is nothing above it to fold into; the two squash lines below collapse upward into it. If you mark the first line squash, Git errors with "cannot squash without a previous commit" because there is no parent in the range to absorb it. The mental model is "the topmost pick is the keeper, everything below it gets melted into it."

The list is oldest at the top, newest at the bottom, the reverse of git log. That ordering catches people too, so read the commit subjects, not the positions, before you change anything.

squash vs fixup: keep the messages or drop them

The only difference between the two is what happens to the commit messages:

  • squash keeps the message. After you save, Git opens a second editor with all the squashed commit messages concatenated so you can edit them down into one combined message.
  • fixup discards the message. The folded commit's log message is thrown away and only the message of the commit above survives. No second editor opens.

Use fixup when the lower commits are noise ("fix typo", "oops", "wip") and you only want the top commit's message. Use squash when each commit said something worth merging into the final message. You can mix them in one rebase:

bash
pick   a1b2c3d  Add password reset endpoint
squash 4d5e6f7  Add rate limiting to reset endpoint
fixup  7g8h9i0  Fix lint error

Here the rate-limiting message gets folded into the combined message you edit, and the lint fix is silently absorbed.

There is a faster path for the fixup case. If you commit a fix and immediately know which earlier commit it belongs to, tag it at commit time and let Git wire up the rebase for you:

bash
git commit --fixup=a1b2c3d
git rebase -i --autosquash HEAD~5

--autosquash reorders the fixup! commit directly under its target and pre-marks it fixup, so the editor buffer is already correct and you just save. Set git config --global rebase.autosquash true to make it the default for every interactive rebase.

If it goes wrong, abort

A rebase that hits a conflict, or one where you realize the plan was wrong, is fully reversible until you finish it. To throw the whole thing away and put the branch back exactly where it was:

bash
git rebase --abort

This returns HEAD and the working tree to the pre-rebase state. Nothing is lost. If instead you hit a conflict you do want to resolve, fix the files, git add them, and run git rebase --continue. Reach for --abort whenever you are unsure: it is the safe exit, and the original commits are still in the reflog even after a rebase completes, so a botched squash is recoverable with git reflog either way.

The editor that opens for the rebase plan is whatever Git is configured to use. In some non-interactive shells, cron jobs, or minimal containers there is no editor and the rebase fails to start. Set one for that invocation:

bash
GIT_EDITOR=nano git rebase -i HEAD~5

GIT_EDITOR overrides the configured editor for that one command. For a permanent setting use git config --global core.editor "nano" (or "code --wait", "vim", whatever you actually use).

Squash a whole feature branch in one move

If the goal is "turn my entire feature branch into a single commit on main," you do not need the interactive editor at all. git merge --squash stages all the branch's changes as one uncommitted set, which you then commit yourself:

bash
git switch main
git merge --squash my-feature
git commit -m "Add the feature, squashed"

This takes every commit on my-feature and flattens it into one staged changeset on main, no rebase, no per-line editing. The difference from interactive rebase is scope: rebase -i lets you squash a subset and keep some commits separate, whereas merge --squash collapses the whole branch into one. It also leaves my-feature untouched, so the original commits still exist on that branch if you want them. This is the option to reach for when you do not care about the intermediate history at all.

Or just reset to a base and recommit

A third approach skips both rebase and merge. Move the branch pointer back to where it diverged, keeping all the work staged, then make one fresh commit:

bash
git reset --soft main
git commit -m "All my feature work in one commit"

git reset --soft moves HEAD to main but leaves the index and working tree exactly as they are, so every change from every commit since the branch point is sitting staged, ready for a single commit. This is the bluntest way to flatten a branch and the easiest to reason about: no plan file, no conflicts to walk through. The trade-off is that it is all-or-nothing, like merge --squash, so use it when you want one commit and nothing else.

After squashing a pushed branch, you must force push

Squashing rewrites history: the commits you folded no longer exist, replaced by new ones with new SHAs. If the branch was only ever local, a plain git push works. But if you had already pushed it, the remote still has the old commits, and a normal push is rejected because your local history is no longer a fast-forward of the remote. You have to overwrite the remote branch:

bash
git push --force-with-lease origin my-feature

Use --force-with-lease, not a bare --force. The lease refuses the push if someone else pushed to the branch since you last fetched, which protects you from silently clobbering a teammate's commits. The full safety story, including when even --force-with-lease is not enough, is in the safe force-push guide. And the rule that goes with it: squashing already-pushed commits on a shared branch (especially main) rewrites history other people have built on, which is antisocial and breaks their clones. Squash freely on your own feature or PR branches; leave shared history alone.

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

Tagsgitsquash commitsinteractive rebasegit rebasefixupgit merge --squashDevOpsCLI

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Find (and Delete) Empty Directories and Files

find . -type d -empty lists every empty directory; find . -type f -empty lists every empty file. The catch is what 'empty' means (a hidden file makes a directory not empty) and the -depth trap that lets find -delete collapse whole nested empty trees in one pass. The flag reference, the safe two-pass cleanup, the BSD vs GNU notes, and the mistakes that bite.

How to Count Rows in an ACF Repeater Field

Counting ACF Repeater rows is three short patterns: count() on the raw field, get_field_count() inside a loop, and a faster meta-only count that skips loading the rows. Each has its right use case.