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 run git commit yourself
  • git commit --fixup=<commit> — create a commit specially marked as a fixup for an earlier commit
  • git rebase -i --autosquash <base> — automatically reorders and marks any --fixup/--squash commits in the todo list
  • git 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~3 into a detached HEAD state — 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 detached HEAD as its parent; the detached HEAD then advances to that new commit.
  • For a squash line, 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 reattaches HEAD to that branch.
  • The old commits are no longer referenced by any branch, so they vanish from git log, but their objects remain in .git/objects and are listed in git reflog until 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 --fixup plus git rebase -i --autosquash for "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 reflog in mind as your safety net — if a squash goes wrong, git reset --hard ORIG_HEAD right 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 main or any branch teammates have already pulled from
  • Use git push --force-with-lease, never bare git 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~4 to squash them into a single commit with a clear Conventional Commits message. Verify with git log --oneline that 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 run git rebase -i --autosquash HEAD~3 and confirm the fixup was folded in automatically without you reordering anything.
  • Simulate merging a finished feature branch into main as one commit using git merge --squash instead of rebasing the feature branch. Compare the resulting git log --oneline --graph on main to 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> with squash/fixup lines in the todo list is the standard way to squash local commits
  • squash keeps and lets you edit the combined message; fixup discards the folded commit’s message silently
  • git merge --squash and git reset --soft are squashing techniques that don’t require interactive rebase
  • git commit --fixup plus git rebase -i --autosquash automates 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-lease over bare --force whenever you must push rewritten history