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.

Where each server-side hook runs in the push transactionA push arrives at the remote. pre-receive runs once for the whole push and can reject it. update runs once per ref and can reject individual refs. Refs are then written to the object store — the point of no return — after which post-receive runs for notifications only and cannot reject anything.CAN REJECT THE PUSHpre-receiveonce per pushrefs on stdinupdateonce per refrefs as argumentspoint of no returnrefs written to the object storethe push has landedCANNOT REJECT ANYTHINGpost-receivenotify · trigger CIchat message, pipeline dispatch,mirror sync, deployment webhookexit code is ignored here —a failure logs, it does not blockuse pre-receive for whole-push rules,update for per-ref rules

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-receive hook 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 with mv hooks/pre-receive hooks/pre-receive.disabled executed 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.

Three places server-side policy can liveThree columns. A self-hosted bare repository runs hooks from its own hooks directory. A self-managed platform runs global custom hooks applied to every project. A SaaS platform exposes declarative branch protection and required status checks instead of custom hook scripts.Self-hosted bare reporepo.git/hooks/pre-receiverepo.git/hooks/updateFull control, no platformlimits on what you can check.You own uptime: a hook thathangs blocks every push.Best for: internal mirrors,air-gapped estatesSelf-managed platformcustom_hooks/pre-receive.d/global server hooksOne script, applied to everyproject on the instance.Rollout is instance-wide, soreport-only first is essential.Best for: org-wide policyon one instanceSaaS platformbranch protection rulesrequired status checksNo custom hook scripts —policy is declarative.Anything the settings cannotexpress becomes a blocking job.Best for: most teams —no server to operate

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.

One policy definition, two enforcement pointsA single shared policy script is read by two consumers: a local pre-push hook that warns the developer in seconds and can be skipped, and a server-side pre-receive hook that rejects the push and cannot be skipped. Both paths lead to the same verdict.policy/branch-name.shone predicatecommitted to the repolocal pre-pushfails in ~50 msskippable with --no-verifyserver pre-receiverejects the transactionnot skippableidentical verdictno surprise at push time,no gap in enforcement

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 hookRuns whereCan rejectTypical use
pre-receiveReceiving repository, once per pushYes — whole pushBranch naming, forbidden paths, cross-ref invariants
updateReceiving repository, once per refYes — that ref onlyFast-forward-only branches, protected tags
post-receiveReceiving repository, after refs are writtenNoNotifications, mirror sync, pipeline dispatch
core.hooksPathClient or servern/aPoint Git at a versioned hooks directory instead of .git/hooks
receive.denyNonFastForwardsServer configYesBuilt-in alternative to a hand-written fast-forward update hook
receive.denyDeletesServer configYesRefuse ref deletions without writing a script
receive.fsckObjectsServer configYesReject 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.