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,updateorpost-receivehooks 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 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 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 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-receivehook 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.previouscopy 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.
Related Jump to heading
- Server-Side Hook Enforcement — the parent topic.
- Testing Git Hooks Before Sharing Them — the client-side equivalent.
- Post-Receive Hooks for Notifications and Deploys — the hooks that run after a push.
- Blocking Force-Pushes with a pre-receive Hook — a hook worth adding to the scenario suite.