Finding the new commits in a pre-push hook Jump to heading
Almost every pre-push check needs the same answer first: which commits is this push about to send? Message checks, secret scans, large-file checks and signature checks all operate on that set. Hooks commonly get it wrong in the same few ways. They check HEAD instead of what is being pushed, so pushing a different branch checks the wrong commits. They use origin/main..HEAD, which is wrong for any branch not based on main. They mishandle a new branch, where the remote side is all zeros, and check either nothing or the entire history. This page builds the one function every pre-push hook should share, covering each case Git can hand it, within pre-push validation rules.
When to use this approach Jump to heading
- You are writing a pre-push hook that inspects commits or their contents.
- An existing hook checks
HEAD, or a fixed range such asorigin/main..HEAD. - Hooks behave strangely when pushing a new branch, a tag or with
--force. - You want one tested implementation for several hooks, such as blocking secrets with a pre-push scan and rejecting large files before push.
Step 1 β Read what Git passes in Jump to heading
Git calls pre-push with two arguments β the remote name and URL β and writes one line per ref being pushed to standard input: local ref, local object ID, remote ref, remote object ID.
refs/heads/feature/export 8b3e4f2β¦ refs/heads/feature/export 1a7c3e0β¦
refs/tags/v2.4.1 c40e8a1β¦ refs/tags/v2.4.1 0000000β¦ A push can update several refs at once β git push --all, --follow-tags, or an explicit list β so the hook must loop over every line, not just read one.
Step 2 β Handle each case: update, new branch, deletion Jump to heading
The commits being pushed are those reachable from the local object ID that the remote does not already have. For an ordinary update, that is remote..local. For a new ref the remote has nothing for, compare against everything the remote is already known to have. For a deletion, there is nothing to check.
#!/bin/sh
# scripts/hooks/pushed-commits.sh β print the commits a push will send, one per line
# usage (in pre-push): sh scripts/hooks/pushed-commits.sh "$1" < stdin
set -eu
remote=$1
zero=$(git hash-object --stdin </dev/null | tr '0-9a-f' '0')
while read -r local_ref local_oid remote_ref remote_oid; do
[ "$local_oid" = "$zero" ] && continue # deletion
if [ "$remote_oid" = "$zero" ]; then
git rev-list "$local_oid" --not --remotes="$remote" # new ref
else
git rev-list "$remote_oid..$local_oid" # update (incl. force)
fi
done | sort -u --not --remotes="$remote" excludes every commit the hook knows the remote already has, through remote-tracking refs, so a new branch created from main reports only its own commits rather than all of history.
Step 3 β Handle force-pushes correctly Jump to heading
For a force-push, remote_oid may not be an ancestor of local_oid. remote..local still gives the right answer: commits reachable from the new tip that were not on the remote branch. Commits being discarded by the force-push are not part of the push and should not be checked.
# Demonstrate: rewrite the last commit and force-push β only the new commit is reported
git commit --amend --no-edit -m "fix(pay): round totals (amended)"
printf 'refs/heads/feature %s refs/heads/feature %s\n' "$(git rev-parse HEAD)" "$(git rev-parse '@{u}')" |
sh scripts/hooks/pushed-commits.sh origin If the remote object is not present locally β someone else force-pushed and you have not fetched β git rev-list fails on an unknown object. Fall back to the new-ref logic in that case.
if ! git cat-file -e "$remote_oid^{commit}" 2>/dev/null; then
git rev-list "$local_oid" --not --remotes="$remote" # unknown remote state
else
git rev-list "$remote_oid..$local_oid"
fi Step 4 β Use the helper in actual checks Jump to heading
With the commit list computed once, each check becomes a loop over it. The hook reads standard input once and passes it on.
# .husky/pre-push (or .git/hooks/pre-push)
input=$(cat)
commits=$(printf '%s\n' "$input" | sh scripts/hooks/pushed-commits.sh "$1")
[ -z "$commits" ] && exit 0
for c in $commits; do
git log -1 --format=%s "$c" | grep -qE '^(fixup|squash)! ' && {
echo "refusing to push unsquashed $(git log -1 --format='%h %s' "$c")"; exit 1; }
done Step 5 β Test it with synthetic input Jump to heading
Because the hookβs input is plain text, every case can be tested by feeding it lines, without any remote.
zero=0000000000000000000000000000000000000000
git switch -q -c t/new main && git commit -q --allow-empty -m "new branch commit"
printf 'refs/heads/t/new %s refs/heads/t/new %s\n' "$(git rev-parse HEAD)" "$zero" |
sh scripts/hooks/pushed-commits.sh origin | wc -l # expect 1
printf 'refs/heads/gone %s refs/heads/gone %s\n' "$zero" "$(git rev-parse main)" |
sh scripts/hooks/pushed-commits.sh origin | wc -l # expect 0 (deletion) The broader hook-testing approach is in testing Git hooks before sharing them.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not just check @{upstream}..HEAD? Jump to heading
Because the push may not be of HEAD or of the upstream branch β git push origin other-branch or --all push different refs. The standard input is the only reliable statement of what is being pushed.
Does this include tags? Jump to heading
Tag lines appear on standard input like branch lines. For an annotated tag, the local object ID is the tag object; git rev-list follows it to the commit. Pushing a tag on an already-pushed commit reports no new commits, which is correct.
Is the result the same as what a server-side pre-receive sees? Jump to heading
Close, but not identical: the client knows only its remote-tracking refs, which may be stale. Server-side checks compute against the serverβs real refs, as in verifying commit signatures in a pre-receive hook, which is why server enforcement remains necessary.
Related Jump to heading
- Pre-Push Validation Rules β the parent topic.
- Checking Commit Messages Before Push β a check built on this helper.
- Blocking Direct Pushes to Protected Branches Locally β a check that uses the remote ref field.
- Commit-msg and pre-push Stages in pre-commit β the frameworkβs built-in handling of the same input.