PricingSign inDownload

Blog / git

A coding agent force-pushed over my teammate's work

How an agent's force push erases a teammate's commits, why --force-with-lease failed after a fetch, and the pre-push hook that stopped every form.

On this page
  1. What a force push actually removes
  2. --force-with-lease helps, until someone fetches
  3. A deny rule catches one spelling
  4. A pre-push hook stops every form
  5. The remote is the last line
  6. If it already happened: don't pull with --rebase
  7. The short version
  8. Try it with your team

Two people share a branch. One of them asks their coding agent to tidy up the history before a review. The agent rebases, the push is rejected because the branch moved, so it does what the error message seems to invite: it pushes with force. The other person's commits from that morning are no longer on the remote.

Nobody did anything strange here. Agents rewrite history because people ask them to ("squash these", "fix that commit message"), and a rejected push followed by --force is a pattern they've seen thousands of times. We reproduced the whole thing with git 2.54 and two local clones, Alice and Bob, sharing one remote. Here's what happened, what each common safeguard did, and the setup that stopped it.

What a force push actually removes#

Alice pushed a commit. Bob, who had cloned before that, made his own commit and tried to push:

 ! [rejected]        main -> main (fetch first)

That rejection is git protecting Alice's work. A plain git push --force from Bob replaces the remote branch with his, and Alice's commit is no longer on it. It still exists on Alice's machine, so it's not gone forever, but anyone who pulls now gets Bob's version.

--force-with-lease helps, until someone fetches#

The usual advice is to use --force-with-lease instead. It only overwrites the remote if the remote is still where your clone last saw it. We tried it from Bob's clone before fetching:

 ! [rejected]        main -> main (stale info)

Good. Then Bob fetched (as agents and editors do all the time, often in the background) and ran the same command:

 + 95d6814...de7e8c7 main -> main (forced update)

Alice's commit was gone from the remote. The lease compares the remote against your last fetch, and the fetch had just updated that, even though Bob never looked at or merged Alice's commit. Adding --force-if-includes closes this gap: it also checks that the remote's tip is part of your own history. With both flags, after the same fetch:

 ! [rejected]        main -> main (remote ref updated since checkout)

You can make that the default with git config --global push.useForceIfIncludes true. But both flags only help when they're used. An agent that types plain --force skips all of it.

A deny rule catches one spelling#

If you use Claude Code, the obvious move is a permission rule. We put this in the project's .claude/settings.json (Claude Code 2.1.312):

json
{
  "permissions": {
    "deny": ["Bash(git push --force:*)", "Bash(git push -f:*)"]
  }
}

git push --force origin main was refused. git push origin +main, which does exactly the same thing (the + in front of a branch name means force), ran and rewrote the remote. Deny rules match the start of the command as written, so they're worth having, but they can't be the only guard.

A pre-push hook stops every form#

Git runs a pre-push hook on the pushing machine before anything is sent, with each ref being pushed and what the remote has for it. The check that matters is simple: the remote's current commit has to be part of what you're pushing. If it isn't, the push would remove commits. This version doesn't care which flag was used:

bash
#!/bin/sh
# Refuse any push that would remove commits from the remote branch.
zero=0000000000000000000000000000000000000000
while read local_ref local_sha remote_ref remote_sha; do
  [ "$remote_sha" = "$zero" ] && continue          # new branch on the remote
  if [ "$local_sha" = "$zero" ]; then              # deleting a remote branch
    case "$remote_ref" in
      refs/heads/main|refs/heads/master)
        echo "pre-push: refusing to delete $remote_ref" >&2; exit 1 ;;
    esac
    continue
  fi
  if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
    echo "pre-push: this would remove commits from $remote_ref. Pull and merge first." >&2
    exit 1
  fi
done
exit 0

Save it as .git/hooks/pre-push and make it executable with chmod +x .git/hooks/pre-push. Results from Bob's clone, with a rewritten commit:

PushResult
git push --force origin mainRefused by the hook
git push --force-with-lease origin mainRefused by the hook
git push origin +mainRefused by the hook
A normal push on top of the remoteWent through
git push origin --delete mainRefused by the hook

The hook works for every agent (Claude Code, Codex, Cursor, anything that calls git) and for people too, because git runs it, not the agent. Two limits: .git/hooks isn't committed, so each teammate installs it in their own clone, and git push --no-verify skips hooks. In Claude Code you can add a deny rule for --no-verify pushes as well. Rebasing your own feature branch and force-pushing it is a normal workflow, and this hook will refuse that too: when you mean it, run the push yourself with --no-verify.

The remote is the last line#

The strongest guard runs where every push lands. On a self-hosted bare repository, one setting does it:

bash
git config receive.denyNonFastForwards true

With that set, both --force and +main came back as:

remote: error: denying non-fast-forward refs/heads/main (you should pull first)
 ! [remote rejected] main -> main (non-fast-forward)

On GitHub, GitLab and similar hosts, the same thing is a branch protection rule or ruleset that blocks force pushes on your main branch. If an agent can push to a branch other people build on, turn it on. It's the one guard no client-side setting or flag can get around.

If it already happened: don't pull with --rebase#

This part surprised us. After Bob's force push, Alice ran the command most people would run to catch up, git pull --rebase. It finished without an error. Her commit was gone from her own branch too.

Here's why. git pull --rebase uses git's fork-point logic: it looks at the history of Alice's copy of the remote branch, sees that her commit was once on it (she pushed it herself), and concludes it's already upstream, so there's nothing to replay. After a force push that's exactly wrong.

The commit is still in Alice's reflog:

bash
git reflog
# 95d6814 HEAD@{2}: commit: alice work

Put the branch back on it, then rebase without fork-point:

bash
git reset --hard 95d6814
git fetch
git rebase --no-fork-point origin/main
git push origin main

That replayed Alice's commit on top of Bob's, and the remote ended up with both. The rule of thumb: after anyone force-pushes a shared branch, whoever had work on it rebases with --no-fork-point (or merges), never a plain git pull --rebase.

The short version#

  • --force-with-lease alone can still erase a teammate's commits after a background fetch. Add --force-if-includes, or set push.useForceIfIncludes.
  • A Claude Code deny rule stops git push --force but not git push origin +main.
  • A pre-push hook that checks the remote's commit is part of yours stops every form, for every agent.
  • Block force pushes on the server (receive.denyNonFastForwards, or branch protection on your host). Nothing gets past that.
  • After a force push, recover from git reflog and rebase with --no-fork-point. A plain git pull --rebase can silently drop your own commits.

Force pushes are one way two people's agents trip over each other. For the more common one, two agents changing the same files at the same time, see how to stop agents editing the same files.