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.

Pre-push hook position in the git push flowDiagram showing the sequence: local commits, pre-push hook intercept (lint, type check, related tests), then remote transmission to origin, then CI pipeline. Failure at the hook stops the flow before any network traffic.Local commitsgit pushpre-push hooklint (changed files)type-check (if .ts changed)related tests onlyexit 1 — pushblocked locallyfailpassoriginremote updatedCI pipelineintegration tests

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 --incremental flag makes TypeScript read and write a .tsbuildinfo cache 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"

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-verify skips 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.