Server-Side Hook Enforcement Jump to heading
Local hooks are a courtesy; server-side hooks are a contract. Every check you install with local hook configuration with Husky can be skipped by one developer typing --no-verify on a Friday afternoon, and every check you delegate to lint-staged can be skipped the same way. That is not a flaw — fast local feedback should be interruptible — but it means a repository whose only enforcement lives on developer machines has no enforcement at all. This part of Git Automation & CI/CD Hook Engineering covers the other half of the boundary: the hooks that run inside the receiving repository, where the answer to “can I skip this?” is no.
Server-side hooks execute during the push transaction itself. Git streams the proposed ref updates to your script, waits for its exit code, and either commits the whole push to the object store or rejects it and leaves the remote untouched. That atomicity is the property you are buying: a rejected push never half-lands, so the remote never enters a state your policy forbids.
Prerequisites Jump to heading
Step 1 — Choose the Hook That Matches Your Decision Jump to heading
Three hooks fire on the receiving end, and picking the wrong one is the most common reason a policy misbehaves. The distinction is not what they can check but when they run and what they can still stop.
Pick pre-receive when the decision needs to see the whole push at once: “a push may not touch main and a release branch in the same transaction”, or “at least one commit in this push must reference an issue key”. Pick update when refs are independent and you want partial acceptance: rejecting a rewritten main while still accepting the three feature branches pushed alongside it. Pick post-receive only for side effects — notifications, mirror synchronisation, pipeline dispatch — because by the time it runs the refs are already written and its exit code changes nothing.
Step 2 — Write a pre-receive Hook That Reads the Ref Triple Jump to heading
A pre-receive hook receives one line per ref update on standard input, each line holding three space-separated values: the old object name, the new object name, and the full ref name. Reading them correctly is most of the work.
#!/bin/sh
# hooks/pre-receive — reject branch names that do not match the team convention.
# Runs once per push, inside the bare repository, before any ref is written.
zero='0000000000000000000000000000000000000000'
status=0
# Each line is: <old-sha> <new-sha> <refname>
while read -r oldrev newrev refname; do
# Only police branches; tags are handled by a separate rule below.
case "$refname" in
refs/heads/*) branch=${refname#refs/heads/} ;;
*) continue ;;
esac
# A deletion arrives as an all-zero new object name — nothing to validate.
[ "$newrev" = "$zero" ] && continue
# Allowed: main, and type/short-description with lowercase and dashes.
case "$branch" in
main|release/*) ;;
feat/*|fix/*|chore/*|docs/*) ;;
*)
echo "policy: '$branch' is not an allowed branch name." >&2
echo "policy: use main, release/*, or feat|fix|chore|docs/<description>." >&2
status=1
;;
esac
# Silence the unused-variable warning while documenting intent.
: "$oldrev"
done
exit "$status" Verify the hook is wired up and syntactically valid before anyone else pushes:
# From inside the bare repository on the server
sh -n hooks/pre-receive && echo "syntax OK"
chmod +x hooks/pre-receive
ls -l hooks/pre-receive # expect an executable bit: -rwxr-xr-x Three details in that script matter more than they look. Writing diagnostics to standard error (>&2) is what makes them appear in the pushing developer’s terminal prefixed with remote:. Collecting a status variable instead of exiting on the first violation means one push reports every problem rather than making the developer discover them one at a time. And treating the all-zero object name as a deletion prevents the hook from trying to inspect a commit that does not exist.
SAFETY WARNING — a
pre-receivehook that exits non-zero for every push locks the entire team out of the repository, including whoever needs to push the fix. Keep a second path to the server (shell access to the bare repo) before you enable one, and recover withmv hooks/pre-receive hooks/pre-receive.disabledexecuted directly on the server.
Step 3 — Add Per-Ref Rules With an update Hook Jump to heading
Where pre-receive sees everything, update runs once per ref and receives the same three values as positional arguments. Its natural job is protecting individual refs — most usefully, refusing history rewrites on branches that other people build on.
#!/bin/sh
# hooks/update — protect shared branches from non-fast-forward updates.
# Arguments: $1 = refname, $2 = old object name, $3 = new object name
refname="$1"
oldrev="$2"
newrev="$3"
zero='0000000000000000000000000000000000000000'
case "$refname" in
refs/heads/main|refs/heads/release/*) ;;
*) exit 0 ;; # other branches may be rewritten freely
esac
if [ "$newrev" = "$zero" ]; then
echo "policy: $refname may not be deleted." >&2
exit 1
fi
if [ "$oldrev" != "$zero" ]; then
# A fast-forward means the old tip is an ancestor of the new tip.
if ! git merge-base --is-ancestor "$oldrev" "$newrev"; then
echo "policy: $refname only accepts fast-forward updates." >&2
echo "policy: rebase your branch onto the current tip and push again." >&2
exit 1
fi
fi
exit 0 Confirm the rule behaves as intended against your scratch remote:
# Should succeed: an ordinary fast-forward
git push scratch main
# Should be rejected: a rewritten history
git commit --amend --no-edit && git push --force scratch main
# Expect: remote: policy: refs/heads/main only accepts fast-forward updates. The git merge-base --is-ancestor test is the precise definition of a fast-forward and is far more reliable than comparing commit counts or timestamps. It is the same predicate a platform’s “block force pushes” setting uses internally, which is why a hand-written hook and a platform rule agree on edge cases such as a branch that has been merged and re-pushed unchanged.
Step 4 — Deploy the Hook Where Pushes Actually Land Jump to heading
A hook is only enforcement if it lives on the repository developers push to. Where that is depends on how your remote is hosted, and the diagram below maps the three arrangements you are likely to meet.
On a self-hosted remote, installation is a copy and a chmod:
# On the server, inside the bare repository
install -m 0755 /tmp/pre-receive hooks/pre-receive
git config --local core.hooksPath hooks # explicit, survives platform defaults On a SaaS platform the equivalent move is to express the rule declaratively and back it with a blocking job. A branch-protection setting covers the fast-forward rule from Step 3 directly; a branch-name convention becomes a required check that reads the ref from the pipeline’s environment and exits non-zero, which is enforced at merge in the same way a hook is enforced at push. The details of wiring those checks live in CI/CD Pipeline Trigger Mapping, and the signature-specific version of the same idea is covered in Commit Verification Gates.
Roll out in report-only mode Jump to heading
Whatever the target, ship the hook once with its rejection disabled:
# Report-only: log the violation, let the push through.
if ! branch_is_allowed "$branch"; then
echo "policy (report-only): '$branch' would be rejected." >&2
logger -t git-policy "would reject $branch from ${GL_USERNAME:-unknown}"
# status=1 <- enable after a quiet sprint
fi Step 5 — Mirror the Same Rules Locally Jump to heading
Enforcement on the server is necessary but hostile on its own: a developer learns their branch name is wrong only after finishing the work and pushing. The fix is not to weaken the server rule but to run the same predicate locally, early, where it costs nothing to correct.
Commit the predicate to the repository and call it from both sides:
#!/bin/sh
# policy/branch-name.sh — single source of truth, called locally and on the server.
# Usage: policy/branch-name.sh <branch>
branch="$1"
case "$branch" in
main|release/*|feat/*|fix/*|chore/*|docs/*) exit 0 ;;
*) exit 1 ;;
esac #!/usr/bin/env sh
# .husky/pre-push — same rule, 50 ms, before the network call.
branch=$(git rev-parse --abbrev-ref HEAD)
if ! sh policy/branch-name.sh "$branch"; then
echo "This branch name will be rejected by the remote: $branch" >&2
echo "Rename it: git branch -m feat/your-description" >&2
exit 1
fi Verify both ends agree before you rely on them:
git checkout -b nonsense-name
git push origin nonsense-name --no-verify # bypasses local, must still be rejected remotely
# Expect: remote: policy: 'nonsense-name' is not an allowed branch name. That last command is the important test. It proves the local hook is a convenience and the server hook is the control — the property that makes the whole arrangement trustworthy.
Integration With Adjacent Tooling Jump to heading
Boundary with local hooks. Local hook configuration with Husky owns everything that should be fast and interruptible: formatting, staged-file linting, commit-message shape. Server-side hooks own everything that must hold regardless of who is pushing. When the same rule belongs on both sides, factor it into a script both call rather than writing it twice — two implementations drift, and the drift always shows up as a push that passes locally and fails remotely.
Boundary with pipeline triggers. A server-side hook must answer in well under a second because it blocks the push. Anything that compiles, installs dependencies, or runs a test suite belongs to the pipeline described in CI/CD Pipeline Trigger Mapping, where it can run in parallel and report back as a required check. A useful rule of thumb: if the check needs the working tree, it is a pipeline job; if it only needs the ref names and object metadata, it is a hook.
Boundary with pre-push validation. The rules in Pre-Push Validation Rules are the client-side mirror of this page. Secret scanning is the clearest example: scanning locally at pre-push stops the credential before it leaves the machine, and re-checking in pre-receive stops it when someone bypasses the local hook. Neither alone is sufficient.
Configuration Reference Jump to heading
| Setting or hook | Runs where | Can reject | Typical use |
|---|---|---|---|
pre-receive | Receiving repository, once per push | Yes — whole push | Branch naming, forbidden paths, cross-ref invariants |
update | Receiving repository, once per ref | Yes — that ref only | Fast-forward-only branches, protected tags |
post-receive | Receiving repository, after refs are written | No | Notifications, mirror sync, pipeline dispatch |
core.hooksPath | Client or server | n/a | Point Git at a versioned hooks directory instead of .git/hooks |
receive.denyNonFastForwards | Server config | Yes | Built-in alternative to a hand-written fast-forward update hook |
receive.denyDeletes | Server config | Yes | Refuse ref deletions without writing a script |
receive.fsckObjects | Server config | Yes | Reject malformed objects at push time |
Two of those rows deserve emphasis: receive.denyNonFastForwards and receive.denyDeletes do in one config line what Step 3’s update hook does in twenty. Reach for the built-in setting first and write a hook only when the policy is genuinely custom. Less code on the push path means fewer ways to break every push at once.
Common Failure Modes and Diagnostics Jump to heading
The hook never runs. Symptom: a push that should be rejected succeeds silently. Root cause: the file is not executable, or it sits in the wrong directory — a common mistake is installing into a non-bare clone’s .git/hooks rather than the bare repository developers actually push to. Fix: ls -l hooks/pre-receive on the server and confirm both the executable bit and the path; check git config core.hooksPath in case it points elsewhere.
Every push hangs for seconds. Symptom: pushes that used to be instant now take five or ten seconds. Root cause: the hook shells out per commit — git log, git cat-file, or a network call inside the read loop. Fix: hoist repository-wide queries out of the loop and use a single git rev-list "$oldrev..$newrev" to enumerate new commits once.
Diagnostics never reach the developer. Symptom: the push is rejected with no explanation. Root cause: messages were written to standard output instead of standard error, where Git’s remote: relay picks them up. Fix: append >&2 to every echo intended for a human.
The hook rejects the first push to an empty repository. Symptom: the initial push of main fails. Root cause: the old object name is all zeros for a newly created ref, and the script tried to inspect it. Fix: test for the zero object name and treat the range as “all commits reachable from the new tip”.
A merge from a long-lived branch trips a path rule. Symptom: a merge commit is rejected for touching files the author never edited. Root cause: comparing against the wrong parent. Fix: enumerate only the commits genuinely new to the remote with git rev-list "$newrev" --not --all, which excludes anything already present.
Team Rollout Jump to heading
Frequently Asked Questions Jump to heading
Can a developer bypass a server-side hook with --no-verify? Jump to heading
No. The --no-verify flag suppresses client-side hooks only — pre-commit, commit-msg, and pre-push all run on the developer’s machine and are therefore advisory. A pre-receive or update hook executes inside the receiving repository as part of the push transaction, so the only way to skip it is to have write access to the server and disable it there. That asymmetry is exactly why enforcement belongs on the server and fast feedback belongs on the client.
What is the difference between pre-receive and update? Jump to heading
pre-receive runs once per push and reads every ref update from standard input as old-sha new-sha refname triples, so it can reason about the push as a whole — for example rejecting a push that touches both a release branch and the trunk. update runs once per ref and receives the same three values as arguments, so it can accept some refs and reject others in the same push. Use pre-receive for whole-push policy and cross-ref invariants; use update when refs should be judged independently.
How do I test a server-side hook without breaking pushes for the team? Jump to heading
Create a bare clone on a scratch host, install the hook there, add it as a second remote, and push test branches to it. Alternatively run the hook in report-only mode first: log every violation and exit 0 for a sprint, collect the log, then flip the exit code to 1 once the noise is gone. Report-only rollout is the single most effective way to avoid a policy that blocks legitimate work on day one.
Do hosted platforms support custom pre-receive hooks? Jump to heading
It varies. Self-managed GitLab and GitHub Enterprise Server both allow custom server-side hooks; the SaaS tiers generally do not, and expose branch protection rules, rulesets, or push rules instead. On those platforms, express the same policy declaratively through protection settings and enforce anything they cannot express in a required status check, which is blocking in the same way a hook is.
Should a server-side hook run tests? Jump to heading
No. A pre-receive hook holds the push open while it runs, so every second of work is a second the developer stares at a stalled terminal. Keep server-side hooks to cheap, deterministic checks on refs and objects — name patterns, file size, forbidden paths, signature presence. Anything that needs to build or execute code belongs in the pipeline that the push triggers, gated as a required check before merge.
Related Jump to heading
- Blocking Force Pushes with a Pre-Receive Hook — a complete, copy-ready hook that distinguishes a rewrite from a fast-forward and explains itself to the developer.
- Enforcing File Size Limits on the Remote — stop a 400 MB binary from entering history at the only point where refusal is still cheap.
- Mirroring Local Hook Checks in Server-Side Policy — factor one predicate into two enforcement points so local and remote verdicts never disagree.
- Local Hook Configuration with Husky — the client-side half of the boundary, where checks are fast, friendly, and deliberately skippable.
- Pre-Push Validation Rules — which checks earn their place in the last hook before the network call, and how to keep them under the attention budget.