Scenario
Section titled “Scenario”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.
Objective
Section titled “Objective”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.
Prerequisites
Section titled “Prerequisites”- Git installed
- When not to rebase — the rules this lab breaks on purpose
Starting state
Section titled “Starting state”A bare repository standing in for the server, your clone, and a colleague’s clone:
mkdir -p /tmp/lab-fp && cd /tmp/lab-fpgit init -q --bare -b main remote.git
git init -q -b main you && cd yougit config user.email "lab@example.com" && git config user.name "Lab User"git remote add origin ../remote.gitecho "# 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"; donegit push -q -u origin main
cd .. && git clone -q remote.git colleague && cd colleaguegit 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 maingit log --oneline -2The colleague’s commit is on the remote. You have not fetched it.
-
Rewrite your last commit without fetching first — the realistic setup:
Terminal window cd /tmp/lab-fp/yougit commit --amend -qm "Feature 3 (renamed)"git log --oneline -2 -
Try the safe force. Push with a lease:
Terminal window git push --force-with-lease origin mainRead the rejection. What is “stale info”?
-
Make the mistake. Push with a plain force:
Terminal window git push --force origin maingit log --oneline origin/main | head -3The colleague’s commit is not there.
-
Be the colleague. Fetch and read every line Git prints:
Terminal window cd ../colleaguegit fetch origingit log --oneline origin/main -2Their commit is gone from the remote. Is it gone from their machine?
-
Find it. The colleague’s clone kept a record of where
origin/mainused to point:Terminal window git reflog show origin/maingit log --oneline main -1 -
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 -
Back to you. Fetch, and find your renamed commit — it is not lost either:
Terminal window cd ../yougit fetch origingit reflog | grep "Feature 3 (renamed)"Now do it properly: rebase your rename onto the restored
main, or simply redo the amend after pulling.
Validation
Section titled “Validation”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.
Solution
Section titled “Solution”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-updateb94d27d refs/remotes/origin/main@{1}: update by pushb94d27d Colleague: fix typo in READMEStep 6:
+ 44adf09...b94d27d b94d27d -> main (forced update)b94d27d Colleague: fix typo in READMEa848187 Feature 37c07d94 Feature 2Step 7:
+ 44adf09...b94d27d main -> origin/main (forced update)44adf09 HEAD@{0}: commit (amend): Feature 3 (renamed)Explanation
Section titled “Explanation”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.
Troubleshooting
Section titled “Troubleshooting”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.
Clean up
Section titled “Clean up”cd /tmp && rm -rf lab-fpRelated lessons
Section titled “Related lessons”Next lab
Section titled “Next lab”Clean up a branch with interactive rebase — turn five messy commits into two reviewable ones before opening the pull request.