Recovering from an accidental force-push Jump to heading

It usually happens with a stale local branch: someone runs git push --force to update their feature branch and, because of a misconfigured push default or a typo, overwrites main with a version from last week. Every commit merged since then vanishes from the remote. The good news is that commits are almost never truly lost. They still exist in the reflogs of the people who had them, in CI checkouts, in the forge’s own records, and often in the remote repository’s object store until garbage collection runs. Recovery is a matter of finding the right tip quickly and putting it back without making things worse. This page gives the order to search in and the safe way to restore, within history rewriting and recovery.

When to use this approach Jump to heading

  • A shared branch’s history suddenly lost commits after someone’s push.
  • CI reports “branch was force-pushed” or pull requests show unexpected diffs.
  • git pull tells you your branch and the remote have diverged when you have not changed anything.
  • For a deliberate rewrite that went wrong, the same steps apply, along with the backup advice in rewriting history with git filter-repo.

Step 1 — Stop further pushes and fetches that would spread the damage Jump to heading

The first priority is to stop anyone pulling the bad state and rebasing their work onto it, or force-pushing again. Announce it, and if you can, temporarily lock the branch.

# Lock the branch on GitHub (classic protection shown) while you recover
gh api -X PUT "repos/$OWNER/$REPO/branches/main/protection" --input - <<'EOF'
{ "required_status_checks": null, "enforce_admins": true,
  "required_pull_request_reviews": null, "restrictions": { "users": [], "teams": [] },
  "lock_branch": true }
EOF

People who have not fetched since the force-push are holding the good history. Ask them not to fetch until you have restored the branch.

Step 2 — Find the lost tip Jump to heading

Search the places most likely to have it, starting with the cheapest. Any one hit is enough.

Where the old tip of a branch survivesYour own remote-tracking reflog records the tip you last fetched. The forge's event log records the before and after of every push. Teammates who have not fetched still have it as their origin/main. CI checkouts and caches often have it too.search from cheapest to most effortYour refloggit reflog show origin/mainForge push eventsbefore / after SHAsTeammates' clonestheir origin/main, unfetchedCI checkoutsworkspace or cachea single SHA from any layer is all you need Where the old tip of a branch survivesYour own remote-tracking reflog records the tip you last fetched. The forge's event log records the before and after of every push. Teammates who have not fetched still have it as their origin/main. CI checkouts and caches often have it too.search from cheapest to most effortYour refloggit reflog show origin/mainForge push eventsbefore / after SHAsTeammates' clonestheir origin/main, unfetchedCI checkoutsworkspace or cachea single SHA from any layer is all you need
# 1. Your own remote-tracking reflog — the tip of origin/main before your last fetch
git reflog show --date=iso origin/main | head -5

# 2. The forge records every push with before and after commits
gh api "repos/$OWNER/$REPO/activity?ref=refs/heads/main&activity_type=force_push" \
  --jq '.[0] | {before, after, actor: .actor.login, timestamp}'
# 3. Any teammate who has not fetched since
git rev-parse origin/main          # run on their machine; send you the SHA

# Verification: the candidate contains the commits that went missing
git fetch origin <candidate-sha> 2>/dev/null || true
git log --oneline <candidate-sha> -10

Fetching a commit by hash works if it still exists on the server, which it usually does for a while after a force-push.

Step 3 — Compare and decide what the branch should be Jump to heading

Do not blindly restore the old tip. Someone may have pushed legitimate commits after the bad force-push, and those need to survive too.

good=<candidate-sha>
bad=$(git rev-parse origin/main)
git log --oneline "$bad..$good" | wc -l      # commits the force-push removed
git log --oneline "$good..$bad"              # commits only on the current tip — anything legitimate?
How to restore the branchIf nothing legitimate was pushed after the force-push, reset the branch to the old tip. If someone pushed real work on top of the bad state, restore the old tip and cherry-pick that work onto it. If the force-push itself contained wanted changes, merge rather than reset.What is on the current tip that is not on the old one?nothing wantedReset to old tipforce-with-leasenew real commitsOld tip + pickscherry-pick themwanted changesMerge bothno force needed--force-with-lease ensures you only overwrite the exact state you inspected How to restore the branchIf nothing legitimate was pushed after the force-push, reset the branch to the old tip. If someone pushed real work on top of the bad state, restore the old tip and cherry-pick that work onto it. If the force-push itself contained wanted changes, merge rather than reset.What is on the current tip that is not on the old one?nothing wantedReset to old tipforce-with-leasenew real commitsOld tip + pickscherry-pick themwanted changesMerge bothno force needed--force-with-lease ensures you only overwrite the exact state you inspected

Step 4 — Restore with --force-with-lease Jump to heading

Restoring is itself a force-push, so do it in the safest form. --force-with-lease with an explicit expected value refuses if the branch has moved since you looked, which prevents overwriting a push that arrived during the recovery.

# Simple case: put the old tip back
git push --force-with-lease=main:"$bad" origin "$good":refs/heads/main

# With legitimate commits made on top of the bad state
git switch -c restore "$good"
git cherry-pick <legit-sha-1> <legit-sha-2>
git push --force-with-lease=main:"$bad" origin restore:refs/heads/main

⚠️ SAFETY WARNING: Restoring a branch is another history change for anyone who fetched the bad state. People who pulled in the meantime must reset their local branch to the restored tip — git fetch && git reset --keep origin/main — and anyone who based new work on the bad state should rebase it onto the restored branch rather than merging, or the bad history comes back.

# Verification: the remote now has the full history again
git fetch origin && git log --oneline origin/main | head -5
git merge-base --is-ancestor "$good" origin/main && echo "old tip restored"
Before, during and after the recoveryMain had commits up to C5. A stale branch ending at C2 was force-pushed over it, so C3 to C5 disappeared from the remote. Restoring the old tip C5 and cherry-picking the one legitimate commit L made in the meantime gives a branch that contains everything.restore the tip, keep anything legitimate pushed sincebeforeC1C2C3C4C5after accidentC1C2LrestoredC1C2C3C4C5L'L is cherry-picked onto C5 so the work made on the bad state is not lost Before, during and after the recoveryMain had commits up to C5. A stale branch ending at C2 was force-pushed over it, so C3 to C5 disappeared from the remote. Restoring the old tip C5 and cherry-picking the one legitimate commit L made in the meantime gives a branch that contains everything.restore the tip, keep anything legitimate pushed sincebeforeC1C2C3C4C5after accidentC1C2LrestoredC1C2C3C4C5L'L is cherry-picked onto C5 so the work made on the bad state is not lost

Step 5 — Prevent the next one Jump to heading

Most accidental force-pushes to shared branches are preventable with configuration that costs nothing.

# Server side: forbid force-pushes to protected branches (and unlock the branch)
gh api -X PUT "repos/$OWNER/$REPO/branches/main/protection" --input protection.json   # allow_force_pushes: false

# Client side: safer defaults for everyone
git config --global push.default simple                  # push only the current branch to its upstream
git config --global alias.pushf 'push --force-with-lease --force-if-includes'

--force-if-includes (Git 2.30+) adds a second check: the force-push is refused unless your local branch has incorporated the remote tip you are about to overwrite. It catches the “stale branch” case that --force-with-lease alone can miss when a background fetch updated the lease. Server-side rejection is covered in blocking force-pushes with a pre-receive hook.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

How long do force-pushed commits stay on the server? Jump to heading

Until the server garbage-collects unreachable objects, which hosted forges do on their own schedule — typically days to weeks. Do not rely on it; recover as soon as you notice, and if the server copy is gone, a clone or CI workspace will still have it.

What if nobody has the old commits anywhere? Jump to heading

Check CI caches, deployment artefacts with embedded commit IDs, and any mirrors. Hosted forges can sometimes restore from their own backups through support. A preserving mirror, as in mirroring third-party repositories for resilience, makes this question irrelevant for repositories you depend on.

Should the person who force-pushed do the recovery? Jump to heading

Whoever is calmest and has the right access. The person who made the mistake usually has the best idea of what happened and is a good second pair of eyes, but recovery under pressure benefits from someone who is not also processing the mistake.