By admin , 29 April 2026

The case for pre-push tests

A broken push wastes everyone's time - CI runs, fails, the team gets a notification, the author scrambles to fix. A pre-push hook running the test suite catches the issue before the push leaves your machine.

The simplest pre-push hook

# .git/hooks/pre-push
#!/usr/bin/env bash
set -e
npm test --silent
chmod +x .git/hooks/pre-push

Now git push runs npm test; failure aborts the push.

Reading what is being pushed

Pre-push hooks receive the local and remote refs on stdin. This lets you tailor checks:

#!/usr/bin/env bash
while read local_ref local_sha remote_ref remote_sha; do
  # Skip deletions
  if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
    continue
  fi
  # Run tests against the new commit
  git checkout "$local_sha" -- .
  npm test
done

Selective test runs

Run only tests affected by changed files:

changed=$(git diff --name-only origin/main...HEAD)
affected=$(./scripts/find-affected-tests.sh "$changed")
npx jest $affected

Tools like Nx (nx affected:test) and Bazel do this natively.

Branch-aware enforcement

protected_branches=("main" "release")
branch=$(git symbolic-ref --short HEAD)
if [[ " ${protected_branches[*]} " =~ " $branch " ]]; then
  echo "Refusing to push directly to $branch" >&2
  exit 1
fi

Hooks complement, not replace, branch protection on the server. Server-side rules are authoritative.

Using Husky for shared pre-push hooks

# .husky/pre-push
npm test --silent
npm run typecheck --silent

Committed hooks travel with the repo; npm install activates them via Husky's bootstrap.

Performance tips

  • Run tests in parallel: jest --maxWorkers=50%.
  • Skip tests that did not change: jest --onlyChanged.
  • Cache transformations: most modern test runners have caches.
  • Reserve heavy integration tests for CI.

Bypassing

git push --no-verify

Use sparingly. Document policy: bypass only for emergency hotfixes; CI must still pass.

Server-side hooks

Client hooks can be skipped. Server hooks cannot. The server can run a pre-receive hook that rejects pushes failing some criterion - branch name pattern, commit signature, file size, secret scan. Combine client hooks (UX) with server hooks (enforcement) for defence in depth.

# /srv/git/repo.git/hooks/pre-receive
#!/usr/bin/env bash
while read old new ref; do
  if ! gitleaks detect --redact --no-banner --staged 2>/dev/null; then
    echo "Push rejected: secrets detected" >&2
    exit 1
  fi
done

The right boundary

What belongs pre-commit, pre-push, or in CI?

  • Pre-commit: instant checks (lint, format, typecheck on changed files).
  • Pre-push: unit tests, full lint.
  • CI: integration tests, smoke tests, deploy preview.

Hooks should accelerate, not replace, CI. Use them to shorten the feedback loop, not to skip server-side gating.