Someone ran git push --force origin main after a local rebase and the branch pointer jumped back two days. GitHub now shows history ending at a Tuesday commit, and every PR merged since then is gone from the tip. It looks catastrophic but is almost always fully recoverable: a force-push only moves a ref pointer, it does not delete the commit objects. They are still in the repo, just unreachable.
Fastest fix: find the pre-overwrite SHA (from a teammate’s git reflog or the GitHub Activity view), point a recovery branch at it, merge it back, then push with git push --force-with-lease. Tell everyone to stop pushing to that branch first. Full steps below.
Stop and do this before anything else: post in your team channel “do not push to <branch>” so nobody force-pushes again on top of the broken state. A second force-push on the new (truncated) tip is what turns a 10-minute recovery into a real data-loss incident.
Common causes
Ordered by hit rate, highest first.
1. Developer rebased a local branch and force-pushed to a shared branch
The most common cause. Someone ran git rebase main on develop locally, then git push --force origin develop without realizing teammates had pushed commits to origin/develop since their last fetch.
How to spot it: Open the repo Activity view (github.com/OWNER/REPO/activity, or the pulse icon next to the file list), filter by “Force pushes”, and click “Compare changes” on the event. The “before” SHA is the last-good tip; the “after” SHA is the truncated one.
2. git push --force used instead of --force-with-lease
A script or CI job configured with --force (not --force-with-lease) re-ran after a delay and overwrote commits pushed in the interim. Plain --force never checks the remote tip, so it overwrites unconditionally.
How to spot it: Grep CI config (.github/workflows/, .gitlab-ci.yml) for git push --force or git push -f. The pipeline run timestamp matches the timestamp of the lost commits in the Activity view.
3. Amend + force-push to fix a typo in a commit message
Developer amended the last commit, then force-pushed. If the remote had one more commit on top (merged seconds earlier), that commit is now orphaned.
How to spot it: The commit count on the remote branch dropped by exactly one after the force-push event.
4. Force-push during an automated release script
Release tooling bumps a version, commits, tags, and force-pushes main. If the window between the script’s git fetch and git push --force was long enough for a hotfix to land, the hotfix is orphaned.
How to spot it: Cross-reference the release run time with the “before” SHA timestamp in the Activity view.
5. Mistyped branch or refspec in the force-push command
Developer meant git push --force origin feature/my-fix but typed a stray refspec like local-experiment:main, overwriting main with unrelated history.
How to spot it: The new tip is unrelated to the branch’s task. The Activity log shows one lone force-push event whose new content matches some other local branch.
Which bucket are you in
| Question | Yes | No |
|---|---|---|
| Did a teammate fetch the branch before the force-push? | Recover from their reflog (Step 1A) — fastest | Use GitHub Activity / API (Step 1B) |
| Is the host GitHub or GitLab? | Activity view shows the old SHA directly | Self-hosted: check server reflog/fsck |
| Has more than ~30 days passed? | Object may be GC’d — open a host support ticket now | Recoverable; proceed normally |
| Did anyone force-push again after the loss? | Stop everyone; recover the oldest good SHA, not the latest | Single overwrite, straightforward |
Shortest path to fix
Step 1A: Get the last-good SHA from a teammate’s clone (fastest)
Any teammate who fetched the branch before the force-push still has the old commits in their object store and reflog. Ask one to run:
git reflog show origin/main | head -20 # remote-tracking reflog
git log --oneline origin/main | head -20 # their cached pre-overwrite tip
Note the SHA of the last commit before the overwrite — call it GOOD_SHA.
Step 1B: If no teammate has it, read the SHA from the host
On GitHub, open github.com/OWNER/REPO/activity, filter to “Force pushes”, and click “Compare changes” on the event. The before SHA is GOOD_SHA. (On GitLab, “Repository → Commits” plus the Audit Events / push events show the same.) You can also enumerate stale refs over the API:
curl -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/OWNER/REPO/activity?activity_type=force_push"
If the commit object is gone from your clone, GitHub may still hold it server-side: open github.com/OWNER/REPO/tree/GOOD_SHA and, if the page loads, click the branch button to create a recovery branch straight from the web UI.
Step 2: Bring GOOD_SHA into your repo
If you have it locally (Step 1A teammate), pull it from their clone:
# Add the teammate's repo as a temporary remote and fetch
git remote add teammate /path/to/their/local/clone # or their fork URL
git fetch teammate
If GitHub still serves the object (Step 1B), fetch it by SHA:
git fetch origin GOOD_SHA
Step 3: Create a recovery branch at the last-good SHA
git branch recovery/main-preforce GOOD_SHA
git log --oneline recovery/main-preforce | head -20
Confirm the recovery branch contains the missing commits and authors.
Step 4: Back up the current (broken) tip, then merge the recovery branch
git tag backup/post-forcepush origin/main # safety net before you touch main
git checkout main
git merge recovery/main-preforce --no-ff \
-m "recover: restore commits lost in force-push on $(date -u +%Y-%m-%d)"
A --no-ff merge keeps both the lost work and anything legitimately pushed after the incident. If nothing valid landed after the overwrite, you can instead git reset --hard GOOD_SHA.
Step 5: Push the restored branch safely
# --force-with-lease refuses the push if the remote tip changed again since your fetch
git push --force-with-lease origin main
Step 6: Verify the restored history
git log --oneline -20
git shortlog -s -n GOOD_SHA~1..HEAD # confirm every author's commits are back
Step 7: Have every collaborator re-sync
Anyone who pulled the broken state must reset, not merge, or they will re-introduce the truncated history:
git fetch origin
git reset --hard origin/main
How to confirm it’s fixed
- The Activity view shows your
--force-with-leasepush as the newest event, with the after SHA matching your restoredHEAD. git shortlog -s -n GOOD_SHA~1..HEADlists every author who had commits in the lost range.- The PRs that “disappeared” again show their merge commits in the branch history (
git log --merges). - A fresh clone (
git clone <url> /tmp/verify) contains the recovered commits — proving the fix is on the remote, not just local.
Prevention
- Replace every
git push --forcein scripts and docs withgit push --force-with-lease. The lease refuses the push if the remote has commits you have not fetched. For an even stronger guard (Git 2.30+, released late 2020), setgit config --global push.useForceIfIncludes trueso a lease push also requires that the remote’s new commits are integrated into your local history, not merely fetched. - Add a shell alias so the safe form is the one in your fingers:
alias gpf='git push --force-with-lease --force-if-includes'. - Enable branch protection on
main/develop(GitHub: Settings → Branches → Branch protection rules / Rulesets). Disallow force pushes, and toggle “Do not allow bypassing the above settings” so admins are covered too. - Add a pre-push hook that blocks force-pushes to protected branch names:
# .git/hooks/pre-push (chmod +x)
protected="refs/heads/main refs/heads/develop"
while read -r local_ref local_sha remote_ref remote_sha; do
for p in $protected; do
if [ "$remote_ref" = "$p" ] && [ "$remote_sha" != "0000000000000000000000000000000000000000" ]; then
# non-zero remote_sha + this hook firing on a non-FF push => block it
echo "ERROR: force-push to ${remote_ref#refs/heads/} blocked. Open a PR."
exit 1
fi
done
done
exit 0
- On self-hosted Git servers, set
receive.denyNonFastForwards = trueto block all non-fast-forward pushes at the server level. - Run
git fetch --allbefore any rebase that will be followed by a push, and confirmgit log HEAD..origin/<branch>is empty before pushing. - Keep automated release scripts on a bot account whose push access is limited to tags, not
main. - Raise the local safety net:
git config --global gc.reflogExpireUnreachable 90.dayskeeps unreachable reflog entries around longer (the default is only 30 days; reachable entries default to 90).
FAQ
Q: GitHub shows “force-pushed” in the PR timeline. Can I restore directly from GitHub?
A: Yes. The fastest path is the repo Activity view (/activity, filter “Force pushes”) — the “Compare changes” link exposes the pre-push SHA. Then open github.com/OWNER/REPO/tree/<old-sha>; if the page loads, the object still exists and you can create a branch from it in the UI. The PR timeline’s “force-pushed” line also prints the old tip SHA.
Q: How long does GitHub keep the overwritten commits?
A: There is no contractual figure, but practically: as long as a ref or PR references the commit it stays indefinitely, and unreachable objects survive until garbage collection. Locally, Git’s defaults are 90 days for reachable reflog entries (gc.reflogExpire) and 30 days for unreachable ones (gc.reflogExpireUnreachable). Treat ~30 days as your safe window for an unreferenced commit and act sooner; if it’s gone, open a GitHub Support ticket with the repo, branch, and approximate time.
Q: What is the difference between --force, --force-with-lease, and --force-if-includes?
A: --force pushes unconditionally. --force-with-lease first checks that the remote tip still matches your cached origin/<branch> — if someone pushed in the meantime, it is rejected. --force-if-includes (used alongside the lease) goes further: it confirms the remote’s new commits are actually reachable from your local reflog, not just fetched into a tracking ref. Use --force-with-lease --force-if-includes together.
Q: Can Git globally turn my --force into --force-with-lease?
A: Not by rewriting the flag itself, but push.useForceIfIncludes=true strengthens lease pushes, and a shell alias (gpf above) gives you a safe default. Some teams also wrap git in a function that rejects a bare --force.
Q: Branch protection is enabled but someone still force-pushed. How? A: Repository admins (and apps/tokens with bypass) can skip protection unless you forbid it. In the branch ruleset, enable “Do not allow bypassing the above settings” (older UI: “Include administrators”) to remove the admin escape hatch, and audit who holds bypass via Settings → Rules → Rulesets.