Squashing Commits
Squashing commits means combining several individual commits into a single, unified commit. It’s one of the most common uses of Git’s interactive rebase, and it solves a real problem: as you work on a feature you naturally accumulate messy commits like "wip", "fix typo", and "actually fix it this time" that are useful to you while coding but add noise to the project’s permanent history. Squashing lets you clean that history up before anyone else has to read it, turning a dozen exploratory commits into one clear, well-described change.
Overview: What Squashing Actually Does
To understand squashing you need to remember what a commit actually is. Every commit object stores a pointer to a tree (a snapshot of the entire project at that point), a pointer to its parent commit (or commits, for a merge), author and committer metadata, and a message. Git identifies each commit by a SHA-1 hash of all of that content, so if anything about a commit changes — even just its parent — it gets a brand new hash.
Squashing does not merge commit objects together in place; Git has no such operation. Instead, it creates one new commit object whose tree is the final combined snapshot (the working tree state after all the squashed commits’ changes have been applied in order) and whose parent is whatever commit came before the range you squashed. The original individual commits still exist as objects in the repository’s object database, but once the branch pointer moves to the new commit, the old ones are no longer reachable from any branch or tag. They linger briefly and are visible in git reflog as a safety net, then eventually get cleaned up by garbage collection.
Squashing is almost always done through interactive rebase. git rebase -i <base> takes every commit after <base>, checks out the tree at <base> in a temporary detached HEAD, and replays each commit’s diff one at a time according to a "todo list" you edit in a text editor. By default every line says pick, meaning "apply this commit’s diff and create a new commit for it, unchanged." When you change a line to squash (or its abbreviation s), Git applies that commit’s diff to the index and working tree exactly like a pick would, but instead of creating a separate commit it folds the change into the commit immediately above it in the list, and opens your editor with the concatenated commit messages so you can write one combined message. fixup (f) behaves identically but silently discards the folded commit’s message. Note that the very first line of a rebase todo list can never be squash or fixup — there has to be a commit above it to fold into.
Because every commit from the rebased point onward gets a new hash, rebasing (and therefore squashing via rebase) rewrites history. This is Git’s golden rule of rebasing: never rebase or squash commits that other people have already pulled and built work on top of. Doing so rewrites the branch’s history out from under them, and their local copy diverges from yours in a way that causes duplicate commits and confusing conflicts when they next pull.
Squashing doesn’t require interactive rebase at all. Two other common techniques accomplish the same goal: git merge --squash <branch>, which stages the combined diff of an entire branch as a single change ready to commit into the branch you’re merging into, and a manual git reset --soft <commit> followed by one git commit, which rewinds the branch pointer while leaving all the changes staged in the index.
Syntax
git rebase -i <base>
Opens your editor with a todo list covering every commit between <base> (exclusive) and your current HEAD (inclusive), oldest first. <base> is usually written as HEAD~N for the last N commits, or a specific commit hash or branch name.
| Todo command | Effect |
|---|---|
pick (p) |
Keep this commit as its own commit, unchanged |
reword (r) |
Keep the commit’s changes but stop to edit its message |
edit (e) |
Apply the commit, then pause so you can amend it further |
squash (s) |
Fold this commit into the previous one; combine messages and prompt for a new one |
fixup (f) |
Fold this commit into the previous one; discard this commit’s message |
drop (d) |
Remove the commit entirely |
exec (x) |
Run a shell command at this point in the sequence (e.g. run tests) |
Related commands used for squashing without interactive rebase:
git merge --squash <branch>— stage the combined diff of<branch>into the current branch as one pending change; you still rungit commityourselfgit commit --fixup=<commit>— create a commit specially marked as a fixup for an earlier commitgit rebase -i --autosquash <base>— automatically reorders and marks any--fixup/--squashcommits in the todo listgit reset --soft <commit>— move the branch pointer back to<commit>while keeping every subsequent change staged
Examples
Example 1: Squashing the last three commits interactively
Suppose your feature branch has three messy commits you want to present as one.
git switch feature/login-page
git log --oneline -3
Output:
7c8d9e0 wip: adjust button styling
e4f5a6b fix typo in login form
a1b2c3d feat: add login form
git rebase -i HEAD~3
Git opens your editor with a todo list like this:
pick a1b2c3d feat: add login form
squash e4f5a6b fix typo in login form
squash 7c8d9e0 wip: adjust button styling
You leave the first line as pick and change the other two to squash, then save and close the editor. Git immediately reopens a second editor containing all three original messages concatenated, inviting you to write the final one:
feat: add login form with client-side validation and styled button
After saving, run git log --oneline -1 and you’ll see a single new commit with a brand new hash:
f9a0b1c feat: add login form with client-side validation and styled button
The three original commits are gone from the branch’s history (though still recoverable briefly through git reflog), replaced by one clean commit.
Example 2: Squashing an entire feature branch with merge –squash
If you’d rather squash at merge time instead of rebasing the feature branch itself, use git merge --squash from the branch you’re merging into:
git switch main
git merge --squash feature/login-page
git commit -m "feat: add login page with validation"
Output of the merge step:
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested
git merge --squash computes the combined diff of every commit unique to feature/login-page and stages it in your index, but deliberately does not create a commit or record any merge relationship — that’s why it says "not updating HEAD." You then commit that staged diff as one ordinary commit on main. Unlike a normal merge, no merge commit is created and main‘s history gains only a single new commit, as if you had typed the whole feature by hand in one sitting. This is exactly what GitHub’s "Squash and merge" pull request button does under the hood.
Example 3: Fixup and autosquash for ongoing work
While actively developing you’ll often realize a fix belongs in an earlier commit, not as a new one. Rather than manually reordering an interactive rebase todo list, mark the fix as a fixup for that commit:
git commit --fixup=e4f5a6b
git rebase -i --autosquash HEAD~4
Git generates the todo list itself, already reordered with the fixup line placed directly beneath its target:
pick a1b2c3d feat: add login form
fixup 1d2e3f4 fixup! feat: add login form
pick e4f5a6b fix typo in login form
pick 7c8d9e0 wip: adjust button styling
You don’t have to touch the file at all — just save and close it. Git folds the fixup commit into a1b2c3d and discards its throwaway message automatically. This pairing of --fixup and --autosquash is the cleanest way to keep "oops" commits organized without hand-editing the rebase list every time.
How It Works Step by Step
When you run git rebase -i HEAD~3 with two lines marked squash, Git internally does the following:
- Records the current branch tip and determines the commit range (
HEAD~3..HEAD) that the todo list will cover. - Checks out the commit at
HEAD~3into a detachedHEADstate — you are temporarily not on any branch. - Processes the todo list top to bottom. For a
pick, it applies that commit’s diff to the index and working tree and immediately creates a new commit object with the current detachedHEADas its parent; the detachedHEADthen advances to that new commit. - For a
squashline, it applies the diff the same way but does not commit yet. Instead it appends that commit’s message to a buffer of pending messages and waits. - Once it reaches the next
pick(or the end of the list), it creates one commit containing everything accumulated since the last real commit, opens your editor pre-filled with the concatenated messages, and lets you write the final message. - After the whole list is processed, Git moves your original branch reference (e.g.
refs/heads/feature/login-page) to point at the last newly created commit, and reattachesHEADto that branch. - The old commits are no longer referenced by any branch, so they vanish from
git log, but their objects remain in.git/objectsand are listed ingit refloguntil Git’s garbage collector eventually prunes them.
Common Mistakes
Mistake 1: Squashing commits that have already been pushed and pulled by others.
git push origin feature/login-page
# a teammate pulls and starts committing on top
git rebase -i HEAD~3
git push --force
Because rebasing rewrites every squashed commit’s hash, forcing this history onto the shared branch strands your teammate’s local commits on the old, now-orphaned history. Their next pull produces duplicate commits and confusing conflicts. Fix: only squash commits that exist solely on your own local or personal-fork branch, before anyone else has based work on them. If you absolutely must rewrite a shared branch, coordinate with the team first and have everyone re-sync afterward.
Mistake 2: Marking the first line of the todo list as squash.
squash a1b2c3d feat: add login form
squash e4f5a6b fix typo in login form
Rebase aborts with an error because there’s nothing above the first line to fold into. Fix: the topmost line in any squash block must stay pick; only the lines below it become squash or fixup.
Mistake 3: Accepting the default concatenated message without editing it. Leaving the combined message editor untouched produces a final commit message that’s literally "feat: add login form\n\nfix typo in login form\n\nwip: adjust button styling" — the exact noise squashing was supposed to remove. Fix: always rewrite it into one clear, conventional-commit-style summary of the whole change.
Mistake 4: Leaving conflict markers in a squashed commit. If a squash step conflicts, Git pauses mid-rebase with markers like <<<<<<< HEAD in the affected files. Fix: resolve every marker, stage the files, and run git rebase --continue before assuming the squash finished — a commit that still contains conflict markers has silently broken code, not resolved code.
Best Practices
- Squash local, unpublished commits before opening a pull request, not after it’s already been reviewed by others on that exact history
- Use
git commit --fixupplusgit rebase -i --autosquashfor "oops" fixes instead of manually reordering the todo list - Write the final combined message as a proper summary of the whole change, ideally following Conventional Commits (
feat: ...,fix: ...) rather than concatenating the originals - Keep
git reflogin mind as your safety net — if a squash goes wrong,git reset --hard ORIG_HEADright after the rebase restores the pre-rebase state - For pull requests on GitHub, consider letting the "Squash and merge" button do the work instead of rebasing locally, especially for small feature branches
- Never squash or rebase
mainor any branch teammates have already pulled from - Use
git push --force-with-lease, never baregit push --force, if you do need to update a remote branch after squashing — it refuses the push if someone else added commits you haven’t seen
Practice Exercises
- Create a new branch, make four small commits with intentionally messy messages (e.g. "wip", "fix", "fix again", "final feature commit"), then use
git rebase -i HEAD~4to squash them into a single commit with a clear Conventional Commits message. Verify withgit log --onelinethat only one new commit remains. - On a feature branch with two commits, add a third commit that fixes a mistake in the first one using
git commit --fixup=<first-commit-hash>, then rungit rebase -i --autosquash HEAD~3and confirm the fixup was folded in automatically without you reordering anything. - Simulate merging a finished feature branch into
mainas one commit usinggit merge --squashinstead of rebasing the feature branch. Compare the resultinggit log --oneline --graphonmainto what a normal (non-squash) merge would have produced.
Summary
- Squashing combines multiple commits into one new commit object with a single combined tree snapshot; the originals become unreachable, not literally merged
git rebase -i <base>withsquash/fixuplines in the todo list is the standard way to squash local commitssquashkeeps and lets you edit the combined message;fixupdiscards the folded commit’s message silentlygit merge --squashandgit reset --softare squashing techniques that don’t require interactive rebasegit commit --fixupplusgit rebase -i --autosquashautomates folding small fixes into earlier commits- Never squash or rebase commits that other people have already pulled and built work on — it rewrites shared history
- Prefer
git push --force-with-leaseover bare--forcewhenever you must push rewritten history
