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" 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
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
# 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.
Related Jump to heading
- Server-Side Hook Enforcement β the parent guide on which hook runs when, and why enforcement belongs on the server.
- Blocking Force Pushes with a Pre-Receive Hook β a policy that has no useful local mirror, because only the server knows the remoteβs current tip.
- How to Enforce Conventional Commits with commitlint β the richer, tool-backed version of the commit-subject rule sketched here.