I have typed git push --force-with-lease for years with the smug calm of someone wearing a seatbelt. Then one afternoon it overwrote two of my teammate's commits on our shared feature branch, and git didn't print a single warning.
No typo, no stale alias, no plain --force. The lease checked out fine. The problem was my editor, quietly running git fetch in the background every few minutes, which turned my seatbelt into a decorative strap.
This post explains exactly why git push --force-with-lease is unsafe when something auto-fetches, how to reproduce it in two minutes, and the flag that actually closes the hole.
TL;DR
-
git push --force-with-leasewith no argument compares the remote branch to your remote-tracking ref (origin/feature), not to the commit you actually rebased on. - Any background
git fetch(VS Code'sgit.autofetch, GitHub Desktop, a cron job, a prompt script) updatesorigin/featureto include your teammate's new commits. The lease now matches, and your push deletes their work. - The fix is
--force-if-includes(Git 2.30+), which refuses the push if the remote tip was never part of your local branch's history. - Set it once:
git config --global push.useForceIfIncludes true. It only kicks in alongside--force-with-lease, so plain--forcestays dangerous. - Lost commits are usually recoverable from
git reflog show origin/<branch>.
What does git push --force-with-lease actually check?
git push --force-with-lease checks one thing: that the branch on the remote still points to the same commit your local remote-tracking ref points to. If they match, the force push goes through. If they differ, git rejects it with "stale info".
That's it. It doesn't know which commit you started your rebase from. It doesn't know what you've looked at. It reads refs/remotes/origin/feature and assumes that's the last state you saw.
That assumption holds when you are the only thing that runs git fetch. It quietly breaks the moment anything else does.
Why does a background fetch break the lease?
A background fetch breaks the lease because it updates the exact ref the lease compares against, without touching your branch. Your view of the world goes stale, but git's record of "what you last saw" gets refreshed behind your back.
Here's the timeline from my afternoon:
- 14:02. I start an interactive rebase on
feature/billingto squash some fixup commits. My local branch andorigin/feature/billingboth point at commitA. - 14:09. My teammate pushes two commits,
BandC, to the same branch. - 14:11. VS Code's autofetch runs.
origin/feature/billingnow points atC. My local branch is still my rewritten history on top of the old base. - 14:15. I finish the rebase and run
git push --force-with-lease. - Git compares: remote says
C, my tracking ref saysC. Lease matches. Push accepted.BandCare gone from the remote.
The lease worked perfectly. It protected me against a state I had technically "seen". I hadn't seen anything. My editor had.
The git documentation actually warns about this in the --force-with-lease section: if something updates your remote-tracking refs in the background, the default lease form gives you no protection. Almost nobody reads that paragraph, because the flag's name sounds like it already handled it.
How do I reproduce the force-with-lease overwrite?
You can reproduce it locally with a bare repo and two clones, no network needed. Paste this into a scratch directory:
git init --bare origin.git
git clone origin.git me
git clone origin.git them
# me: create a branch with one commit
cd me
echo a > f && git add f && git commit -m "base"
git push origin HEAD:main
git branch -u origin/main
# them: pull it, add a fix, push
cd ../them
git pull origin main
echo fix > g && git add g && git commit -m "teammate fix"
git push origin HEAD:main
# me: rewrite my history, then a "background" fetch happens
cd ../me
git commit --amend -m "base (amended)"
git fetch # pretend this was your editor
git push --force-with-lease
git log --oneline origin/main # "teammate fix" is gone
The push succeeds. Now repeat the last section with the fix:
git push --force-with-lease --force-if-includes
This time git rejects the push, because the remote tip (teammate fix) never appeared in the history of your local branch. The rejection reason says the remote ref was updated since checkout, which is precisely the truth.
What does --force-if-includes do?
--force-if-includes makes git verify that the commit your remote-tracking ref points to is something your local branch has actually included at some point. It checks the reflog of your local branch: if the remote tip isn't reachable from any entry, git assumes you never integrated it and refuses the force push.
That's the missing check. The lease asks "did the remote change since my last fetch?" The include check asks "did I ever incorporate what's on the remote?" A background fetch can fake the first answer. It can't fake the second, because it never touches your local branch.
A few rules worth knowing:
- It only works together with
--force-with-lease. With plain--forceit does nothing. - It's ignored if you pass an explicit lease value, like
--force-with-lease=feature:abc123, since you've already told git exactly what to expect. - It requires Git 2.30 or newer. Run
git --versionbefore you rely on it.
How do I make --force-if-includes the default?
Set one config option and keep typing what you already type:
git config --global push.useForceIfIncludes true
Now every git push --force-with-lease also runs the include check. I also added an alias so my muscle memory never reaches for plain --force:
git config --global alias.pushf "push --force-with-lease --force-if-includes"
The explicit flag in the alias is redundant with the config, but it means the alias still works on a machine where I forgot to set the config. Cheap insurance.
When does --force-if-includes still let you overwrite work?
--force-if-includes still lets you overwrite work if your teammate's commits were in your local branch at any point, even briefly. Because the check looks at the reflog, "I pulled it once" counts as "I included it".
The classic case: you git pull --rebase, see your teammate's commits, then start an interactive rebase and drop them (on purpose or by fat-fingering d instead of p). The tip was in your reflog, so the check passes, and the push removes their work. Git is trusting that you made a decision. It can't tell the difference between a decision and an accident.
If you want the strongest guarantee, use an explicit lease captured before you start rewriting:
BASE=$(git rev-parse origin/feature/billing)
git rebase -i main
git push --force-with-lease=feature/billing:$BASE
Now the push only succeeds if the remote is exactly the commit you started from. Background fetches can't touch a shell variable. This is what I use for long-lived shared branches. For my own short branches, the config option is enough.
How do I recover commits lost to a force push?
You can usually recover them from the reflog of your remote-tracking ref, because the background fetch that caused the problem also recorded your teammate's commits locally. Run:
git reflog show origin/feature/billing
You'll see an entry from the fetch pointing at the old remote tip (in my case, C). Cherry-pick the lost commits back onto your branch:
git cherry-pick <sha-of-B> <sha-of-C>
git push --force-with-lease --force-if-includes
If your reflog doesn't have them, your teammate's local clone almost certainly does. That's how we recovered: a slightly awkward "hey, can you push again?" message and five minutes of cleanup. Lucky. On a branch with three people and a CI bot, it would have been a much longer afternoon.
Should I stop auto-fetching instead?
No. Auto-fetch is genuinely useful: it keeps your branch status accurate and shows incoming changes before you start a rebase. Turning it off trades a precise fix for a vague one, and you'd still get bitten by the next tool that fetches on your behalf.
The better move is to stop trusting the default lease form. Fetching should never be what decides whether a destructive push is safe.
So is git push --force-with-lease safe?
git push --force-with-lease is only safe if nothing else runs git fetch in your repo, because by default it compares the remote against your remote-tracking ref, and a background fetch silently updates that ref to include commits you never saw. With VS Code autofetch, GitHub Desktop, or any scheduled fetch, the lease will happily approve a push that deletes your teammate's work. Add --force-if-includes (or set git config --global push.useForceIfIncludes true on Git 2.30+) so git also checks that the remote tip was ever part of your local branch, and use an explicit --force-with-lease=branch:sha when you need a hard guarantee on shared branches.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)