DEV Community

jidonglab
jidonglab

Posted on

git push --force-with-lease Stops Protecting You After git fetch

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-lease with 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) moves origin/feature to 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-lease as 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:

  1. I branch feature off main, push it. origin/feature = A.
  2. My teammate pulls feature, adds commit B, pushes. Server feature = B.
  3. I start an interactive rebase locally. I have not seen B.
  4. My editor's auto-fetch fires. Now my origin/feature = B. I don't notice. Nothing in my terminal changed.
  5. I finish the rebase and run git push --force-with-lease.
  6. Git compares server feature (B) to my origin/feature (B). They match. Lease granted. B is 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.autofetch enabled (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/feature where 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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-includes doesn'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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 contains B, 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}"
Enter fullscreen mode Exit fullscreen mode

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)