Skip to content

Lab: Recover from a Force Push

Lesson 6 of 6Intermediate4 min readHands-On Git & GitHub Labs · Git Recovery Labs
Time20 minutes
LevelIntermediate
You needLocal Git only — the remote is a bare repository on disk

You amend a commit message on main and push with --force. What you did not know: a colleague pushed a fix ten minutes ago. Your force push replaced the remote’s main with your version, and their commit is no longer reachable from any branch on the server.

They will notice on their next git fetch. This lab plays both roles — the person who broke it and the person who fixes it — because the fix depends on something only the colleague has.

Recreate the mistake, watch it appear on the colleague’s side, recover the lost commit from the colleague’s reflog of origin/main, and confirm that --force-with-lease refuses the push that --force allowed.

A bare repository standing in for the server, your clone, and a colleague’s clone:

Terminal window
mkdir -p /tmp/lab-fp && cd /tmp/lab-fp
git init -q --bare -b main remote.git
git init -q -b main you && cd you
git config user.email "lab@example.com" && git config user.name "Lab User"
git remote add origin ../remote.git
echo "# App" > README.md && git add . && git commit -q -m "Initial commit"
for i in 1 2 3; do echo "feature $i" >> app.txt; git add . && git commit -q -m "Feature $i"; done
git push -q -u origin main
cd .. && git clone -q remote.git colleague && cd colleague
git config user.email "c@example.com" && git config user.name "Colleague"
echo "typo fixed" >> README.md && git commit -qam "Colleague: fix typo in README"
git push -q origin main
git log --oneline -2

The colleague’s commit is on the remote. You have not fetched it.

  1. Rewrite your last commit without fetching first — the realistic setup:

    Terminal window
    cd /tmp/lab-fp/you
    git commit --amend -qm "Feature 3 (renamed)"
    git log --oneline -2
  2. Try the safe force. Push with a lease:

    Terminal window
    git push --force-with-lease origin main

    Read the rejection. What is “stale info”?

  3. Make the mistake. Push with a plain force:

    Terminal window
    git push --force origin main
    git log --oneline origin/main | head -3

    The colleague’s commit is not there.

  4. Be the colleague. Fetch and read every line Git prints:

    Terminal window
    cd ../colleague
    git fetch origin
    git log --oneline origin/main -2

    Their commit is gone from the remote. Is it gone from their machine?

  5. Find it. The colleague’s clone kept a record of where origin/main used to point:

    Terminal window
    git reflog show origin/main
    git log --oneline main -1
  6. Restore the remote from the colleague’s copy, safely — with a lease so this push cannot itself overwrite something newer:

    Terminal window
    OLD=$(git reflog show origin/main --format=%h | sed -n '2p')
    git push --force-with-lease=main:origin/main origin "$OLD:main"
    git fetch -q origin && git log --oneline origin/main | head -3
  7. Back to you. Fetch, and find your renamed commit — it is not lost either:

    Terminal window
    cd ../you
    git fetch origin
    git reflog | grep "Feature 3 (renamed)"

    Now do it properly: rebase your rename onto the restored main, or simply redo the amend after pulling.

After step 6, git log --oneline origin/main in the colleague’s clone shows their commit on top. After step 7, your reflog still holds the renamed commit, so nothing was lost on either side.

Step 2. --force-with-lease compares the remote’s main with your origin/main. Yours is stale — the colleague pushed since — so the lease fails. That is the check working.

Step 4. + b94d27d...44adf09 main -> origin/main (forced update): the + and “forced update” are Git telling the colleague that history moved non-linearly.

Step 5. git reflog show origin/main is the reflog of the remote-tracking branch: every value origin/main has had in this clone. The entry before the forced update is the tip that was overwritten. The bare remote has no reflog by default; the clone does.

Step 2 — the lease refuses:

! [rejected] main -> main (stale info)
error: failed to push some refs to '../remote.git'

Step 4 — the colleague sees the damage:

+ b94d27d...44adf09 main -> origin/main (forced update)

Step 5:

44adf09 refs/remotes/origin/main@{0}: fetch origin: forced-update
b94d27d refs/remotes/origin/main@{1}: update by push
b94d27d Colleague: fix typo in README

Step 6:

+ 44adf09...b94d27d b94d27d -> main (forced update)
b94d27d Colleague: fix typo in README
a848187 Feature 3
7c07d94 Feature 2

Step 7:

+ 44adf09...b94d27d main -> origin/main (forced update)
44adf09 HEAD@{0}: commit (amend): Feature 3 (renamed)

A force push replaces the remote ref; it does not delete commits from clones. The colleague’s commit was unreachable on the server and fully present in the colleague’s repository. Recovery is pushing it back. The only truly lost case is a commit that existed only on the server — which, with a normal workflow, is never.

--force-with-lease is a compare-and-swap. “Overwrite main only if it still points where I last saw it.” Your remote-tracking ref was behind, so it refused. The trap: if you git fetch right before pushing, the lease is refreshed and passes — the fetch is what defeats it. Fetch before you rewrite, not before you push; and add --force-if-includes, which additionally requires the remote’s tip to be in your local history.

The remote-tracking reflog is the recovery tool nobody knows they have. git reflog show origin/<branch> lists every value the remote-tracking ref has held in this clone, including the one the forced update replaced. Any clone that fetched before the force push has it.

On GitHub, one more layer: a repository ruleset or branch protection with “block force pushes” makes step 3 impossible. The PR workflow lab sets that up.

Step 2’s lease push succeeded. You fetched after the colleague pushed. The lease compares against your refreshed origin/main, which now includes their commit — so the push is a real fast-forward of the amended history and succeeds. Restart from the starting state and do not fetch before step 2.

git reflog show origin/main has one entry. The colleague’s clone never fetched between the push and the force push. In the real world the recovery then comes from whoever did — or from the colleague’s own main, which still has the commit (step 5, second command).

Step 6 is rejected with “stale info”. Someone pushed again in between. Fetch, inspect, and redo the lease with the current origin/main.

Terminal window
cd /tmp && rm -rf lab-fp

Clean up a branch with interactive rebase — turn five messy commits into two reviewable ones before opening the pull request.

Choose a learning pathA sequenced route through the curriculum for wherever you are now.