git squash commits combines several sequential commits into one, rewriting the tail of your branch's history so it reads as a single, clean change instead of a dozen work-in-progress checkpoints. There is no git squash command — Git ships no dedicated git squash subcommand — it's a result you reach through interactive rebase, git merge --squash, or git reset --soft, and each path leaves a slightly different footprint. This guide covers all three, plus the parts most tutorials skip: the exact difference between squash and fixup in a rebase todo list, how to squash commits on a branch down to just the last 2 or last 4, autosquash with fixup! commits, verifying the squashed result before you force-push, and recovering from a squash gone wrong with git reflog. If you only need to fix the wording on your most recent commit rather than combine several, amending a single commit is the narrower, simpler tool.

The Fast Answer: Squash the Last N Commits

Most git squash commits questions collapse into one decision: do you want to pick exactly which commits combine and edit each message, or do you just want everything gone in one shot? Two commands, one for each answer.

# Full control — choose which commits squash into which, edit messages as you go
git rebase -i HEAD~3

# Fast path — squash all N commits into exactly one, no per-commit review
git reset --soft HEAD~3
git commit -m "Combined commit message"

The git squash command people usually mean is the first one: HEAD~3 tells git rebase -i to open an editable list of the last 3 commits, oldest at the top; changing pick to squash (or the shorthand s) on every line but the first folds that commit into the one above it. The second form skips the editor entirely — git reset --soft HEAD~3 moves your branch pointer back 3 commits without touching the working tree or index, so every change from those 3 commits ends up staged as one diff, ready for a single git commit. Neither command is Git-specific magic; squashing is just a name for the result, reached through ordinary rebase or reset commands Git already ships with.

Already pushed the commits you want to squash? Skip ahead to force-pushing the result — squashing rewrites commit hashes, so the remote needs a force push, not a normal one, once you're done.

What Squashing Actually Does to Your History

Squashing takes the changes introduced by a range of consecutive commits and repackages them as the diff of a single new commit. The code that results is identical to what you'd have after all the original commits applied in order — squashing doesn't add or remove any change, it only changes how many commit objects hold that change and how the log reads. People search for this under several names — git collapse commits, git combine commits, git join commits — and all of them describe the exact same operation Git itself just calls squashing.

The part that trips people up: every commit involved in a squash gets a brand-new SHA-1 hash, including any commits after the squashed range that stay on the branch. Git computes each commit's hash from its content, its parent's hash, and metadata — change the parent, and the hash changes too, even if that later commit's own diff never touched a single line. That's why squashing counts as a history rewrite exactly like undoing the last commit or an ordinary rebase: your code doesn't change, but every commit object downstream of the squash point is a genuinely new object with a new identity. Anyone who already has the old commits — because you pushed them, or they cloned your branch — now has a divergent history the moment you push the squashed version, which is exactly what makes force-pushing necessary later in this guide. The Pro Git book's Rewriting History chapter documents the same rule from Git's own side.

Squashing 3 Commits Into 1: Same Code, Fewer Commits BEFORE — 3 commits, 3 hashes C0 A B C 9f8e7d6 base a1b2c3d Add login form e4f5g6h Fix typo i7j8k9l Add validation AFTER — 1 commit, 1 new hash C0 D 9f8e7d6 base f4a9c21 A + B + C, one diff a1b2c3d · e4f5g6h · i7j8k9l old hashes now unreachable Working tree ends up identical — only commit count and hashes change
Squashing folds A, B and C into one new commit D with the combined diff — the original three hashes existed a moment ago, but nothing on the branch points at them anymore.

Interactive Rebase: The Standard Way to Squash Commits

git rebase -i (interactive rebase) is the standard answer to how to squash commits on a branch when you want control over which ones combine, in what order, and what the resulting message says. Point it at how far back you want to look, and Git opens your configured editor with a todo list — the git rebase squash workflow starts and ends in that file.

git rebase -i HEAD~3

The editor shows something like this — oldest of the three commits at the top, HEAD at the bottom:

pick a1b2c3d Add login form
pick e4f5g6h Fix typo in login form
pick i7j8k9l Add login form validation

# Rebase 9f8e7d6..i7j8k9l onto 9f8e7d6 (3 commands)
# Commands: p, pick, r, reword, e, edit, s, squash, f, fixup, ...

Change pick to squash (or the single-letter shorthand s) on the second and third lines, save, and close the editor:

pick a1b2c3d Add login form
squash e4f5g6h Fix typo in login form
squash i7j8k9l Add login form validation

Git then opens a second editor prefilled with all three commit messages concatenated, letting you write one combined message before it creates the squashed commit. git rebase -i opens whatever $GIT_EDITOR or core.editor points to — if neither is set, Git falls back to Vim, which trips up plenty of people expecting Nano or VS Code. Set it once:

git config --global core.editor "code --wait"
# or: git config --global core.editor "nano"

Interactive rebase also refuses to start while uncommitted changes are sitting in the working tree, so stash them first and pop the stash once the squash is done. If a squashed commit conflicts with the one before it, Git pauses mid-rebase exactly like an ordinary rebase conflict: resolve the files, git add them, then git rebase --continue. If it's not worth finishing, git rebase --abort puts every commit back exactly where it was, no partial state left behind — which is what makes git rebase squash commits safe to attempt on a branch you're unsure about. Every todo-list keyword, including the ones this guide skips, is defined in the official git-rebase documentation.

pick → squash in the Rebase Todo List First editor — the todo list pick a1b2c3d Add login form squash e4f5g6h Fix typo squash i7j8k9l Add validation line 1 stays "pick" — everything below it folds upward into it save and close the editor to continue save & close Second editor — combine messages # combination of 3 commits Add login form Fix typo Add validation edit into one final message, then Git creates the squashed commit fixup instead of squash skips this second editor entirely
Marking lines squash reorders nothing — it just tells Git to fold each one into the pick above it and open a second editor to combine their messages.

squash vs fixup in the Rebase Todo List

squash and fixup do the same combining work — fold a commit into the one before it — and differ only in what happens to the folded commit's message.

  • squash (or s) keeps both messages and opens an editor so you can combine them into one.
  • fixup (or f) discards the fixup commit's message entirely and keeps only the message from the commit it's folding into — no editor prompt at all.
pick a1b2c3d Add login form
fixup e4f5g6h Fix typo in login form
squash i7j8k9l Add login form validation

That mix produces one commit titled "Add login form," silently absorbing the typo fix, but still pausing once to let you fold in whatever "Add login form validation" said. fixup is the right default for commits whose own message adds nothing — "wip," "typo," "address review comment" — while squash earns its extra step when the folded commit's message actually says something worth keeping. Getting this backwards isn't destructive, just annoying: picking squash where you meant fixup means an extra editor pass to manually delete a line you didn't want.

Squashing the Last 2 Commits (and the Last 4)

Squashing the last 2 commits is the single most common version of this question — people type it as git squash last 2 commits, git squash last two commits, or git combine last two commits, and it's one operation either way. The mechanics don't change from the general case, just the number after HEAD~.

# Interactive — see and edit both messages
git rebase -i HEAD~2
# change the second line's "pick" to "squash" or "fixup"

# Fast path — no editor for the individual commits
git reset --soft HEAD~2
git commit -m "Combined commit message"

To squash last 4 commits into one, or any other specific count, the process is identical — swap the number:

git rebase -i HEAD~4
# mark the last 3 lines as squash or fixup

# or, to skip straight to one commit:
git reset --soft HEAD~4
git commit -m "Combined commit message"

Both approaches produce the same end state — one commit replacing N — but git reset --soft gets there in two commands total regardless of N, while git rebase -i costs one editor round trip per commit you mark. For 2 commits the difference barely registers; for squashing 10 or 20 commits accumulated over a long-lived branch, reset --soft is meaningfully faster. The shape of the command never changes — git squash two commits, squash last 4 commits, or collapse a 30-commit branch, and only the number after HEAD~ moves.

git merge --squash: Collapse a Whole Branch Into One Commit

git merge --squash answers a different question than the sections above: instead of squashing a few commits within a branch, it collapses an entire feature branch's commits into a single commit on whatever branch you're merging into.

git checkout main
git merge --squash feature-login
# stages every change introduced across all of feature-login's commits
# writes no commit yet — that's still your call

git commit -m "Add login flow with retry and validation"

Running this from the command line has the same effect as squashing every commit in a GitHub branch through a pull request, just without one: main gets exactly one new commit containing feature-login's combined diff, and feature-login itself is completely untouched — its original commits, with their original hashes, still exist on that branch. The catch: git merge --squash deliberately doesn't record that main and feature-login share history the way a normal merge would, so git branch --merged won't list feature-login as merged, and merging it normally later would try to reapply every one of its changes again as fresh conflicts — the kind of pile-up aborting the merge exists for. Delete the feature branch once the squashed commit lands, or don't merge it again the ordinary way.

git merge --squash: One New Commit on main, Branch Untouched main M0 S new commit combined diff of 3 commits feature-login 1 2 3 3 commits — untouched, original hashes git merge --squash reads the combined diff (no merge parent recorded) main gets one new commit — feature-login's three commits still exist, unchanged, on their own branch
git merge --squash reads feature-login's combined diff into one new commit on main — it never records a merge relationship, so the feature branch's own three commits stay exactly as they were.

git reset --soft: The Fastest Way to Squash All Commits Into One

git reset --soft is the fastest way to squash all commits into one when you don't need to review or edit each one individually. The --soft flag is the reason it works this way: it moves the branch pointer back N commits but leaves the index and working tree exactly as they were — so every file change across the commits you just detached from ends up staged, as one diff, in a single step.

git reset --soft HEAD~5
git commit -m "Squashed 5 commits into one"

Contrast that with --mixed (unstages but keeps working tree files) or --hard (discards everything, staged or not) — the fuller breakdown of what each flag preserves is in the guide to git reset --hard. Only --soft leaves you standing directly in front of a ready-to-commit staged diff.

To squash all commits into one since a branch diverged from main, without counting how many that is:

git reset --soft $(git merge-base HEAD main)
git commit -m "Add login flow"

git merge-base HEAD main finds the exact commit where the two branches split, so this squashes everything unique to your branch — however many commits that turns out to be — in one command, no HEAD~N counting required.

--soft vs --mixed vs --hard: What Moves, What Stays reset flag → --soft --mixed --hard Branch pointer moves to HEAD~N moves to HEAD~N moves to HEAD~N Staging (index) kept staged ready to commit unstaged kept in tree wiped matches HEAD~N Working tree files untouched nothing lost untouched edits stay on disk wiped matches HEAD~N All three move the branch pointer identically — the difference is the index and working tree Only --soft leaves the combined diff staged and ready for one new commit
Same branch-pointer move for all three flags — --soft is the only one that leaves the detached commits' changes staged and ready to become the squashed commit in one step.

Which Method to Use

Four ways to reach the same visual result — a clean, single commit — with different mechanics and different requirements once you push.

Method What It Does Best For Keeps Original Commits? Needs Force-Push?
git rebase -i Rewrites a chosen range, combining only the commits marked squash/fixup Squashing a subset with per-commit control and message editing No — replaced with new hashes Yes, if already pushed
git merge --squash Collapses an entire branch's commits into one new commit on the target branch Landing a finished feature branch as one commit on main Yes — untouched on the original feature branch No — it's a normal forward commit on the target
git reset --soft + commit Detaches HEAD from N commits, stages their combined diff, commits it as one Squashing everything at once with no need to review individual commits No — replaced with one new commit Yes, if already pushed
GitHub/GitLab squash-merge button Same effect as merge --squash, run server-side at merge time Enforcing a one-commit-per-PR policy without touching contributor history Yes — visible in the PR/MR's own commit list No — the platform does it as part of merging

The force-push column is the one worth remembering: git rebase -i and git reset --soft rewrite commits that might already be on a remote, so pushing the result needs --force-with-lease. git merge --squash and the GitHub/GitLab squash-merge button both add a fresh commit on top of the target branch instead of rewriting anything already there — that's a normal push, not a rewrite, which is exactly why teams that squash-merge PRs never need to touch a contributor's local branch at all.

Autosquash: fixup! and squash! Commits

Autosquash automates the "mark this line as fixup/squash" step for the common case of fixing something you committed a few commits back, without hand-editing the rebase todo list.

# Create a commit tagged as a fixup for an earlier one
git commit --fixup=e4f5g6h
# subject line becomes: "fixup! Add login form"

# Rebase with autosquash — fixup! commits move next to their target automatically
git rebase -i --autosquash HEAD~6

With --autosquash, the todo list Git opens already has the fixup! commit reordered directly under e4f5g6h and marked fixup — nothing left to edit manually unless something else also needs attention. git commit --squash=<sha> works the same way with a squash! prefix instead, and additionally opens the message editor to combine both, same as a manual squash line would. Make it permanent instead of typing --autosquash every time:

git config --global rebase.autoSquash true

With that set, a plain git rebase -i HEAD~6 behaves as if --autosquash was passed on every call, so tagging commits with --fixup or --squash as you go is enough — the reordering happens without you touching the todo list at all.

Verifying the Squashed Result Before You Push

A squash should change nothing about your code — only the number of commits and the log message. That's a testable claim, not just an assumption, and worth actually testing before a force-push makes the rewrite permanent for everyone else.

# Before squashing, tag a safety net at the current tip
git branch backup-feature-login

git rebase -i HEAD~4
# ... complete the squash ...

# Prove the resulting tree is identical — this should print nothing
git diff backup-feature-login

An empty git diff against the backup branch means the squashed branch's files are byte-for-byte identical to before — only the commit graph changed. Any output there means something shifted during the squash — a line marked squash that should've been fixup, a conflict resolved wrong mid-rebase — and it needs a second look before that becomes the remote's permanent history.

For a specific file, or when the bulk diff is too long to parse quickly, pull both exact versions as plain text and read them side by side instead of parsing a terminal diff:

git show backup-feature-login:src/auth/login.ts > /tmp/before.ts
git show HEAD:src/auth/login.ts > /tmp/after.ts

Diff Checker, a free Chrome extension (also at diffchecker.pro), can't diff commits directly — it doesn't know what a commit is, and it isn't a Git client — but it's built for exactly this kind of pasted-dump comparison: paste before.ts into one editable pane and after.ts into the other, pick Smart Diff, Ignore Whitespace, or Classic (LCS), and it renders a side-by-side or unified diff with syntax highlighting across 17 languages. "Show Diff Only" with the context-lines picker (0, 1, 2, 3, or 5) collapses everything unchanged, so if the squash actually did alter something, it's the first thing you see instead of the last. Once you've confirmed the diff is clean, git branch -D backup-feature-login clears the safety net.

Verify the Squash: Diff Against a Pre-Squash Backup git branch backup-feature-login before rebasing git rebase -i HEAD~4 (squash the commits) after squashing git diff backup-feature-login empty output = tree identical — only the commit graph changed Any other output means something shifted mid-rebase — look before pushing
Tag a backup before rebasing, squash, then diff against it — empty output is proof the tree is byte-for-byte identical and only the commit graph changed.

Pushing a Squashed Branch: --force-with-lease, Not --force

Every squash method except git merge --squash and a platform's squash-merge button rewrites commit hashes. If any of the squashed commits were already pushed, the remote branch and your local one have diverged on purpose, and a normal git push gets rejected as non-fast-forward. That's expected — it's the same rejection covered in the guide to pushing to a remote branch, just deliberately triggered this time instead of by surprise.

git push --force-with-lease origin feature-login

--force-with-lease overwrites the remote branch to match your squashed history, but only after confirming the remote's current tip still matches what your local origin/feature-login remote-tracking ref last recorded. If a teammate pushed to that branch since your last fetch, the push is refused instead of silently erasing their commit. Plain --force skips that check entirely and overwrites regardless — reach for --force-with-lease as the default, and run git fetch immediately before pushing if there's any doubt the lease check is comparing against something current. For the exact hash instead of "whatever I last fetched," --force-with-lease=<branch>:<expected-commit> pins it explicitly; Git 2.30+ also adds --force-if-includes, which additionally checks your reflog to confirm any remote commits you're about to discard are ones you've actually already integrated somewhere in your own history.

Squashing a branch with several people's stacked work on top of it has its own wrinkle: rebasing the base branch leaves any dependent branches pointing at commits that no longer exist on the rewritten line. git rebase --update-refs (Git 2.38+) moves those dependent branch tips along automatically during the same rebase, instead of leaving them orphaned on the old history.

Squashing on GitHub, GitLab and Bitbucket

Every major host offers a squash option built into its pull/merge request merge action, and all three apply it to the target branch, not the contributor's source branch.

  • GitHub: the merge button's dropdown includes "Squash and merge" — the route most teams actually use to squash every commit in a branch. It combines every commit in the PR into one commit on the base branch, prefilling a combined message from the individual commit titles that you can edit before confirming.
  • GitLab: merge requests have a "Squash commits when merging" checkbox (or a project-wide default), producing the identical result on the target branch.
  • Bitbucket Cloud: the pull request merge dropdown offers Squash as one of three strategies, alongside Merge commit and Fast forward.

The distinction worth remembering, since it answers the GitHub squash-merge button vs local squash question directly: none of these three require the contributor to touch their own branch. The squash happens server-side, once, at merge time, and only affects how the change lands on the target branch — the source branch's original commits stay exactly as they were, still visible in the PR's own commit history tab. Squashing locally with git rebase -i before you even open a PR is a different, earlier step — useful if you want the PR itself to already show one clean commit, but never required, since the platform will squash a messy branch just as well at merge time. That's why there are two correct answers for squashing commits on a branch: rewrite the branch yourself before the PR, or let the merge button do it once at the end. Working from a PR someone else opened first means checking out that remote branch locally before any of this applies.

Undoing a Botched Squash With git reflog

Squashing doesn't delete the original commits immediately — it just stops pointing at them. Until Git's garbage collector actually prunes unreferenced objects (by default, not before gc.reflogExpire's 90-day window for reachable entries), they're recoverable through git reflog, which records every position HEAD has held on this machine.

git reflog
# a1b2c3d (HEAD -> feature-login) HEAD@{0}: rebase (finish): returning to refs/heads/feature-login
# a1b2c3d HEAD@{1}: rebase (pick): Add login form
# f9e8d7c HEAD@{5}: commit: Add login form validation
# e6d5c4b HEAD@{6}: rebase (start): checkout HEAD~4

git reset --hard HEAD@{6}

The entry logged right before "rebase (start)" is your pre-squash state — reset --hard to that reflog reference restores every original commit, with its original hash, exactly as it was before the rebase touched anything. This works because those commits were never deleted, only unreferenced by any branch; git reflog is the map back to them. Two caveats worth knowing before relying on this: git reflog is entirely local, never pushed, so it only helps on the machine where the squash actually ran, and it's a moving target — any later rebase, reset, or commit adds new entries on top, so recover before you do much else. If you're unsure which entry is the right one, git reflog show HEAD --date=iso adds timestamps to help pick.

git reflog: Finding the Pre-Squash State git reset --hard HEAD@{6} 6 HEAD@{6} rebase (start) ← recovery target 1–5 HEAD@{1..5} picks + a commit 0 HEAD@{0} rebase (finish) current HEAD Original commits are never deleted, just unreferenced — reflog is the map back to them
HEAD@{6} is the reflog entry right before the rebase started — git reset --hard against it restores every original commit, with its original hash, on this machine.

When Not to Squash

Squashing is a good default for a messy branch about to become a permanent part of main, but it's the wrong move in a few specific situations:

  • The commits are already pushed to a branch others are building on. Rewriting shared history forces every collaborator to reconcile diverging commits, usually through a confusing force-push conversation. If you're not certain, squash-merging through a pull request instead is the safer version — it never touches the shared branch's own history.
  • You rely on git bisect for this project. Bisect walks commit-by-commit through history looking for the change that introduced a bug; squashing several logically distinct changes into one commit means bisect can only tell you "somewhere in this pile," not which specific change.
  • Reviewers, or your team's convention, expect incremental commits. Some review processes are built around reading a PR commit-by-commit rather than as one combined diff — squashing removes that granularity permanently once merged.
  • You're not sure whether anyone else has the branch. Check with git log origin/<branch>..HEAD before rewriting anything — if the commits you want to squash don't show up there, they're still local-only and safe to rewrite freely.

None of this argues against squashing in general — it argues for checking whether a commit has left your machine before deciding how to clean it up. If you decide not to squash but still want just one specific commit out of a messy branch, cherry-picking that single commit gets you the same clean result without touching anything else.

Frequently Asked Questions

What's the fastest way to squash the last N commits?

git reset --soft HEAD~N followed by git commit -m "message" is the fastest path — it skips the rebase editor entirely and combines all N commits into one in two commands, whether you git squash 2 commits or twenty. git rebase -i HEAD~N is the alternative when you want to review or edit individual commits along the way rather than combining everything blindly.

What's the difference between squash and fixup in a rebase?

Both fold a commit into the one above it during an interactive rebase; the only difference is what happens to the message. squash keeps both commit messages and opens an editor so you can combine them; fixup discards the folded commit's message entirely and keeps only the message from the commit it's merging into, with no editor prompt at all.

Should I squash commits before or after pushing?

Before, whenever that's an option — squashing unpushed commits is a plain rewrite with no force-push required afterward. Squashing commits that are already on a shared remote still works the same way locally, but pushing the result needs git push --force-with-lease instead of a normal push, since the rewritten commits no longer share history with what's already there.

Can I undo a squash after force-pushing it?

Locally, yes — git reflog still has the entry from right before the rebase started, and git reset --hard against that entry restores every original commit on your machine. The catch is the remote: if you've already force-pushed the squashed version, restoring locally and pushing again is itself a second force-push, and anyone who already pulled the squashed history has to reconcile it a second time.

What's the difference between git rebase squash and git merge --squash?

Both end with one commit, but on different branches. git rebase squash commits rewrites the branch you are standing on: the originals are replaced by new hashes, so a force-push is required if any of them were already shared. git merge --squash instead writes one new commit onto the branch you are merging into and leaves the source branch, its commits and its hashes completely untouched, so a normal push is enough. Use interactive rebase to clean up your own branch before review; use merge --squash to land a finished branch as a single commit.