I rebased my feature branch, ran git push --force-with-lease, and felt responsible. That's the safe one, right? The one every blog post tells you to use instead of --force.
Twenty minutes later a teammate asked where their fix went. It was gone from the branch. My "safe" push had overwritten it, and Git didn't say a word.
The culprit was my editor. It had run git fetch in the background while I was rebasing. And git push --force-with-lease stops protecting you after git fetch, because the lease it checks is your local copy of the remote, and fetch just updated that copy.
TL;DR
-
git push --force-with-leasewith no arguments compares the remote branch against your remote-tracking ref (origin/feature), not against what you last saw or integrated. - Any
git fetch(manual, IDE auto-fetch, GUI client, shell prompt plugin) movesorigin/featureto the new tip, so the lease matches and your push overwrites commits you never looked at. - Fix: add
--force-if-includes(Git 2.30+). It rejects the push unless the remote tip is reachable from your local branch's reflog, meaning you actually had it at some point. - Make it the default:
git config --global push.useForceIfIncludes true, then keep typing--force-with-leaseas usual. - For scripts, the bulletproof form is an explicit lease:
--force-with-lease=feature:<sha-you-expect>.
What does git push --force-with-lease actually check?
It checks one thing: "Is the remote branch still pointing where my remote-tracking ref says it is?" If origin/feature in your repo says abc123 and the server's feature is also abc123, the push goes through as a force push. If they differ, you get rejected with (stale info).
That sounds like "has anyone pushed since I last looked?" It isn't. It's "has anyone pushed since my repo last fetched?"
Those are the same question only if you are the one running every fetch, and you always look at what came in. Nobody works like that anymore.
Why does a background git fetch break force-with-lease?
Because fetch updates the exact ref that the lease uses as its expected value. Here's the timeline that got me:
- I branch
featureoffmain, push it.origin/feature=A. - My teammate pulls
feature, adds commitB, pushes. Serverfeature=B. - I start an interactive rebase locally. I have not seen
B. - My editor's auto-fetch fires. Now my
origin/feature=B. I don't notice. Nothing in my terminal changed. - I finish the rebase and run
git push --force-with-lease. - Git compares server
feature(B) to myorigin/feature(B). They match. Lease granted.Bis overwritten.
The lease was valid. It just wasn't mine. The fetch renewed it on my behalf.
Things that run git fetch without you typing it:
- VS Code with
git.autofetchenabled (it offers to turn this on, and plenty of people click yes) - Desktop Git clients that refresh remotes periodically
- Terminal prompt themes and TUI tools that fetch to show ahead/behind counts
- Your own muscle memory:
git fetch && git log origin/featurewhere you skim past the new commit
Any one of these is enough.
How do you reproduce the force-with-lease overwrite?
You can see it in under a minute with a bare repo and two clones. No network needed.
mkdir fwl-demo && cd fwl-demo
git init -q --bare remote.git
# Alice creates the branch
git clone -q remote.git alice && cd alice
git commit -q --allow-empty -m "base"
git push -q origin HEAD:main
git switch -q -c feature
echo a > a && git add a && git commit -qm "alice 1"
git push -q -u origin feature
cd ..
# Bob adds a fix on top
git clone -q -b feature remote.git bob && cd bob
echo b > b && git add b && git commit -qm "bob fix"
git push -q
cd ../alice
# Alice rewrites her commit, and a "background" fetch happens
git commit -q --amend -m "alice 1 (reworded)"
git fetch -q # <- your IDE did this, not you
git push --force-with-lease # succeeds
git log --oneline origin/feature # "bob fix" is gone
The push succeeds. Bob's commit is no longer on the branch. Remove the git fetch line and run it again from scratch: the push is rejected with (stale info), which is the protection you thought you had all along.
What does --force-if-includes do?
--force-if-includes adds a second check: the current tip of the remote-tracking ref must be reachable from one of the entries in your local branch's reflog. In plain words, Git asks "did this branch of yours ever actually contain the commit you're about to overwrite?"
In the timeline above, origin/feature = B after the fetch, but my local feature never contained B. Not before the rebase, not after. So the push is rejected:
git push --force-with-lease --force-if-includes
# ! [rejected] feature -> feature (remote ref updated since checkout)
Now I have to go look. I run git log feature..origin/feature, see Bob's fix, rebase onto it, and push again. This time the check passes, because B is now in my branch history.
Two details from the docs that matter:
- It's a no-op without
--force-with-lease. Plain--force --force-if-includesdoesn't protect anything. - It's also a no-op with an explicit lease like
--force-with-lease=feature:abc123. With an explicit SHA you've already said exactly what you expect, so Git trusts that.
How do I make force-with-lease safe by default?
Set one config value and keep your existing habit:
git config --global push.useForceIfIncludes true
With that set, every git push --force-with-lease also runs the --force-if-includes check. You don't have to retrain your fingers.
If you use an alias, fold both in so there's nothing to forget:
git config --global alias.pushf "push --force-with-lease --force-if-includes"
Check your Git version first. --force-if-includes and push.useForceIfIncludes arrived in Git 2.30. On older versions the flag is an unknown option and the push fails loudly, which is at least an honest failure.
What about CI jobs and scripts that force push?
Scripts shouldn't rely on reflog heuristics. A CI runner often has a fresh clone with almost no reflog, and bots don't "look" at anything anyway. Use an explicit lease with the SHA you read at the start of the job:
expected=$(git rev-parse origin/release)
# ... rebuild, rebase, regenerate ...
git push --force-with-lease=release:"$expected" origin HEAD:release
If anything else moved release between those two lines, the push is rejected, no matter how many fetches happened in between. This is the only form of the lease that is immune to fetch, because the expected value lives in a shell variable instead of a ref that Git keeps updating.
Where does --force-if-includes still let you down?
It's a heuristic built on the reflog, so it can be fooled by things you did yourself:
-
You checked out the remote tip once, then threw it away. Say you ran
git reset --hard origin/feature, then reset back to your old work. The reflog now containsB, so Git believes you integrated it. You didn't. - Reflog expiry. Unreachable reflog entries expire (30 days by default). Very long-lived branches with old rewrites can confuse it.
- It doesn't read the diff. It proves the commit was in your history at some point. It can't prove you kept its changes after a messy conflict resolution during a rebase.
None of these are reasons to skip it. They're reasons to still run git log HEAD..origin/feature before any force push to a shared branch. That one command takes a second and shows you exactly what you're about to delete.
My current setup
This is the whole thing. It took about two minutes:
git config --global push.useForceIfIncludes true
git config --global alias.pushf "push --force-with-lease --force-if-includes"
git config --global alias.incoming "log --oneline HEAD..@{u}"
Before a force push to anything shared: git fetch, git incoming, read it, then git pushf. I left VS Code auto-fetch on. With useForceIfIncludes, it's no longer a trap. It's just a feature that keeps my ahead/behind counter honest.
So is git push --force-with-lease safe after git fetch?
No. git push --force-with-lease without arguments only checks that the remote branch matches your remote-tracking ref, and any git fetch (including your IDE's automatic one) updates that ref to the newest remote tip, so the lease passes and your push overwrites commits you never saw. Add --force-if-includes, or set git config --global push.useForceIfIncludes true on Git 2.30+, so Git also requires the remote tip to be in your local branch's reflog before it lets a force push through. In scripts and CI, use an explicit lease, --force-with-lease=<branch>:<expected-sha>, which fetch cannot silently renew.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)