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 as origin/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.

The four fields of each pre-push lineEach line names the local ref and its object ID, and the remote ref and the object ID the remote currently has for it. An all-zero local ID means the ref is being deleted; an all-zero remote ID means it is new on the remote.local refrefs/heads/featurelocal oidwhat is sentzeros = deleteremote refdestinationremote oidwhat remote haszeros = new refthe arguments name the remote; standard input names everything else

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
Which commits a push sendsIf the local object ID is all zeros, the push deletes a ref and sends nothing. If the remote object ID is all zeros, the ref is new and the commits are those not reachable from any remote-tracking ref. Otherwise the commits are those between the remote's current value and the local value.What does this pre-push line describe?local oid = zerosDeletionnothing to checkremote oid = zerosNew refrev-list --not --remotesboth setUpdateremote..localcomputing the zero ID from hash-object keeps the hook correct on SHA-256 repositories

--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
One computation, several checksGit writes the ref lines to the hook's standard input. The hook reads them once, computes the commit list with the shared helper, and runs each check β€” messages, secrets, file sizes β€” over that list, stopping the push if any check fails.git pushpre-push hookpushed-commits.shchecksref lines on stdincompute commitscommit listmessages, secrets, sizespass or rejectreading stdin once matters β€” it can only be read once

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.