Preventing broken builds with pre-push hooks Jump to heading
Pushing unvalidated commits consumes CI/CD compute and inflates the feedback loop from commit to red build. Common culprits — lint violations slipping past a rushed git add, TypeScript errors the IDE suppressed, a lockfile diverged after a rebase — are all cheaply detectable locally. The challenge is building a hook that catches them deterministically without adding enough friction that developers reach for git push --no-verify. This page is a focused recipe within Pre-Push Validation Rules, which covers the full architecture of local gating before remote transmission.
When to use this approach Jump to heading
Apply this recipe when:
- Your CI pipeline regularly fails on lint, type errors, or missing lockfile updates — issues that need no remote environment to detect.
- The team’s average “push → red build → fix → re-push” cycle exceeds 10 minutes, destroying flow state.
- You already use Local Hook Configuration with Husky for commit-time checks but need a second gate that evaluates the full outbound commit range, not just staged files.
- You want to avoid duplicating slow integration tests locally — those belong in CI, coordinated via CI/CD Pipeline Trigger Mapping.
- Your repository has grown large enough that running the full test suite on every push takes more than 30 seconds.
This recipe is not the right fit if: your repository is a monorepo with complex per-package build graphs (scope the hook per package instead), or if your primary concern is secret scanning (add a dedicated secret-detection step rather than embedding regex in this script).
The diagram below shows where the pre-push hook sits in the local-to-remote flow and which failure vectors it intercepts.
Step-by-step recipe Jump to heading
Step 1 — Parse the push payload and build the commit range Jump to heading
Git passes one line per ref being pushed to the hook’s stdin. Each line contains four space-delimited fields: local ref, local SHA, remote ref, remote SHA. A correct minimal stdin-reading loop:
#!/usr/bin/env bash
# .husky/pre-push (or .githooks/pre-push)
set -euo pipefail
PUSH_RANGE=""
while read -r local_ref local_sha remote_ref remote_sha; do
# Skip branch deletions — local SHA is all zeros
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
continue
fi
if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
# First push of a new remote branch: validate all local commits
# not yet present on any remote branch
PUSH_RANGE="$local_sha --not --remotes=origin"
else
PUSH_RANGE="${remote_sha}..${local_sha}"
fi
done
# If nothing to validate (e.g. only deletions), exit cleanly
if [ -z "$PUSH_RANGE" ]; then
exit 0
fi Verify the range resolves correctly before adding checks:
# Manual test — feed the hook a synthetic push line
echo "refs/heads/feat/my-feature $(git rev-parse HEAD) refs/heads/feat/my-feature $(git rev-parse origin/main 2>/dev/null || printf '0%.0s' {1..40})" \
| bash .husky/pre-push The range now identifies exactly which commits are new relative to the remote — no more, no less.
Step 2 — Identify changed file types in the push range Jump to heading
Restrict every subsequent check to the files actually touched. Running TypeScript type-checking when only markdown changed wastes 10+ seconds:
# Collect files changed across the entire push range
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR $PUSH_RANGE 2>/dev/null || true)
# Bucket by extension — used by later steps
CHANGED_TS=$(echo "$CHANGED_FILES" | grep -E '\.tsx?$' || true)
CHANGED_JS=$(echo "$CHANGED_FILES" | grep -E '\.(ts|js|tsx|jsx|mjs|cjs)$' || true) Verify the filter is working:
# Should list only the source files you modified, nothing else
git diff --name-only --diff-filter=ACMR origin/main..HEAD Step 3 — Scope TypeScript type-checking to changed files Jump to heading
tsc --noEmit verifies the whole project against the current type declarations. Run it only when TypeScript files changed in the push range:
if [ -n "$CHANGED_TS" ]; then
echo "→ TypeScript changed — running type check..."
# --noEmit: type-check only, no output files written
# Uses tsconfig.json at repository root by default
npx tsc --noEmit
else
echo "→ No TypeScript files in push range — skipping type check."
fi Note on
--incremental: The--incrementalflag makes TypeScript read and write a.tsbuildinfocache for faster subsequent runs, but it does not restrict which files are type-checked — the entire project is still checked. Use it to accelerate cold starts, not to scope validation.
Verify the step exits zero on a clean project:
npx tsc --noEmit && echo "Type check passed" Step 4 — Run only tests related to changed files Jump to heading
Avoid running the entire test suite locally. Both Jest and Vitest support change-scoped execution:
if [ -n "$CHANGED_JS" ]; then
echo "→ Source files changed — running related tests..."
# Jest: --findRelatedTests walks the import graph from each changed file
# --passWithNoTests: exits 0 if no related test files are found
npx jest --passWithNoTests --findRelatedTests \
$(echo "$CHANGED_JS" | head -30 | tr '\n' ' ')
# Vitest equivalent (uncomment if using Vitest):
# npx vitest run --related $(echo "$CHANGED_JS" | head -30 | tr '\n' ' ')
fi Verify by touching one source file and checking that only its test file runs:
# Expected output: runs 1 test suite, not the entire suite
git stash && touch src/utils/format.ts && git stash pop Step 5 — Parallelize independent checks and profile latency Jump to heading
Lint and type-check have no dependency on each other. Run them concurrently using background processes and wait:
FAIL=0
# Lint (ESLint with cache — only re-lints changed files via --cache)
if [ -n "$CHANGED_JS" ]; then
npx eslint --cache --max-warnings=0 $CHANGED_JS &
LINT_PID=$!
fi
# Type check (if TypeScript files changed)
if [ -n "$CHANGED_TS" ]; then
npx tsc --noEmit &
TSC_PID=$!
fi
# Wait for each background job and capture exit codes
if [ -n "${LINT_PID:-}" ]; then
wait $LINT_PID || FAIL=1
fi
if [ -n "${TSC_PID:-}" ]; then
wait $TSC_PID || FAIL=1
fi
if [ "$FAIL" -ne 0 ]; then
echo "ERROR: Pre-push validation failed. Fix the errors above and push again."
exit 1
fi Profile total hook execution time to keep it under 15 seconds:
time bash .husky/pre-push <<< "refs/heads/main $(git rev-parse HEAD) refs/heads/main $(git rev-parse origin/main)" Enable fsmonitor to cut the cost of Git’s own file-tree scanning on large repositories:
# Persists in the local repo config
git config core.fsmonitor true WARNING: Never embed network-dependent calls (remote API checks, package registry lookups) inside a pre-push hook. A degraded external service will block the push indefinitely. Implement hard timeouts with
timeout <seconds> <command>if any external call is absolutely required, and always provide a local fallback path.
Step 6 — Distribute the hook to the team Jump to heading
Store the hook in a tracked directory and configure Git to use it:
mkdir -p .githooks
cp .husky/pre-push .githooks/pre-push
chmod +x .githooks/pre-push
git config core.hooksPath .githooks
git add .githooks/ Because .git/config is not version-controlled, run a one-time setup script after clone:
#!/usr/bin/env bash
# scripts/setup-hooks.sh
git config core.hooksPath .githooks
chmod +x .githooks/*
echo "Git hooks installed from .githooks/" If the project uses Husky, leverage the prepare lifecycle instead — npm install will install hooks automatically without requiring contributors to run a separate script. See Local Hook Configuration with Husky for the full Husky setup pattern.
Emergency bypass remains available but must be an explicit, auditable action:
git push --no-verify WARNING:
--no-verifyskips all local hooks. In regulated environments, every bypass must be logged. Configure server-side receive hooks to flag pushes that arrive without local validation markers, and require post-incident review for any bypass on protected branches.
Validation checklist Jump to heading
Before considering this hook production-ready, confirm each item:
Frequently asked questions Jump to heading
How do I avoid running the full test suite on every push? Jump to heading
Use Jest’s --findRelatedTests flag or Vitest’s --related flag, passing the files changed in the push range. Both tools walk the import graph from each changed file to find only the test suites that exercise modified code. Combine this with --passWithNoTests so the command exits cleanly when the changed files have no associated tests.
My hook works locally but keeps timing out in the team’s setup — why? Jump to heading
Timeouts are usually caused by one of three things: npm lifecycle scripts reinstalling packages before type-checking (add --ignore-scripts to the tsc invocation environment), a missing .tsbuildinfo cache being regenerated from scratch on each hook run (commit the cache path or use --skipLibCheck to cut cold-start time), or ESLint’s cache file not being shared (ensure .eslintcache is in .gitignore and each developer’s cache warms on first run). Profile with time and set -x to isolate the slow step.
Can I emit a warning without blocking the push? Jump to heading
Git has no soft-warning exit code — any non-zero exit hard-blocks the push. To emit a non-blocking advisory, write it to stdout and exit 0. Reserve exit 1 for failures that genuinely must block. Document this distinction in your hook with a comment so the next person does not accidentally upgrade a warning to a blocker.
Related Jump to heading
- Pre-Push Validation Rules — the parent page covering the full stdin protocol, policy taxonomy, and governance model for pre-push hooks.
- Local Hook Configuration with Husky — manage hook installation and
core.hooksPathconfiguration automatically via thepreparelifecycle. - Migrating from Legacy Git Hooks to Husky v9 — step-by-step guide for teams moving from hand-managed
.git/hooks/scripts to Husky v9’s directory-based model. - CI/CD Pipeline Trigger Mapping — coordinate what the local hook validates vs. what CI validates, preventing both gaps and redundant 10-minute test runs in both layers.
- Lint-Staged Formatting Automation — scope lint and format checks to staged files at commit time, the complementary layer that runs before this pre-push gate.