Learning Objectives
By the end of this lesson you will be able to:
- Write a
pre-pushhook that runs a test suite and blocks a failing push - Read the branch and commit information Git passes to the hook on stdin
- Block direct pushes to protected branches
- Judge how much time a
pre-pushhook may reasonably take
Why pre-push Is the Better Home for Tests
pre-commit fires many times an hour. pre-push fires a handful of times a day, and only at the moment work is about to become visible to other people.
That difference changes the budget completely. A 30-second test suite in pre-commit is intolerable; the same suite in pre-push is a fair trade against a broken build that blocks three colleagues and takes fifteen minutes to discover.
It is also the last point at which a mistake is still entirely yours. After the push it is on the remote, in CI, in a colleague's pull.
A Test-Running Hook
.git/hooks/pre-push:
#!/usr/bin/env bash
set -euo pipefail
echo "๐งช Running tests before push..."
if ! npm test --silent; then
echo ""
echo "โ Tests failed โ push aborted."
echo " Fix them, or push anyway with: git push --no-verify"
exit 1
fi
echo "โ
Tests passed."
chmod +x .git/hooks/pre-push
Note the message naming --no-verify explicitly. The escape hatch exists whether or not you mention it, and telling people about it is what stops the hook feeling like an obstacle rather than a service.
What Git Passes the Hook
pre-push gets more context than the other hooks. Two arguments โ the remote's name and URL โ and one line per ref on stdin:
<local ref> <local sha> <remote ref> <remote sha>
#!/usr/bin/env bash
REMOTE="$1"
URL="$2"
while read -r local_ref local_sha remote_ref remote_sha; do
echo "Pushing $local_ref to $remote_ref on $REMOTE"
done
Two details worth knowing:
- A
local_shaof all zeros means a branch deletion. - A
remote_shaof all zeros means a new branch the remote does not have yet.
Blocking a Push to main
The single most valuable use of pre-push, and it is short:
#!/usr/bin/env bash
PROTECTED="main master develop release/.*"
while read -r local_ref local_sha remote_ref remote_sha; do
branch="${remote_ref#refs/heads/}"
for pattern in $PROTECTED; do
if [[ "$branch" =~ ^$pattern$ ]]; then
echo "โ Direct push to '$branch' is not allowed. Open a Pull Request."
exit 1
fi
done
done
Server-side branch protection is the real defence and every platform offers it. This hook is the friendlier version: it stops you before the push instead of rejecting it afterwards, and it works on a repository whose protection rules nobody has configured.
Only Testing What You Are Pushing
Running everything is often unnecessary. This runs only the tests affected by the commits being pushed:
#!/usr/bin/env bash
while read -r local_ref local_sha remote_ref remote_sha; do
# New branch: compare against main instead of an empty remote
if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
RANGE="main..$local_sha"
else
RANGE="$remote_sha..$local_sha"
fi
CHANGED=$(git diff --name-only "$RANGE" | grep -E '\.(ts|js)$' || true)
[ -z "$CHANGED" ] && continue
echo "๐งช Testing changes in $RANGE"
npm test --silent -- --findRelatedTests $CHANGED || exit 1
done
The zero-sha check is what makes this work on the first push of a new branch, where there is no remote commit to diff against.
How Long Is Too Long?
| Duration | Verdict |
|---|---|
| Under 30 seconds | Comfortable โ nobody will complain |
| 30โ60 seconds | Acceptable if it catches real problems |
| 1โ3 minutes | Borderline; expect regular --no-verify |
| Over 3 minutes | Too slow. Run a subset here, the rest in CI |
The rule is the same as for pre-commit, scaled up: a hook people skip protects nothing. If your suite takes ten minutes, run the fast unit tests in pre-push and leave integration and end-to-end to CI. Half a check that runs beats a full check that gets bypassed.
Where the Line Is
Be honest about what this hook is and is not.
It is: a fast feedback loop that catches the obvious break before it costs anyone else time, and a way for QA to raise the floor on a team without needing pipeline permissions.
It is not: a guarantee. It is local, uncommitted, skippable, and it runs on one machine with one set of dependencies and one operating system. CI is the gate. The hook just means CI rarely has to say no.
Pro Tip: A
pre-pushhook that blocks direct pushes tomainis the easiest thing in this trail to propose to a team and the hardest to argue against. It costs nothing, it breaks nothing, and it prevents the one mistake that genuinely ruins an afternoon for everybody.
Key Takeaways
pre-pushruns a few times a day, so it can afford real tests- Git passes the remote name and URL as arguments, and one ref line per push on stdin
- An all-zero sha means a branch deletion (local) or a brand-new branch (remote)
- Blocking pushes to
mainis short, effective, and complements server-side protection --findRelatedTestsor a similar filter keeps the hook proportional to the change- Keep it under a minute; over three minutes it will be bypassed
- Always name
--no-verifyin the failure message