In Git, how do the pre-receive and update hooks differ on the repository receiving a push?
answer
- Both run on the receiving side, before refs move
- One is per push, the other per ref
- Arguments versus lines on stdin
- One rejection blocks everything, one blocks one ref
- Only one of them can print to stdout usefully
basics
~20 spre-receive runs once per push and reads every proposed ref update on stdin, so a non-zero exit rejects the whole push. update runs once per ref with the ref name and old and new object names as arguments, and rejecting there blocks only that one ref.
solid answer
~50 sBoth are invoked by `git receive-pack` on the receiving repository when a push arrives, and both run before any ref is changed — but at different granularity. **`pre-receive`** executes **once for the whole push**. It takes no arguments and reads lines of `<old-oid> <new-oid> <ref-name>` on standard input, one per ref. Because it sees the complete set, it is where cross-ref rules live, and Git documents that if it exits non-zero **none of the refs are updated**. **`update`** executes **once per ref**, receiving the ref name, the old object name and the new object name as its three arguments; a non-zero exit prevents `receive-pack` from updating *that* ref while the others may still succeed. One practical difference in plumbing: output from `pre-receive` is forwarded to the pushing client, whereas the `update` hook's standard output goes to `/dev/null`, so it must write messages to stderr to be seen.
code
bash · 7 lines#!/bin/sh
# hooks/pre-receive — no arguments; one stdin line per ref
while read -r old new ref; do
echo "proposed: $ref $old -> $new"
done
# a non-zero exit here rejects the ENTIRE push
exit 0go deeper
Know that these hooks run on the repository receiving the push, not on your laptop, and that they can refuse it. The per-push versus per-ref split is the one distinction to recall.
Explain the interfaces precisely — pre-receive takes stdin lines, update takes three arguments — and what each rejection actually blocks. This is the expected tier for the mechanism.
Show that you have operated one: handle creation and deletion object names, report to stderr where required, walk only new commits, and keep the receive path fast for everyone pushing.
Own the policy question of what belongs in the push path at all — latency for every contributor versus the value of blocking early, and which rules are better expressed as declarative repository rules.
## Where these hooks run When you push, the receiving repository runs `git receive-pack`. That program invokes a sequence of hooks from the receiving repository's own hook directory — in a bare repository that is `hooks/` directly in the repository directory, since there is no `.git` subdirectory. Nothing here runs on the developer's machine, and nothing the developer types can turn it off: `--no-verify` is a client-side flag that affects client-side hooks only. ## pre-receive: once, with the whole picture `pre-receive` takes no arguments. For every ref the push wants to change, it receives one line on standard input: ``` <old-oid> SP <new-oid> SP <ref-name> LF ``` `<old-oid>` is the ref's current value on the receiving side and `<new-oid>` is the proposed value. Creating a ref shows an all-zeroes `<old-oid>`; deleting one shows an all-zeroes `<new-oid>`. The crucial property is that it runs **once for the receive operation**, so the hook sees the push as a unit. Git specifies that a non-zero exit means none of the refs are updated — an all-or-nothing veto. That makes `pre-receive` the right place for any rule whose subject is the push rather than an individual ref: "a branch and its tag must arrive together", "reject this push if any commit anywhere in it fails the message format", "reject if the total size is absurd". ## update: once per ref, finer-grained `update` runs once for each ref, with three arguments: the ref name, the old object name and the new object name. Exiting zero allows that ref to be updated; exiting non-zero prevents `receive-pack` from updating that ref. Other refs in the same push can still go through — unless the client asked for an atomic push, in which case any rejection sinks the whole transaction. Because it is per-ref, `update` is the natural home for rules keyed on the ref itself: refuse deletion of `refs/heads/main`, refuse a non-fast-forward on release branches, refuse creation of refs outside an agreed namespace. Detecting a non-fast-forward is a small computation — the update is a fast-forward when the old object name is an ancestor of the new one, which `git merge-base --is-ancestor <old> <new>` answers directly. ## Order and the third hook The order is `pre-receive` for the whole push, then `update` for each ref, then the refs are actually updated, then `post-receive` once, after the fact. Git's documentation is explicit that even when `pre-receive` exits zero, individual refs can still be blocked by `update`. ## Talking back to the pusher A detail that trips people up in practice: both standard output and standard error of `pre-receive` are relayed to the pushing client, so `echo` is enough to explain a rejection. The `update` hook's standard output is sent to `/dev/null`; to report anything you must redirect to stderr. A rejection with no message produces a support ticket, so this is worth getting right. ## Objects are already there By the time these hooks run, the pushed objects have been received and are readable, which is what lets a hook call `git rev-list` or `git log` on the new commits. In recent Git those objects sit in a quarantine area and are only migrated into the repository if the push is accepted, so a rejected push does not leave loose objects behind — the environment variable `GIT_QUARANTINE_PATH` points at it. ## Cost Every push waits for these hooks, and they run on shared infrastructure, so the cost is multiplied by everyone pushing. Walk only the newly introduced commits (`git rev-list <old>..<new>`, or `git rev-list <new> --not --all` when a branch is being created) rather than whole histories, and keep heavy verification out of the receive path.
- How does an update hook tell that a push is a non-fast-forward?It compares the two object names it was given: the update is a fast-forward exactly when the old value is an ancestor of the new one. `git merge-base --is-ancestor "$old" "$new"` answers that with its exit status. A creation shows an all-zeroes old value and a deletion an all-zeroes new value, so both need handling before the ancestry test.
- Why does a rejection message from an update hook sometimes never reach the person pushing?Because the `update` hook's standard output is sent to `/dev/null`; only stderr is relayed. A hook that uses plain `echo` rejects the ref silently and the pusher sees a bare failure. Redirect messages with `>&2`. `pre-receive` and `post-receive` are different — both their streams are forwarded to the client.
- If pre-receive exits zero, can a ref still be refused?Yes. Git runs `pre-receive` once as a whole-push gate, and then the `update` hook for each ref; a non-zero exit from `update` blocks that ref even though the push as a whole was accepted. The result is a partially applied push unless the client used an atomic push, which turns any rejection into a total failure.
saying these in an interview costs you the question
- Thinking pre-receive runs once per ref
- Expecting the ref list in update's stdin instead of arguments
- Believing --no-verify can skip receiving-side hooks
- Using echo in an update hook and wondering why nothing appears
- Assuming a rejected ref rolls back the other refs by default