Running a fast test subset in a pre-push hook Jump to heading
A pre-push hook that runs the whole test suite gets removed within a month. Not because the tests are unwanted, but because pushing is something people do while their attention is already moving elsewhere, and a two-minute pause converts into --no-verify becoming muscle memory. The version that survives runs a selected subset against a hard time budget and lets the pipeline own everything else. This recipe builds that, applying the scoping principles from Pre-Push Validation Rules.
When to use this approach Jump to heading
- Broken builds regularly reach the shared branch and are discovered by someone else.
- Your test suite has a fast subset β unit tests β that is genuinely quick to run.
- Your test runner can select tests related to a set of changed files, which most modern runners can.
- Developers push a handful of times a day, so a ten-second check is affordable.
- If your entire suite runs in five seconds, skip the selection logic and run all of it. The complexity below only pays off when the full suite is slow.
Step 1 β Set a time budget before writing anything Jump to heading
Ten seconds is a good default. Write it down, because every later decision β how many tests, which selection strategy, whether to parallelise β is answered by βdoes it fit in the budget?β.
Step 2 β Read the range being pushed from stdin Jump to heading
A pre-push hook receives one line per ref on standard input: local ref, local SHA, remote ref, remote SHA. That is how you learn what is actually being pushed rather than guessing from HEAD.
#!/usr/bin/env sh
# .husky/pre-push β run a selected test subset within a time budget.
set -eu
BUDGET=10 # seconds
zero='0000000000000000000000000000000000000000'
range=''
while read -r local_ref local_sha remote_ref remote_sha; do
[ "$local_sha" = "$zero" ] && continue # a deletion: nothing to test
case "$remote_ref" in
refs/heads/main|refs/heads/release/*) ;; # protected: always check
*wip*|*scratch*) exit 0 ;; # explicitly throwaway
*) ;; # ordinary branches: check
esac
if [ "$remote_sha" = "$zero" ]; then
range="$local_sha --not --remotes" # new branch: everything new
else
range="$remote_sha..$local_sha"
fi
done
[ -n "$range" ] || exit 0 What changed: the hook now knows the exact commit range being published, including the awkward case of a brand-new branch where no remote SHA exists yet.
# Verify the range logic by hand
git rev-list origin/main..HEAD --count # what an ordinary push would cover Step 3 β Select tests related to the changed files Jump to heading
# Files changed by the commits being pushed
files=$(git diff --name-only $range -- '*.ts' '*.tsx' '*.js' | sort -u)
[ -n "$files" ] || { echo "pre-push: no source changes, skipping tests"; exit 0; }
# Let the runner resolve which tests cover those files
# shellcheck disable=SC2086
npx jest --findRelatedTests $files --passWithNoTests --silent What changed: instead of the whole suite, the hook runs the tests the runner believes are related to the changed files β typically a handful.
# See the selection without running anything
npx jest --findRelatedTests src/payments/refund.ts --listTests Step 4 β Enforce the budget with a hard timeout Jump to heading
Selection usually keeps the run short. A timeout guarantees it, including on the day someone changes a file that half the codebase imports.
# Fail fast rather than holding the terminal open
if command -v timeout >/dev/null 2>&1; then
timeout "${BUDGET}s" npx jest --findRelatedTests $files --passWithNoTests --silent
rc=$?
else
npx jest --findRelatedTests $files --passWithNoTests --silent
rc=$?
fi
if [ "$rc" -eq 124 ]; then
echo "" >&2
echo " Selected tests exceeded the ${BUDGET}s budget and were stopped." >&2
echo " Pushing anyway β CI will run the full suite." >&2
echo "" >&2
exit 0 # a slow selection is not a failing selection
fi
exit "$rc" What changed: an unusually broad change no longer blocks the push. The hook gives up on its budget and defers to CI, which is the correct behaviour β the hook is an optimisation, not a gate.
# Verify the timeout path fires
BUDGET=1 sh .husky/pre-push < /dev/null || echo "timeout path exercised" SAFETY WARNING β do not let the hook run tests that touch shared state: a real database, a staging API, a message queue. A
pre-pushhook runs on developer machines with developer credentials, and a test suite that writes to a shared environment will eventually do so from three machines at once. Restrict the selected subset to tests that are hermetic, and keep integration tests in the pipeline.
Step 5 β Route everything slower to the pipeline Jump to heading
The rightmost column is what makes the leftmost two safe to keep small. Because CI runs everything and gates the merge, the hook does not have to be complete β it only has to catch the cheap mistakes early enough to save a round trip.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why pre-push rather than pre-commit for tests? Jump to heading
Commits happen many times an hour and pushes a few times a day, so a ten-second check costs a hundred times more at commit time. Pre-push is also the last moment before the work becomes visible to anyone else, which is exactly where a broken build is still free to fix. Keep pre-commit for sub-second checks like formatting.
What if the related-test selection misses a regression? Jump to heading
It will, sometimes β that is an accepted property, not a bug. The hook is a fast filter that catches the obvious break before anyone else sees it; CI runs the full suite and remains the authority. A hook that tried to be exhaustive would be slow enough that people would bypass it, catching nothing at all.
How do I stop the hook running on a branch nobody cares about? Jump to heading
Read the ref being pushed from stdin and skip anything outside the branches you protect, or skip branches whose names mark them as work in progress. Pushing a scratch branch to share a half-finished idea should not require a green test run, and forcing it to teaches people to reach for --no-verify by habit.
Related Jump to heading
- Pre-Push Validation Rules β the parent guide: what belongs in the last hook before the network call.
- Preventing Broken Builds with Pre-Push Hooks β the build-verification counterpart to test selection.
- Disabling Git Hooks Temporarily Without Breaking the Team β the escape hatch for the day the budget is not enough.