What does Git's pre-push hook receive on stdin, and what can it check before a push?
answer
- Two arguments name the destination
- The interesting part is not in argv
- Four fields per line, one line per ref
- All-zeroes object name has two meanings
- Non-zero aborts the entire push
basics
~20 sGit passes the remote's name and URL as arguments and streams one line per ref on stdin: local ref, local object name, remote ref, remote object name. The hook can inspect the exact commit range being pushed and abort the whole push by exiting non-zero.
solid answer
~40 s`pre-push` is the last client-side veto before anything leaves the machine. Git invokes it with two arguments — the name and the location of the destination remote (both the same string when you push to a URL directly) — and feeds it one line per ref update on **standard input**, in the form `<local-ref> <local-object-name> <remote-ref> <remote-object-name>`. That last field is the all-zeroes object name when the remote branch does not exist yet, and the *local* object name is all zeroes when you are deleting a remote ref. From those pairs the hook can compute exactly what is new — `git rev-list <remote-oid>..<local-oid>` — and check it: no fixup commits, no WIP subjects, no pushes straight to `main`. Exiting non-zero makes `git push` abort without pushing anything; `git push --no-verify` skips the hook entirely.
code
bash · 24 lines#!/bin/sh
# .git/hooks/pre-push — argv: <remote-name> <remote-url>
remote="$1"
z40=0000000000000000000000000000000000000000
while read -r local_ref local_oid remote_ref remote_oid; do
[ "$local_oid" = "$z40" ] && continue # deleting a remote ref
if [ "$remote_oid" = "$z40" ]; then # new branch on the remote
commits=$(git rev-list "$local_oid" --not --remotes="$remote")
else
commits=$(git rev-list "$remote_oid..$local_oid")
fi
for c in $commits; do
case "$(git log -1 --format=%s "$c")" in
WIP*|fixup!*|squash!*)
echo "pre-push: $c is not ready to share ($remote_ref)" >&2
exit 1
;;
esac
done
done
exit 0go deeper
Recall that pre-push runs on your own machine before anything is sent, and that a non-zero exit cancels the push. Knowing it exists is usually enough at this level.
Explain the two arguments and the four-field stdin lines, and be able to derive the pushed commit range from them. This is the tier where the protocol details are expected.
Show that you handle the edge cases — new branch, ref deletion, multiple refs in one push — and that you choose pre-push over pre-commit for checks whose cost only pays off at sharing time.
Own the tradeoff between local seatbelts and binding checks: pre-push is bypassable, so decide what it buys in mistake-prevention versus what must be re-verified where pushes land.
## Where pre-push sits `pre-push` runs on the *pushing* machine, after Git has negotiated with the remote enough to know which refs would move, but before any objects are transferred. That timing is what makes it useful: unlike `pre-commit`, which sees one commit at a time, `pre-push` sees the whole set of commits about to become visible to other people, and it knows which remote they are going to. ## The two arguments Git calls the hook with the name and the location of the destination remote. With `git push origin main` those are `origin` and the configured URL. When a named remote is not used — pushing to a raw URL — Git passes the same value for both. A hook can therefore apply stricter rules to the shared remote than to a personal one. ## The stdin protocol The interesting information arrives on standard input, one line per ref being updated: ``` <local-ref> SP <local-object-name> SP <remote-ref> SP <remote-object-name> LF ``` For example, pushing a local `main` to `refs/heads/main` on the remote gives you the local tip and the remote's current tip. Two special cases matter and are the usual source of buggy hooks: - **The remote ref does not exist yet** (a brand-new branch): `<remote-object-name>` is the all-zeroes object name. There is no `A..B` range to compute, so a naive `rev-list old..new` silently examines nothing. The correct move is something like `git rev-list <local-oid> --not --remotes=<remote>`, which lists commits not already present on that remote. - **You are deleting a remote ref** (`git push origin --delete feature`): `<local-object-name>` is all zeroes. There is nothing to inspect; skip the line. A push can update several refs in one invocation, so the hook must loop over all input lines rather than reading one. ## What it is good for The checks that genuinely belong here are the ones that only make sense in aggregate or only matter once work becomes shared: - refusing to push commits whose subject starts with `WIP`, `fixup!` or `squash!`, which are meant to be rebased away first; - refusing a push whose target ref is a protected-looking branch name, catching the accidental `git push origin main`; - running a fast build or the tests relevant to the changed files — expensive per-commit, tolerable per-push; - scanning the newly added commits for large blobs or credential-shaped strings before they escape the laptop. ## Exit status Zero lets the push proceed. Non-zero aborts it, and Git pushes nothing at all — not even the refs whose lines the hook approved, because the veto is on the command, not on an individual ref. Whatever the hook prints goes to the developer's terminal, so print the offending commit and the rule it broke. ## Bypass and consequences `git push --no-verify` skips `pre-push`, and removing or unsetting the executable bit on the hook file has the same effect. So `pre-push` is a *seatbelt*, not a *lock*: it catches the honest mistake at the moment it would have become everyone's problem, but it stops nobody who intends to skip it. Anything that must be guaranteed has to be re-checked where the push is received. ## Cost and habit Because a push is a deliberate, less frequent act than a commit, `pre-push` tolerates a few seconds of work in a way `pre-commit` does not. Even so, a hook that runs a five-minute suite on every push trains people to type `--no-verify`, at which point the seatbelt is permanently unbuckled. Keep it to the checks whose value clearly exceeds the wait.
- Your pre-push hook works for existing branches but passes everything on a brand-new branch. Why?Because for a new remote branch the remote object name is the all-zeroes value, so `git rev-list <zeroes>..<local>` is not a meaningful range and yields nothing useful. Detect the zero object name and switch to listing commits not already on that remote, for example `git rev-list <local-oid> --not --remotes=<remote>`.
- Can pre-push reject just one of several refs in a push?No. The hook's exit status applies to the whole `git push` invocation, so a non-zero exit aborts everything, including refs it read and approved. Per-ref rejection is only possible on the receiving side, where a hook is invoked once per ref.
- Why is pre-push often a better place for a test run than pre-commit?Commits happen many times an hour and are frequently intermediate, so an expensive pre-commit hook penalises exactly the incremental committing you want to encourage. A push is deliberate and less frequent, and it is the moment work becomes visible to others — so a few seconds of verification buys real protection without wrecking the inner loop.
saying these in an interview costs you the question
- Expecting the ref list in argv instead of stdin
- Ignoring the all-zeroes object name cases
- Reading only the first stdin line
- Believing pre-push can reject a single ref
- Thinking pre-push runs on the receiving repository