How pre-receive Works Mechanically
When you run git push, the server receives the packfile of new objects (commits, trees, blobs), writes them to a temporary area, and then runs pre-receive before updating any refs. This is important: the objects exist on the server at this point, but no branch pointer has moved yet. If pre-receive rejects the push, those objects are discarded.
The hook is an executable file located at $GIT_DIR/hooks/pre-receive. It can be written in any language — Bash, Python, Ruby — as long as the file is executable and the interpreter is available on the server.
The Input Format
The hook reads from Standard Input (stdin). For every reference (branch or tag) being pushed, it receives one line in this exact format:
<old-sha> <new-sha> <ref-name>
| Field | Meaning |
|---|---|
old-sha |
The SHA the ref pointed to before this push. All zeros (000...000) if this is a new branch being created. |
new-sha |
The SHA the ref will point to if the push is accepted. All zeros if this is a deletion. |
ref-name |
The full ref path, e.g., refs/heads/main or refs/tags/v1.0. |
A push updating two branches at once produces one such line per ref on stdin — for an existing branch like main, both SHAs are real; for a brand-new branch, the old SHA is all zeros, which is exactly how the hook tells "this branch already existed" apart from "this branch is being created for the first time."
The Exit Contract
- Exit 0: Push accepted. Git proceeds to update the refs.
- Exit non-zero (typically 1): Push rejected. Any output written to stdout or stderr by the hook is forwarded to the developer's terminal. This is how you communicate why the push was rejected.
Inspecting Commits in the Hook
The most common task in pre-receive is iterating over every new commit in the push and inspecting it. The git rev-list command is the primary tool here, listing commits reachable from the new SHA but not the old one. New branches need a different form of the same idea: since the old SHA is all zeros, listing "everything reachable from new but not old" would return the entire history of the repository, not just what this push actually introduces — so for a new branch, the query instead excludes anything already reachable from any existing branch, isolating just the genuinely new commits.
A common interview question is: "How would you prevent large binary files from being committed to your Git repository?" The answer has two layers: client-side (a pre-commit hook that checks file sizes) for fast feedback, and server-side (pre-receive that checks git diff-tree --diff-filter=A for new files exceeding a size threshold) for actual enforcement. Mentioning Git LFS as the correct long-term solution for large files demonstrates architectural awareness beyond just the hook mechanism.