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

How long a push hook can take before it gets bypassedUnder five seconds a hook is invisible. Between five and fifteen seconds it is noticed but tolerated. Beyond thirty seconds developers begin using no-verify routinely, and beyond a minute the hook is effectively disabled regardless of what it checks.0 s5 s15 s30 s60 s+invisiblenobody noticesnoticed, acceptedaim heregrumbling startsbypasses appeareffectively disabled--no-verify by habitpick a number in the green-to-outline range, then design the check to fit it β€” not the other way round

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
# 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
From changed files to the tests that cover themTwo changed source files are traced through the import graph to the modules that depend on them, and from there to the test files that exercise those modules. The result is a small selected set rather than the whole suite.changedpayments/refund.tspayments/fees.tsimporterscheckout/session.tsbilling/invoice.tsselected testsrefund.test.tsinvoice.test.tssession.test.ts3 test files instead of 400 β€” and the full suite still runs in CI, where waiting costs nobody's attention

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-push hook 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

Three layers, three time budgetsPre-commit runs sub-second checks such as formatting because commits are frequent. Pre-push runs a selected test subset within about ten seconds. CI runs the full suite, integration tests and security scans, where minutes are acceptable because nobody is waiting at a terminal.pre-commit< 1 sformattingstaged-file lintingcommit-message shapemany times an hour β€”cost is multipliedpre-pushβ‰ˆ 10 sselected unit testssecret scan of the rangebranch-name policya few times a day β€”last free momentCIminutesthe full test suiteintegration and e2edependency and security scansnobody is waiting β€”and it is the authority

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.

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.