Mirroring local hook checks in server-side policy Jump to heading

Two copies of a rule become two different rules. Someone tightens the branch-name pattern in the pre-receive hook on the server, nobody updates the pre-push hook in the repository, and for the next month developers get a green local check followed by a red remote rejection with a slightly different message. The fix is not more discipline β€” it is having one implementation with two callers. This recipe shows that arrangement concretely, applying the split described in Server-Side Hook Enforcement between advisory client checks and authoritative server ones.

When to use this approach Jump to heading

  • The same rule needs to be enforced on the server and surfaced early on the client β€” branch naming, commit-message shape, forbidden paths, file size.
  • Developers have started complaining that β€œit passed locally”, the classic symptom of two implementations drifting apart.
  • Your policy is changing while you tune it, so every change would otherwise need to be made twice and deployed to two places.
  • You already run local hooks through Husky or the pre-commit framework and want the server to agree with them exactly.
  • The rule is cheap and deterministic. Anything needing a build belongs in the pipeline described in CI/CD Pipeline Trigger Mapping, not in a hook on either side.

Step 1 β€” Extract the predicate into a versioned script Jump to heading

The script takes its input as arguments, prints nothing on success, and returns 0 or 1. No Git commands, no assumptions about a working tree β€” that keeps it callable from a bare repository where there is no checkout at all.

#!/bin/sh
# policy/check-ref.sh β€” the single definition of "is this ref update allowed?"
# Usage: policy/check-ref.sh <refname> <subject-of-first-new-commit>
# Exit 0 = allowed. Exit 1 = rejected, with the reason on stdout.

POLICY_VERSION='2026.07.31'
[ "$1" = "--version" ] && { echo "$POLICY_VERSION"; exit 0; }

refname="$1"
subject="$2"

case "$refname" in
  refs/heads/main|refs/heads/release/*) ;;
  refs/heads/feat/*|refs/heads/fix/*|refs/heads/chore/*|refs/heads/docs/*) ;;
  refs/tags/*) ;;
  refs/heads/*)
    echo "branch '${refname#refs/heads/}' does not match the naming convention"
    echo "expected: main, release/<version>, or feat|fix|chore|docs/<description>"
    exit 1
    ;;
esac

# Conventional-commit shape on the first new commit's subject line.
case "$subject" in
  '') ;;                                     # nothing to check
  feat:*|fix:*|chore:*|docs:*|refactor:*|test:*|perf:*|build:*|ci:*) ;;
  feat\(*|fix\(*|chore\(*|docs\(*|refactor\(*|test\(*|perf\(*|build\(*|ci\(*) ;;
  *)
    echo "commit subject '$subject' is not a Conventional Commit"
    echo "expected: type(optional-scope): description"
    exit 1
    ;;
esac

exit 0

What changed: the rule now exists exactly once, in the repository, under review like any other code. The --version flag is what makes drift detectable later.

chmod +x policy/check-ref.sh
policy/check-ref.sh refs/heads/feat/thing "feat: add thing"; echo "exit=$?"   # exit=0
policy/check-ref.sh refs/heads/nonsense "feat: add thing"; echo "exit=$?"     # exit=1 + reason

Step 2 β€” Call it from the local hook Jump to heading

The local caller’s job is speed and tone. It runs in milliseconds, explains the fix, and β€” importantly β€” remains skippable.

#!/usr/bin/env sh
# .husky/pre-push β€” the same rule, before the network call.

branch=$(git rev-parse --symbolic-full-name HEAD)
subject=$(git log -1 --pretty=%s)

if ! reason=$(sh policy/check-ref.sh "$branch" "$subject"); then
  echo "" >&2
  echo "  This push will be rejected by the remote:" >&2
  echo "$reason" | sed 's/^/    /' >&2
  echo "" >&2
  echo "  Policy version: $(sh policy/check-ref.sh --version)" >&2
  echo "" >&2
  exit 1
fi

What changed: the developer now learns about the violation in about fifty milliseconds, from the same predicate the server will apply, with the reason quoted verbatim rather than paraphrased.

git checkout -b nonsense && git commit --allow-empty -m "wip"
git push origin nonsense
# Expect: local rejection naming the convention, before any network traffic

Step 3 β€” Call the same script from the server hook Jump to heading

The bare repository has no working tree, so the server reads the policy script out of the objects being pushed.

#!/bin/sh
# hooks/pre-receive β€” enforcement, using the policy from the pushed tree.

zero='0000000000000000000000000000000000000000'
status=0

while read -r oldrev newrev refname; do
  [ "$newrev" = "$zero" ] && continue

  # Extract the policy script from the incoming commit into a temp file.
  policy=$(mktemp) || exit 1
  if ! git show "$newrev:policy/check-ref.sh" > "$policy" 2>/dev/null; then
    rm -f "$policy"
    echo "  Rejected: policy/check-ref.sh is missing from $refname." >&2
    status=1
    continue
  fi

  subject=$(git log -1 --pretty=%s "$newrev")

  if ! reason=$(sh "$policy" "$refname" "$subject"); then
    version=$(sh "$policy" --version)
    echo "" >&2
    echo "  Rejected by policy $version:" >&2
    echo "$reason" | sed 's/^/    /' >&2
    echo "" >&2
    status=1
  fi
  rm -f "$policy"
done

exit "$status"
A bare repository has no working treeOn the left, a normal clone has a working tree so the policy script can be read from disk. On the right, the bare repository on the server has only an object store, so the hook extracts the script from the pushed commit with git show before running it.developer cloneworking treepolicy/*.shobject store.git/objectshook reads the file from disksh policy/check-ref.sh …bare repository on the serverno working treenothing to readobject storepushed objectshook extracts it from the pushed commitgit show $newrev:policy/check-ref.sh

What changed: the server evaluates the push against the policy contained in that very push, so a commit that legitimately updates the rules is judged by the rules it ships β€” and no separate deployment step is needed when the policy changes.

# Verify the server truly ignores the local hook:
git push origin nonsense --no-verify
# Expect: remote:   Rejected by policy 2026.07.31: branch 'nonsense' does not match …

SAFETY WARNING β€” because the server reads the policy from the pushed tree, a single commit can both weaken the rule and take advantage of the weakened version. Protect policy/ with a code-owners entry and require review on that path, exactly as you would for CODEOWNERS-governed directories. Without that guard the mirror is a convenience, not a control.

Step 4 β€” Pin the version so drift is visible Jump to heading

Two copies drift; one definition cannotAbove, a local hook and a server hook each contain their own copy of the rule; when one is edited the two disagree and a push passes locally but fails remotely. Below, both hooks call one versioned policy script committed to the repository, so both always evaluate the same logic and print the same version string.Two copies β€” they drift.husky/pre-pushrule copied by handhooks/pre-receiverule edited last monthno shared source"but it passed locally"One definition β€” they cannot.husky/pre-pushcalls the scripthooks/pre-receivecalls the scriptpolicy/check-ref.shversioned Β· reviewed2026.07.31both ends print the same version β€” a mismatch is a stale clone, and it says so

Because both callers print POLICY_VERSION, a disagreement stops being a mystery. A developer whose local hook reports 2026.06.01 while the server reports 2026.07.31 has not pulled since the rule changed, and the two lines in the terminal say exactly that.

# A one-line drift check anyone can run:
echo "local:  $(sh policy/check-ref.sh --version)"
echo "remote: $(git show origin/main:policy/check-ref.sh | sh /dev/stdin --version)"
# Expect the two to match; if not, git pull

Step 5 β€” Prove both ends agree Jump to heading

The four cases that prove the mirror worksA two by two matrix. A valid ref is accepted with or without no-verify. An invalid ref is stopped by the local hook when hooks run, and stopped by the server when the local hook is bypassed. No combination lets an invalid ref reach the remote.hooks run normallypushed with --no-verifyvalid refinvalid refacceptedlocal passes, server passesacceptedserver agrees with localstopped locallyno network traffic at allstopped by the serverthe bypass changes nothingfast pathsafety net
# Case 1 β€” valid ref, hooks on
git checkout -b feat/mirror-demo && git commit --allow-empty -m "feat: demo"
git push origin feat/mirror-demo               # accepted

# Case 2 β€” valid ref, hooks bypassed
git commit --allow-empty -m "feat: demo again"
git push origin feat/mirror-demo --no-verify   # accepted

# Case 3 β€” invalid ref, hooks on
git checkout -b Bad_Name && git commit --allow-empty -m "feat: demo"
git push origin Bad_Name                       # stopped locally, no network call

# Case 4 β€” invalid ref, hooks bypassed  ← the case that proves enforcement
git push origin Bad_Name --no-verify           # stopped by the server

Case 4 is the whole point of the exercise. If it succeeds, you have two advisory checks and no enforcement.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not let the server hook be the only check? Jump to heading

Because feedback arrives at the wrong moment. A rule that only fires on push tells the developer their work is unacceptable after it is finished, which is the most expensive point at which to find out. Running the identical predicate locally moves the same verdict to the moment the mistake is made, when correcting it costs seconds. The server copy stays because the local one is skippable β€” they serve different purposes and both are needed.

How does the server get the policy script if it lives in the repository? Jump to heading

The pre-receive hook reads it out of the pushed objects rather than the filesystem, using git show with the incoming commit β€” the bare repository has no working tree. Read the version being pushed, not the version on the default branch, so a push that legitimately updates the policy is evaluated against its own rules; guard that with review on the policy path so nobody weakens the rule and pushes in the same commit.

What if the local and server versions still disagree? Jump to heading

That is what the pinned version string is for. The local hook prints the policy version it ran, the server prints the one it enforced, and a mismatch appears in the rejection message rather than as an unexplained failure. In practice the common cause is a developer who has not pulled since the policy changed, and the message tells them exactly that.