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-receive hooks are available and branch protection settings are not expressive enough.
  • The rule needs to be conditionalmain and release/* are immutable, but everyone’s own feat/* 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.

Which refs are protected and whyTwo groups of refs. Protected refs — main, release slash star, and tags — are shared foundations that other clones track, so rewrites are rejected. Personal refs — feature, fix and chore branches — belong to one author and stay rewritable so interactive rebase remains usable.Protected — reject rewritesrefs/heads/mainevery clone tracks it; every build starts hererefs/heads/release/*shipped artefacts are traced back to these tipsrefs/tags/*a moved tag invalidates every recorded versionshared foundation — immutability is the contractPersonal — allow rewritesrefs/heads/feat/*rebased and squashed before reviewrefs/heads/fix/*amended freely while the fix takes shaperefs/heads/chore/*short-lived; no one else tracks themone author — rewriting is normal hygiene

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"
Fast-forward versus rewrite, as the graph sees itIn a fast-forward the old tip commit C is still reachable from the new tip E, so nothing is lost. In a rewrite the new tip D-prime descends from B instead, leaving commits C and D unreachable and invisible to anyone who does not inspect the reflog.Fast-forward — acceptedABCold tipDEnew tipC is still an ancestor of E —nothing on the remote became unreachableRewrite — rejectedABCDorphaned — unreachableonly the reflog still knows they existC'rewritten tip: not a descendant of C,so the ancestor test fails and the push is refused

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.disabled directly 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.

What to do when the hook rejects a pushA rejected push splits three ways. If the rewrite was accidental, pull with rebase and push again. If history genuinely needs correcting, add a revert commit. If a secret must be removed from history, use the audited override with a break-glass token and announce the rewrite.push rejectedwhy did it rewrite?Accidentalstale local branch,amended after pushingBad commit to undowrong change already sharedSecret in historymust be rewritten out —the only real emergencygit pull --rebasethen push normallygit revert <sha>history stays intactaudited overridetoken + announcement
  # 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.