Testing server-side hooks safely Jump to heading

A broken client-side hook inconveniences the person who installed it. A broken server-side hook stops everyone: every push to the repository fails until someone with server access fixes it, and if the hook is global, every repository on the server is affected. Server hooks also run in an environment that is easy to get wrong — a different user, a minimal PATH, no terminal, a quarantine directory for incoming objects. Testing them properly means exercising them exactly as the server will: against a bare repository, with real pushes, under the server’s user and environment, and with a timing budget. This page sets up that test harness and a deployment routine that keeps a bad hook from taking everyone down, within server-side hook enforcement.

When to use this approach Jump to heading

  • You maintain pre-receive, update or post-receive hooks on a self-hosted server.
  • A hook change has blocked all pushes before, or you fear it might.
  • Hooks behave differently on the server than in local tests.
  • You are about to deploy a new policy, such as enforcing commit message policy in pre-receive.

Step 1 — Build a disposable test server locally Jump to heading

A bare repository on your own machine behaves like a server for hook purposes: pushes to it run its receive hooks with the same input format and the same quarantine behaviour.

#!/bin/sh
# scripts/server-hooks/make-test-server.sh — fresh bare repo with the hooks installed
set -eu
srv=$(mktemp -d)/server.git
git init -q --bare "$srv"
cp server-hooks/pre-receive server-hooks/update "$srv/hooks/"
chmod +x "$srv/hooks/"*
echo "$srv"
srv=$(sh scripts/server-hooks/make-test-server.sh)
git remote add testsrv "$srv"
git push testsrv main          # runs the hooks exactly as a real server would
A disposable server for hook testsA script creates a bare repository in a temporary directory and installs the hooks under test. A local clone pushes to it as a remote, so the hooks receive real ref updates and objects through the same quarantine path a production server uses.Temp bare repogit init --bareInstall hookspre-receive, updatePush scenariosgood, bad, edgeAssertaccepted / rejectednothing about this touches the real server

Step 2 — Script the scenarios that matter Jump to heading

Each policy has a handful of cases: an acceptable push, each kind of violation, and edge cases such as new branches, deletions, tags and force-pushes. Script them so they can be rerun after every change.

#!/bin/sh
# scripts/server-hooks/test-scenarios.sh
set -u
srv=$(sh scripts/server-hooks/make-test-server.sh)
work=$(mktemp -d); cd "$work"
git init -q && git config user.email [email protected] && git config user.name t
git remote add srv "$srv"
expect() {   # expect <ok|reject> <description> <command...>
  want=$1 desc=$2; shift 2
  if "$@" >/dev/null 2>&1; then got=ok; else got=reject; fi
  [ "$got" = "$want" ] && echo "pass  $desc" || { echo "FAIL  $desc (wanted $want, got $got)"; failed=1; }
}
failed=0
git commit -q --allow-empty -m "chore: initial PAY-1"
expect ok     "good message on new branch"   git push -q srv HEAD:refs/heads/main
git commit -q --allow-empty -m "stuff"
expect reject "bad message rejected"         git push -q srv HEAD:refs/heads/main
git reset -q --hard HEAD~1
expect ok     "branch deletion allowed"      git push -q srv :refs/heads/does-not-exist
exit $failed

Step 3 — Reproduce the server’s environment Jump to heading

Hooks that pass locally can fail on the server because of the environment: the hook runs as the server account, often with a minimal PATH, no home directory configuration and no terminal. Run the tests the same way.

# Run the scenarios under a clean environment similar to the server's
env -i HOME=/nonexistent PATH=/usr/bin:/bin sh scripts/server-hooks/test-scenarios.sh

# On the server itself, as the server user, against a scratch repository
sudo -u git env -i PATH=/usr/bin:/bin sh scripts/server-hooks/test-scenarios.sh
Developer shell against server hook environmentA developer's shell has a rich PATH, Git configuration in the home directory and a terminal. A server hook runs as the service account, often with a minimal PATH, no user Git configuration and no terminal, and with incoming objects held in a quarantine directory until the hook succeeds.Developer shellServer hookuseryougit service accountPATHfull, tools availableminimal~/.gitconfigyoursnone or the service'snew objectsin the object storein quarantineuse absolute paths to tools and never assume a terminal

Quarantine is worth understanding: during pre-receive, incoming objects live in a temporary directory that Git makes visible to the hook through environment variables. Commands run from the hook see them; a separate process started outside that environment would not.

Step 4 — Measure how long the hook takes Jump to heading

Every push waits for pre-receive. A hook that takes ten seconds on a large push makes every developer wait. Time the scenarios, including a push of many commits.

# A push of 500 commits — the worst realistic case for most teams
for i in $(seq 1 500); do git commit -q --allow-empty -m "chore: load $i PAY-1"; done
time git push -q srv HEAD:refs/heads/load-test
pre-receive time by push sizeAn illustrative hook that checks messages and signatures. A typical push of a few commits takes a fraction of a second. A large first push of five hundred commits takes a few seconds, which is acceptable. The same hook calling an external API per commit would take minutes.seconds of pre-receive per push (illustrative)5 commits0.2 s50 commits0.6 s500 commits3.8 s500, API call each210 snever make a network call per commit in a receive hook

Step 5 — Deploy with a fallback and in stages Jump to heading

Deploy hook changes so a bug can be reversed in seconds. Keep the previous version alongside the new one, roll out to one low-traffic repository first, and only then to the rest.

# On the server
cd /srv/git/app.git/hooks
cp pre-receive pre-receive.previous
install -m 0755 /tmp/new-pre-receive pre-receive
# If anything goes wrong:
#   mv pre-receive.previous pre-receive

⚠️ SAFETY WARNING: A pre-receive hook that exits non-zero for every push — through a syntax error, a missing tool or a wrong path — blocks all pushes to that repository, including the push that would fix it. Deploy hooks through server access, not through a repository the hook itself guards, and keep the .previous copy until the new version has handled real pushes. For global hooks, test on a single repository first.

Step 6 — Run the tests in CI for every hook change Jump to heading

Keep server hooks in an administration repository and run the scenario suite on every change, under a clean environment. Deploy only from that repository’s main branch.

on:
  pull_request: { paths: ["server-hooks/**", "scripts/server-hooks/**"] }
jobs:
  test-hooks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: shellcheck --shell=sh server-hooks/*
      - run: env -i HOME=/tmp PATH=/usr/bin:/bin sh scripts/server-hooks/test-scenarios.sh

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Can I test hooks without a push at all? Jump to heading

For update hooks, yes: they take arguments, so you can call them directly. pre-receive reads standard input and depends on quarantine, so a real push to a test server is the more faithful test.

Why does my hook see objects during the test but not in production? Jump to heading

Usually because the production hook starts a subprocess with a cleaned environment, losing the quarantine variables. Run Git commands directly from the hook script rather than through wrappers that reset the environment.

Should post-receive hooks be tested the same way? Jump to heading

Yes, though their failures do not block pushes. Test that they finish quickly — slow post-receive hooks still delay the client’s push output — and that their side effects, such as notifications, are idempotent.