Blocking force pushes with a pre-receive hook Jump to heading
A force push to a shared branch does not corrupt anything — it does something worse, quietly. The remote accepts a new tip that is not a descendant of the old one, every other clone’s next git pull reports a divergence they did not create, and the commits that used to be on the branch become unreachable and invisible to anyone who does not know to look in the reflog. Recovering is possible; noticing in time is the hard part. This recipe implements the server-side half of that protection, one of the enforcement patterns from Server-Side Hook Enforcement, so a rewrite of a protected branch is refused before it ever lands.
When to use this approach Jump to heading
- You run a self-hosted remote or a self-managed platform where custom
pre-receivehooks are available and branch protection settings are not expressive enough. - The rule needs to be conditional —
mainandrelease/*are immutable, but everyone’s ownfeat/*branches may be rewritten freely, which is what makes interactive rebase workflows usable at all. - You want the rejection message to teach: instead of “non-fast-forward”, the developer should read the exact command that resolves their situation.
- You need an audit trail of who attempted a rewrite and when, which a built-in setting does not give you.
- A single named role — a release engineer during a cutover — needs a documented override rather than an ad-hoc “someone disabled the hook for ten minutes”.
If none of those apply, prefer the built-in receive.denyNonFastForwards described in the server-side hook guide: less code on the push path is a feature.
Step 1 — Decide which refs are protected Jump to heading
Write the protected set down before writing any shell. The distinction is between refs other people build on and refs that belong to one person.
The asymmetry is the point. Blocking force pushes everywhere is a policy teams route around within a week, because cleaning up your own branch before review is good practice, not misbehaviour.
Step 2 — Detect a rewrite precisely Jump to heading
A push is a fast-forward exactly when the old tip is an ancestor of the new tip. Git answers that question directly, and no heuristic based on commit counts or dates is as reliable.
# The predicate, in isolation. Exit 0 means "fast-forward".
git merge-base --is-ancestor "$oldrev" "$newrev" What changed: nothing yet — this is the test the hook is built around. It returns non-zero both when history was rewritten and when the new tip is unrelated to the old one, which are the two cases you want to refuse.
# Verify on any repository with a rewritten branch in its reflog:
git merge-base --is-ancestor "$(git rev-parse @{1})" HEAD && echo "fast-forward" || echo "rewrite" Step 3 — Write the hook, with a message that helps Jump to heading
#!/bin/sh
# hooks/pre-receive — refuse rewrites of protected refs.
# Installed inside the bare repository on the remote.
zero='0000000000000000000000000000000000000000'
pusher="${GL_USERNAME:-${USER:-unknown}}"
status=0
is_protected() {
case "$1" in
refs/heads/main|refs/heads/release/*|refs/tags/*) return 0 ;;
*) return 1 ;;
esac
}
while read -r oldrev newrev refname; do
is_protected "$refname" || continue
# Creation of a new protected ref is always fine.
[ "$oldrev" = "$zero" ] && continue
if [ "$newrev" = "$zero" ]; then
echo "" >&2
echo " Rejected: $refname may not be deleted." >&2
echo " Protected refs are permanent. Open a request if this is intentional." >&2
echo "" >&2
status=1
continue
fi
if git merge-base --is-ancestor "$oldrev" "$newrev"; then
continue # ordinary fast-forward, nothing to do
fi
# Count what the rewrite would orphan, so the message is concrete.
lost=$(git rev-list --count "$newrev..$oldrev")
echo "" >&2
echo " Rejected: $refname does not accept rewritten history." >&2
echo " This push would drop $lost commit(s) already on the remote." >&2
echo "" >&2
echo " If you meant to add to the branch:" >&2
echo " git pull --rebase origin ${refname#refs/heads/}" >&2
echo " git push origin ${refname#refs/heads/}" >&2
echo "" >&2
echo " If you meant to undo a bad commit, add a revert instead:" >&2
echo " git revert <bad-sha> && git push" >&2
echo "" >&2
status=1
done
exit "$status" What changed: pushes that add commits still pass untouched; pushes that would orphan commits on main, a release branch, or a tag are refused with the number of commits at stake and the two commands that cover the situations people are actually in.
Verify the syntax and permissions before anyone else pushes:
sh -n hooks/pre-receive && chmod +x hooks/pre-receive && ls -l hooks/pre-receive
# Expect: syntax silence, then -rwxr-xr-x ... hooks/pre-receive SAFETY WARNING — this hook rejects pushes for the entire repository, and a shell error inside it will reject every push including the one that fixes it. Confirm you can reach the server independently of Git before enabling it. To recover from a broken hook, run
mv hooks/pre-receive hooks/pre-receive.disableddirectly on the server; nothing needs to be pushed to restore service.
Step 4 — Log every attempt Jump to heading
The rejection message helps the developer; the log helps you understand whether the rule is doing its job or fighting a legitimate workflow.
# Insert just before `status=1` in the rewrite branch:
logger -t git-policy \
"force-push rejected: user=$pusher ref=$refname old=$oldrev new=$newrev lost=$lost" What changed: each rejection now lands in the system log with the pusher, the ref, both tips, and the number of commits that would have been orphaned — enough to reconstruct the intent afterwards.
# Verify the entries are arriving (systemd hosts):
journalctl -t git-policy --since "1 hour ago"
# Expect one line per rejected attempt Read that log after the first week. A steady stream of rejections from one workflow usually means the rule is right and a habit needs changing; a steady stream from many people usually means the protected set is too wide.
Step 5 — Provide a documented override Jump to heading
Genuine emergencies exist: a leaked credential committed to main must be rewritten out of history, and the person doing it needs the branch to accept a non-fast-forward push exactly once. Undocumented overrides get invented under pressure, so define one in advance.
# Break-glass: a one-shot token file placed on the server by an authorised operator.
# It is consumed on use, so it cannot be left switched on by accident.
override='/var/lib/git-policy/allow-rewrite'
if [ -f "$override" ]; then
rm -f "$override"
logger -t git-policy "OVERRIDE USED: user=$pusher ref=$refname old=$oldrev new=$newrev"
echo " Override consumed — this rewrite was allowed and has been logged." >&2
continue
fi What changed: an operator with server access can authorise exactly one rewrite by creating the token file; the hook deletes it as it is used, so the protection is never left disabled.
# On the server, immediately before the planned rewrite:
sudo install -m 0600 /dev/null /var/lib/git-policy/allow-rewrite
# After the push, confirm it is gone:
test -e /var/lib/git-policy/allow-rewrite && echo "STILL OPEN — investigate" || echo "closed" The procedure that goes with the token belongs in your runbook alongside the history-rewriting steps in Removing a Leaked Secret from Git History, because a rewrite of a shared branch requires every clone to be re-synchronised afterwards.
Step 6 — Verify against a scratch remote Jump to heading
# 1. Build a throwaway remote with the hook installed
git init --bare /tmp/scratch.git
install -m 0755 hooks/pre-receive /tmp/scratch.git/hooks/pre-receive
git remote add scratch /tmp/scratch.git
# 2. A normal push must succeed
git push scratch main
# Expect: branch 'main' set up to track 'scratch/main'
# 3. A rewrite must be refused
git commit --amend --no-edit
git push --force scratch main
# Expect: remote: Rejected: refs/heads/main does not accept rewritten history.
# 4. A personal branch must still be rewritable
git checkout -b feat/scratch-test && git commit --allow-empty -m "wip" && git push scratch feat/scratch-test
git commit --amend --no-edit && git push --force scratch feat/scratch-test
# Expect: forced update — the protected set does not cover feat/* Step 4 is the one people skip and the one that matters: a hook that blocks rewrites everywhere will be worked around, and a policy that is worked around protects nothing.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not just set receive.denyNonFastForwards? Jump to heading
Use it when the rule is repository-wide — it is one config line with no script to maintain. A hook earns its place when the policy is conditional: protecting only main and release branches while leaving personal branches rewritable, allowing a named release engineer to override, or emitting a message that tells the developer exactly which command to run instead. If you need none of that, the config setting is the better answer.
Does this hook block branch deletions too? Jump to heading
Only if you ask it to. A deletion arrives with an all-zero new object name, which the ancestor test cannot evaluate, so it must be handled explicitly. The hook shown here rejects deletions of protected refs on the grounds that deleting main is at least as destructive as rewriting it, but the two branches of the condition are separate and you can allow one without the other.
What if a developer has already force-pushed and lost commits? Jump to heading
The commits are still in the remote’s object store until it is garbage-collected, and the previous tip is recorded in the server’s reflog for the ref. Recover it with git reflog show <branch> on the server, or from any clone that still has the old tip, then push the recovered SHA back. Act quickly: once gc runs and the objects are unreachable, only a backup will help.
Related Jump to heading
- Server-Side Hook Enforcement — the parent guide covering
pre-receive,update, andpost-receive, and where each belongs in the push transaction. - Enforcing File Size Limits on the Remote — the same hook shape applied to object size, catching a large binary before it is permanent.
- Recovering Lost Commits with git reflog — what to do when a rewrite did land and the previous tip needs to come back.