Hooks as a Poor Man's CI
A `pre-receive` hook that checks out the incoming tree, runs a real check against it, and rejects the push at the transport layer if it fails, before a single ref is updated.

Hooks as a Poor Man's CI
Part 3 of 8 in the Beneath the Porcelain series: what you can build, break, and repair using nothing but git plumbing.
Part 1's post-receive hook deploys unconditionally: whatever lands on main, ships. That is fine for a personal blog and dangerous for anything with a second contributor. The fix most people reach for is a CI platform that runs on every push and reports a status back to GitHub or GitLab. That works, but it is also a second system, with its own queue, its own runner fleet, and its own outage calendar, sitting in front of a transport (git push) that already has a place to run arbitrary code before it accepts anything.
That place is pre-receive. Where post-receive runs after refs are updated and objects are already committed to the object database, pre-receive runs before any ref is updated, with the incoming objects already unpacked and available, but nothing yet visible to a clone. If pre-receive exits non-zero, git discards the whole push: no refs move, no objects become reachable, and the client sees the rejection immediately, in the output of their own git push. This is CI with no separate status API, because the rejection is the push failing, reported over the exact connection the developer is already watching, with no dashboard to open and no separate job to wait on.
The Simplest Version: Pattern-Match the Tree
A pre-receive hook reads the same <oldrev> <newrev> <refname> lines as post-receive, once per updated ref, but everything it decides happens before those refs actually update. A minimal gate: reject any push that introduces a file matching a secret-shaped pattern, and reject any push to main whose commit messages do not follow a convention.
#!/usr/bin/env bash
set -euo pipefail
zero="0000000000000000000000000000000000000000"
while read -r oldrev newrev refname; do
[ "$newrev" = "$zero" ] && continue # branch deletion
echo "pre-receive: checking $refname"
bad=$(git diff-tree --no-commit-id --name-only -r "$newrev" | grep -E '\.secret$' || true)
if [ -n "$bad" ]; then
echo "REJECTED: secret-looking file(s) in $refname:" >&2
echo "$bad" >&2
exit 1
fi
if [ "$refname" = "refs/heads/main" ]; then
for c in $(git rev-list "$oldrev..$newrev" 2>/dev/null || git rev-list "$newrev"); do
msg=$(git log -1 --format=%s "$c")
if ! echo "$msg" | grep -qE '^(feat|fix|chore|docs)(\(.+\))?: '; then
echo "REJECTED: commit $c has non-conventional message: $msg" >&2
exit 1
fi
done
fi
done
echo "pre-receive: all checks passed"git diff-tree --no-commit-id --name-only -r "$newrev" lists every file touched by that commit relative to its parent, which is enough to catch an accidentally-committed keys.secret without ever needing a working tree. Pushing one confirms the whole push is rejected, atomically:
$ git push origin main
remote: pre-receive: checking refs/heads/main
remote: REJECTED: secret-looking file(s) in refs/heads/main:
remote: keys.secret
! [remote rejected] main -> main (pre-receive hook declined)"Atomically" matters here specifically: this was a single-commit push, but a multi-commit push behaves the same way — pre-receive evaluates the whole set of ref updates in one pass, and a rejection on any of them means none of them land. There is no partial state where three good commits got through and a fourth, bad one got rejected on its own.
Fix the message convention next, without touching the secret:
$ git commit -m "made a quick change"
$ git push origin main
remote: pre-receive: checking refs/heads/main
remote: REJECTED: commit 4ea31bc... has non-conventional message: made a quick change
! [remote rejected] main -> main (pre-receive hook declined)And once both are fixed:
$ git commit --amend -m "fix: tidy app.js"
$ git push origin main
remote: pre-receive: checking refs/heads/main
remote: pre-receive: all checks passed
f7b6503..6fccb28 main -> mainThat is a real quality gate, enforced at the transport layer, with zero infrastructure beyond the bare repo you already had from Part 1.
Running Actual Tests, Not Just Pattern Matches
Pattern-matching filenames and commit messages is a start, but "poor man's CI" earns the CI part of its name when it can run the project's actual checks against the actual proposed tree. pre-receive has everything needed for that: $GIT_DIR gives it the object database, and --work-tree (the same flag from Part 1) can materialize any commit into a scratch directory on demand, run a real command against it, and use that command's exit code as the verdict.
#!/usr/bin/env bash
set -euo pipefail
zero="0000000000000000000000000000000000000000"
while read -r oldrev newrev refname; do
[ "$newrev" = "$zero" ] && continue
[ "$refname" = "refs/heads/main" ] || continue
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
git --work-tree="$tmp" --git-dir="$GIT_DIR" checkout -f "$newrev" -- . >/dev/null 2>&1
echo "pre-receive: running test suite against $newrev in $tmp"
if ! (cd "$tmp" && node --check app.js 2>&1); then
echo "REJECTED: app.js failed syntax check" >&2
exit 1
fi
echo "pre-receive: tests passed"
doneA clean push runs the check and passes:
$ git push origin main
remote: pre-receive: running test suite against 58235b5... in /var/folders/.../tmp.yDz53mUuBP
remote: pre-receive: tests passedA commit that actually breaks looks exactly like a broken build failing anywhere else, because that is precisely what it is:
$ git push origin main
remote: pre-receive: running test suite against 3326a92... in /var/folders/.../tmp.aRROudT0of
remote: /var/folders/.../tmp.aRROudT0of/app.js:1
remote: console.log('broken'
remote: ^^^^^^^^
remote:
remote: SyntaxError: missing ) after argument list
remote: at wrapSafe (node:internal/modules/cjs/loader:1666:18)
remote: REJECTED: app.js failed syntax check
! [remote rejected] main -> main (pre-receive hook declined)node --check is a stand-in here for whatever a real project needs: a linter, a test runner, a schema validator against a config file. The mechanism does not care what the check is, only that it exits non-zero on failure. The scratch directory is exactly as disposable as a CI job's workspace, created fresh per push and thrown away when the hook exits — the trap ... EXIT line guarantees that even on a rejection.
pre-receive vs. update: Same Idea, Different Granularity
Git actually offers two hooks that run before a push is accepted, and it is worth knowing both exist before Part 7 leans on the difference between them. pre-receive reads every ref update for the whole push at once, from standard input, and its single exit code decides the fate of the entire push. update runs once per ref, and receives its arguments positionally rather than on stdin:
#!/usr/bin/env bash
# args, not stdin: $1=refname $2=oldrev $3=newrev
refname="$1"; oldrev="$2"; newrev="$3"
echo "update: $refname wants to move from $oldrev to $newrev"Push two new branches in one go and update fires twice, once per ref, each invocation seeing only its own ref:
$ git push origin main scratch
remote: update: refs/heads/main wants to move from 0000...000 to 00203d7...
remote: update: refs/heads/scratch wants to move from 0000...000 to f2f8b1a...
* [new branch] main -> main
* [new branch] scratch -> scratchFor a check that is naturally per-ref — "is this specific branch allowed to be force-pushed" — update maps onto the problem more directly than looping over stdin lines in pre-receive does. The trade-off is that update does not receive everything pre-receive does: push options (the -o flags this series uses again in Part 7) are only ever exported to pre-receive and post-receive, never to update. A protected-branch check that needs to read a push option has to live in pre-receive, full stop, no matter how naturally it would otherwise fit as a per-ref update hook.
Sharing One Hook Across Many Repositories
A single bare repo with a hand-written pre-receive script is easy to reason about. A server hosting a dozen small repos, each needing the same syntax-check gate, is not — copying the same script into a dozen hooks/ directories means a dozen places to update it, and a dozen chances for one copy to drift from the rest. core.hooksPath points a repository at hooks living anywhere else on disk, entirely outside its own .git directory:
$ git -C repo-a.git config core.hooksPath /srv/shared-hooks
$ git -C repo-b.git config core.hooksPath /srv/shared-hooksOne executable file in /srv/shared-hooks/pre-receive, referenced by both repositories at once:
$ git push origin main # into repo-a.git
remote: shared pre-receive hook running for .
$ git push origin main # into repo-b.git
remote: shared pre-receive hook running for .Both pushes ran the exact same shared file, with $GIT_DIR still correctly set to each repository's own path (git resolves it to . because the hook runs with its working directory already inside the target repo, regardless of where the hook script itself actually lives on disk). Updating the check just once, in one place, updates it for every single repository pointed at that path — the same principle a shared CI pipeline template gives a growing set of projects, achieved here with a single git config line and no template-inheritance system at all.
Where This Genuinely Stops Being Enough
It would be dishonest to present this as a CI replacement without naming where it falls over, because the limits are structural, not incidental:
Every push waits for the hook to finish, synchronously. There is no async job queue, no parallelism across pushes unless you build it yourself, and a slow test suite means a slow git push for the person waiting on it. A five-minute integration suite makes every push take five minutes. pre-receive is well-suited to fast checks — seconds, not minutes — and poorly suited to anything a real CI runner would shard across workers.
The hook runs on the same machine as the git server, with whatever the git server's user account can access. There is no isolated runner, no ephemeral container, no separate credentials scope. A test suite that needs a database, a browser, or network access to a third-party API needs all of that provisioned on the box hosting your bare repos, which is a very different security posture from a disposable CI runner.
There is no dashboard, no historical run log, no re-run button. The only record of a pre-receive run is whatever it printed to the pusher's terminal at push time, unless you build logging into the hook yourself. For a solo project or a small trusted team, that is a fair trade for the simplicity. For anything with an audit requirement or a "why did this fail three weeks ago" question, it is not.
A hook cannot enforce anything about a push it never sees. Anyone with direct filesystem or SSH access to the box can bypass pre-receive entirely by writing straight into the object database and ref files, the same way this part's own tests deliberately corrupted a ref in Part 2 by editing a file instead of pushing, with no hook anywhere in that path to object. A real CI platform's protections usually live behind a permissions layer the developer cannot get underneath; a pre-receive hook's protections live entirely at the mercy of whoever has shell access to the same machine.
The right way to think about this part is not "hooks replace CI" but "hooks are the same idea CI is built on, running one layer lower, with fewer moving parts and correspondingly fewer guarantees." Knowing exactly where that trade sits is what makes it possible to use hooks deliberately instead of accidentally outgrowing them.
Takeaways
pre-receive rejects atomically, before any ref moves. A failing check means the push never happened, from the repository's point of view — no partial state, no commit sitting on the branch waiting to be reverted, and no cleanup step needed on the server afterward. That is a stronger guarantee than most CI setups give you, where a failing check runs after the merge has already landed.
--work-tree plus a real command turns a hook into an actual test runner, not just a pattern matcher. The scratch checkout pattern from Part 1 generalizes directly: materialize the proposed tree, run something against it, use the exit code.
pre-receive and update answer the same question at different granularities, and only one of them can read push options. Reach for update when a check is naturally per-ref; reach for pre-receive the moment that check needs a push option, since update never receives them regardless of how well it otherwise fits the problem.
The limits are synchronicity, isolation, and observability, not correctness. pre-receive gates correctly; it just gates on the same machine, in the same process, with no queue and no history, and with no protection against anyone who can reach the git server's filesystem directly. Knowing that boundary is what tells you when it is time to graduate to a real CI platform instead of stretching a hook past where it should go — a question this series returns to directly in Part 8.
Next: Part 4: Worktrees in Production: Blue-Green Deploys from One Clone. Turning the single checkout from Part 1 into two, so a bad deploy is a symlink flip away from being undone.
Beneath the Porcelain is an eight-part series by Ajitem Sahasrabuddhe on the git internals that quietly hold together small production systems. Every command shown was run against git 2.50.1 on macOS before being written up; output may differ slightly on other platforms or versions.