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.