It was a Thursday afternoon. Developer 1 had been working on a feature for four days. Clean commits, good code, reviewed and approved. He was about to open the final PR when he ran one command.
git rebase main
Then git push --force.
By the time anyone realized what had happened, four developers had lost their local branches. Two PRs were silently broken. One developer's two days of work had simply vanished from the remote. The CI pipeline was green — because it was running against old commits.
We spent three days untangling it. Here's exactly what happened, why it happens, and how we recovered — so you don't have to learn this the same way we did.
What git rebase Actually Does to History
Most explanations of rebase start with diagrams. Let's start with the thing that matters: rebase rewrites commits. It doesn't move them. It creates brand new commits with new hashes that contain the same changes.
Before rebase, your feature branch's commits have specific SHA hashes — say a1b2c3 and d4e5f6. After you rebase onto main, those same commits become x7y8z9 and m1n2o3. Different hashes. Different objects in Git's database. As far as Git is concerned, those are completely different commits.
# Before rebase
git log --oneline feature/payments
# d4e5f6 Add payment validation
# a1b2c3 Add payment model
# 9g8h7i Initial setup (base commit on main)
# After git rebase main
git log --oneline feature/payments
# m1n2o3 Add payment validation ← new hash, same change
# x7y8z9 Add payment model ← new hash, same change
# 3k4l5m Latest commit on main
This is fine when the branch only exists on your machine. The problem starts the moment that branch lives on the remote and someone else has based work on it.
How the Disaster Unfolded
Here's what our team's branch situation looked like that Thursday:
main
└── feature/payments ← Developer 1's branch, pushed to remote 4 days ago
└── feature/payments-ui ← Developer 2's branch, based off Developer 1's
Developer 2 had been building the UI layer on top of Developer 1's branch for two days. Her local branch pointed to Developer 1's old commits — a1b2c3 and d4e5f6.
Then Developer 1 rebased and force-pushed.
The remote feature/payments now had completely different commits. Developer 2's branch still pointed to the old ones — which now existed nowhere except her local machine and Git's reflog.
When Developer 2 ran git pull origin feature/payments, Git saw her branch had diverged from remote. She assumed it was a normal conflict. She merged. Git dutifully merged two versions of the same code — the original and the rebased copy — creating a mess of duplicate changes, phantom conflicts, and commits that referenced parents that no longer existed on the remote.
And because two other developers had also pulled from feature/payments earlier that week, their local branches were in the same broken state.
The Error That Should Have Stopped It
The actual signal was there. When Developer 1 tried to push after rebasing, Git rejected it:
git push origin feature/payments
# error: failed to push some refs to 'origin/github.com/team/repo'
# hint: Updates were rejected because the tip of your current branch is behind
# hint: its remote counterpart.
Git was saying: the remote has commits you don't have. Your histories have diverged. Stop.
Instead of reading this as a warning, Developer 1 added --force to make it go away.
git push --force origin feature/payments
# Everything up to date. ← the most dangerous success message in Git
The push succeeded. The damage was done.
How We Recovered
Recovery took three steps. The order matters — don't skip step one.
Step 1: Stop everyone from pulling or pushing immediately
The moment you realize a shared branch has been force-pushed, message the team. Anyone who pulls will compound the damage. Anyone who pushes will overwrite the recovery.
# On Slack / Teams immediately:
# "Do NOT pull feature/payments. Do NOT push anything to it.
# Stay on your current branch. We're recovering."
Step 2: Find the lost commits with git reflog
git reflog is Git's black box recorder. It tracks every position HEAD has been at, including commits that are no longer reachable from any branch. On Developer 1's machine — because the original commits existed there before the rebase — reflog still had them.
git reflog
# m1n2o3 HEAD@{0}: rebase finished: returning to refs/heads/feature/payments
# x7y8z9 HEAD@{1}: rebase: Add payment model
# d4e5f6 HEAD@{2}: commit: Add payment validation ← original commit
# a1b2c3 HEAD@{3}: commit: Add payment model ← original commit
# 9g8h7i HEAD@{4}: checkout: moving from main to feature/payments
d4e5f6 and a1b2c3 — the original commits — were still there in reflog. Git never actually deleted them. It just stopped pointing any branch at them.
# Create a recovery branch at the last good state
git checkout -b feature/payments-recovered d4e5f6
Step 3: Restore the remote branch from the recovery point
# Force-push the recovered branch back to remote
# (yes, another force-push — but this time intentional and coordinated)
git push --force-with-lease origin feature/payments-recovered:feature/payments
We used --force-with-lease instead of --force. This flag checks whether the remote has changed since you last fetched — if someone else has pushed in the meantime, it fails instead of overwriting. Much safer.
Step 4: Help teammates rebase their local branches onto the restored remote
Once the remote was restored, each developer on the team needed to update their local branch:
# Each affected developer runs:
git fetch origin
git checkout feature/payments
git reset --hard origin/feature/payments
For Developer 2, whose branch was derived from the broken state:
# Find the last good commit on her branch before the bad merge
git log --oneline feature/payments-ui
# Identify the commit just before the bad pull
# Rebase her work on top of the restored branch
git rebase --onto origin/feature/payments <bad-merge-commit> feature/payments-ui
It took about 90 minutes of careful, coordinated work. But every commit was recovered.
The Actual Fix: Never Let This Happen Again
The root cause wasn't Developer 1 making a mistake. The root cause was that the repo allowed it.
Fix 1: Use --force-with-lease instead of --force — always
--force overwrites whatever is on remote, no questions asked. --force-with-lease checks first. If the remote has commits you haven't seen, it refuses. Make this a team standard.
# Never
git push --force origin feature/branch
# Always
git push --force-with-lease origin feature/branch
Fix 2: Enable branch protection on shared branches
In GitHub, go to Settings → Branches → Branch protection rules. For any branch that more than one developer works from:
☑ Require pull request reviews before merging
☑ Require status checks to pass
☑ Do not allow bypassing the above settings
☑ Restrict force pushes ← this is the one that prevents the whole incident
Force push to a protected branch returns an error immediately — before any damage is done.
Fix 3: The golden rule of rebase
Only rebase branches that exist only on your machine.
Once a branch is pushed and someone else might have it — merge, don't rebase.
If you want a clean linear history, use squash merge when closing the PR. That gives you one clean commit on main without rewriting anyone's history mid-flight.
Fix 4: Enable git rerere for teams doing a lot of rebasing
rerere — Reuse Recorded Resolution — caches how you resolve a conflict. If the same conflict appears again (which happens constantly when multiple developers are rebasing against main), Git resolves it automatically.
# Enable globally
git config --global rerere.enabled true
The Takeaway
git rebase is not dangerous. Force-pushing a rebased branch to a remote that others are working from is dangerous. The commands are the same. The context is what changes everything.
The rule is simple: if your branch exists only locally, rebase freely. The moment it's pushed — especially if someone else might have pulled it — treat it as immutable. Merge onto it. Squash when you close the PR.
And if it's already happened: breathe, message the team to stop, open reflog, and work backwards. The commits are almost certainly still there.
Git doesn't delete things. It just stops showing them to you.

Top comments (0)