Git Squash — Prevent Commit Loss with --force-with-lease
After a developer force-pushed a squashed commit to a shared branch, 3 commits vanished.
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
- git rebase -i HEAD~N: full control — pick, squash, fixup, reorder, reword
- git merge --squash: stages all branch changes as one set — no merge commit
- git reset --soft HEAD~N: moves HEAD back, changes stay staged — fastest for simple squash
Squashing commits is like editing a draft before you submit it. During development you commit dozens of times — 'fix', 'fix again', 'actually fix'. Before merging, you collapse those into one clean, meaningful commit that tells the story of what changed, not how many times you changed direction. The individual drafts disappear; the final version survives.
Squashing combines multiple commits into one clean commit. It is the standard pre-PR operation that transforms messy development history (WIP commits, typo fixes, debug logging) into a single meaningful commit that answers 'what changed and why'.
Three methods serve different use cases: interactive rebase for surgical control, merge --squash for branch-level squashing, and reset --soft for the fastest simple squash. The choice depends on how much control you need over which commits are combined and whether intermediate messages should be preserved.
Common misconceptions: that squashing is always required before merging (it is a team convention), that squashing loses code (it combines commits, the code stays), and that squashing is safe on shared branches (it rewrites SHAs, which breaks teammates).
What Git Squash Actually Does to Your Commit History
Git squash is a rebase operation that combines multiple commits into a single commit. The core mechanic: during an interactive rebase (git rebase -i), you mark commits with 'squash' or 'fixup' to fold their changes into the preceding commit. Squash preserves the commit message for editing; fixup discards it entirely. This rewrites history by creating new commit objects with combined diffs and timestamps.
Squashing does not merge changes — it replays them. Git takes the diff from each squashed commit and applies it sequentially, then creates one new commit with the accumulated changes. The original commits become unreachable from any branch reference, though they remain in the object store until garbage collection. This means squashing is a destructive rewrite: any other branch or collaborator referencing the original commits will diverge.
Use squash to clean up a feature branch before merging into main — turning a dozen 'fix typo' and 'WIP' commits into one logical change. It's essential for maintaining a readable, bisectable history. In CI/CD systems, squashing prevents noisy commit logs from triggering unnecessary build steps and makes rollbacks atomic. Never squash commits that have already been pushed to a shared branch unless you coordinate with the entire team.
Squash with Interactive Rebase
Interactive rebase is the surgical tool. git rebase -i HEAD~N opens an editor showing the last N commits. Change pick to squash (or s) on any commit to fold it into the one above it. fixup (or f) squashes without keeping the commit message — useful for minor fixes.
The mental model: the topmost commit in the editor is the oldest. Commits marked squash get folded into the commit immediately above them in the list.
- squash: folds commit into parent, opens editor to combine messages
- fixup: folds commit into parent, silently discards the folded message
- Use fixup for WIP, typo fixes, debug logging — messages that add no value
- Use squash when the intermediate message contains context worth preserving
Squash with git merge --squash
When merging a feature branch, git merge --squash feature/branch takes all commits from the branch and stages them as a single set of changes without creating a merge commit. You then write one commit message for the whole thing.
This is what GitHub does when you press 'Squash and merge'. The difference: GitHub creates the squashed commit for you; git merge --squash stages the changes and makes you commit manually.
- merge --squash stages all branch changes without creating a merge commit
- You write the commit message manually — full control
- GitHub's Squash and Merge automates this with PR title/description as the message
- After squash-merge, delete the feature branch — it is no longer needed
Squash with git reset (Simplest for Small Cases)
For squashing a small number of commits on your current branch without opening an editor, git reset is the fastest path. Move HEAD back N commits while keeping the working directory changes staged.
- --force-with-lease: fails if remote has commits you do not know about
- --force: blindly overwrites remote — can lose teammate's commits
- Always use --force-with-lease. Never use --force on any branch.
- If --force-with-lease fails: fetch first, then rebase your squash on top of the new remote state
When NOT to Squash
Squashing is not always the right choice. There are specific scenarios where preserving the commit history is more valuable than a clean linear log.
Large features with meaningful intermediate commits: If your feature has 15 commits but each one represents a logical, testable unit (e.g., 'add database migration', 'add API endpoint', 'add frontend component'), squashing them into one commit loses the ability to bisect within the feature. A reviewer who wants to understand the feature's evolution benefits from seeing the individual commits.
Shared branches where others have pulled your commits: Squashing rewrites SHAs. If teammates have branched off your commits, their branches diverge after you squash and force-push. Never squash on branches that others have pulled.
Release branches with audit requirements: In regulated industries, the commit history on release branches may be part of the audit trail. Squashing removes the individual commits that show when each change was made and by whom. Check your compliance requirements before squashing on release branches.
Commits that will be cherry-picked: If a commit needs to be cherry-picked to another branch (e.g., a hotfix), it must exist as a standalone commit. Squashing it into a larger commit makes cherry-picking impossible.
- --force-with-lease: fails if remote has commits you do not know about
- --force: blindly overwrites remote — can lose teammate's commits
- Always use --force-with-lease. Never use --force on any branch.
- If --force-with-lease fails: fetch first, then rebase your squash on top of the new remote state
The Squash-Merge Trap: What the Docs Don't Tell You
Every new dev discovers git merge --squash and thinks they've found a cheat code. They haven't. They've found a landmine wrapped in convenience.
Here's what happens: git merge --squash feature-branch takes all commits from your feature branch, smashes them into your working directory as uncommitted changes, and leaves you hanging. No commit. No merge. No branch history link. You're holding a grenade with the pin pulled.
The docs call this "clean history." I call it losing the ability to trace which commits actually broke production. When your deployment fails at 3 AM, you want git bisect, not a single monolithic commit that says "Add billing system."
Use --squash only when the feature branch is truly atomic — one logical change, one commit worth of work. If you can't describe the entire branch's purpose in a single tweet, you're not squashing. You're bulldozing evidence.
git revert just one feature commit. You're stuck reverting the entire monolith or crafting a manual reverse commit. Your CI/CD pipeline just lost granular rollback capability.Squashing the Last Two Commits: The Incantation You'll Use Daily
You've shipped a hotfix. Then you realized you forgot to bump the version. Then you noticed a typo in the README. Now you have three commits that should be one.
Here's the command you'll have muscle memory for by next week:
git rebase -i HEAD~2
That -i flag opens your default editor with a list of the last two commits. Change pick to squash (or just s) on the second commit. Save. Exit. Done.
What happened under the hood? Git took your commit messages, combined the diffs, and opened a new editor to merge the messages. Write something descriptive like "fix: resolve race condition in auth module" — not "fix bug #4812 fix bug #4813."
One warning: if you've already pushed those commits, force-pushing a squashed history will make your teammates want to strangle you. Only squash local commits or commits on branches you own exclusively.
git rebase -i @~N instead of HEAD~N. The @ is a shorthand for HEAD that saves exactly two keystrokes each time — and saves you from typing HEAD 500 times over your career.git rebase -i HEAD~2. Change pick to squash on the second commit. Never force-push squashed commits to shared branches.Tips for a Smooth Squash
Most devs butcher a squash because they don't prep the battlefield. You're rewriting history — one misstep and you've nuked a coworker's branch. The rule: squash only commits that have never been pushed to a shared remote. If they have, you're asking for merge-conflict hell.
Before you rebase, run git log --oneline and count your commits. Know exactly how many you're squashing. Then git rebase -i HEAD~N where N is that count. In the editor, change every line after the first from 'pick' to 'squash' (or 's'). Save and exit — Git will prompt you for a new combined message. Write something meaningful, not 'fix stuff'.
Pro tip: if your branch is behind main after the squash, force-push is inevitable. Use git push --force-with-lease instead of --force. It checks if your remote branch has moved since you last fetched. If it has, Git aborts. That safety net has saved my ass more times than I'll admit.
git squash-head to git reset --soft HEAD~1 && git commit --amend. Squashes current commit into previous one — no interactive editor, no rebase.Squash on Shared Feature Branch: 4 Developers Lose Work When --force Overwrites Commits
git rebase -i HEAD~8 and squashed all 8 commits into 1.
4. While Developer A was rebasing, Developer B pushed 3 new commits to the same branch.
5. Developer A ran git push --force origin feature/payment-v2.
6. The --force flag overwrote Developer B's 3 commits on the remote.
7. Developer B's next fetch showed the branch had diverged — their local had 3 commits the remote did not have.
8. Developer B's 3 commits were only in their local reflog, not on the remote.git push --force-with-lease — this would have failed because the remote had commits Developer A did not know about.
3. Team rule: never squash on a branch that multiple developers are actively pushing to. Squash only after all developers have finished pushing.
4. Team rule: always use --force-with-lease, never --force.
5. Added branch protection to require all developers to use separate feature branches, not a shared branch.- Squashing on a shared branch rewrites SHAs. If teammates have pulled the old commits, their branches diverge.
- Always use --force-with-lease instead of --force. --force-with-lease fails if the remote has commits you do not know about.
- Never squash on a branch that multiple developers are actively pushing to. Squash only after developer per feature branch is the industry standard. Shared feature branches create unnecessary coordination overhead.
git reflog — look for your commit hashes.
3. Cherry-pick onto the squashed branch: git cherry-pick <hash> for each lost commit.
4. Prevention: use --force-with-lease. It fails if the remote has commits you do not know about.git reset --hard ORIG_HEAD to return to pre-rebase state.
4. Correct base: git log --oneline -10 to find the right commit hash, then git rebase -i <correct-hash>.git reflog — find the dropped commit hash.
3. Cherry-pick it back: git cherry-pick <hash>.
4. Then re-run the interactive rebase with 'squash' instead of 'drop'.git fetch origin to see what the remote has.
4. If the remote commits should be kept: rebase your squash on top of them.
5. If the remote commits are duplicates: coordinate with the teammate who pushed them.git commit --amend to rewrite the last commit message.
4. Prevention: use 'fixup' instead of 'squash' for commits whose messages you want to discard.git reflog | grep 'commit' (find your lost commit hashes)git cherry-pick <hash> (recover each lost commit onto the squashed branch)Key takeaways
Interview Questions on This Topic
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
That's Git. Mark it forged?
5 min read · try the examples if you haven't