Great work!

XP to next level

BugEater
EN

pre-push: Tests Before Anything Leaves Your Machine

Learning Objectives

By the end of this lesson you will be able to:

  • Write a pre-push hook 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-push hook 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_sha of all zeros means a branch deletion.
  • A remote_sha of 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-push hook that blocks direct pushes to main is 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-push runs 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 main is short, effective, and complements server-side protection
  • --findRelatedTests or 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-verify in the failure message

Quiz

Why is pre-push a better home for a test suite than pre-commit?

How does Git tell a pre-push hook which refs are being pushed?

In a pre-push hook, what does an all-zero remote_sha mean?

Your full suite takes ten minutes. What belongs in pre-push?