Pre-Push Validation Rules: Engineering the Final Local Gate Jump to heading

The pre-push hook is the last automated checkpoint in Git Automation & CI/CD Hook Engineering that runs entirely on the developerโ€™s machine, before a single byte travels to the remote. It fires after git push is invoked, receives the full list of outbound refs via stdin, and can inspect every commit in the outbound range โ€” giving you the authority to block a push that violates policy without involving a CI runner at all.

Pre-push validation rules must be deterministic: they produce consistent outcomes regardless of repository state or transient network conditions. Non-deterministic checks introduce unpredictable delays that train developers to reach for --no-verify.


Prerequisites Jump to heading


How a pre-push Hook Executes Jump to heading

The diagram below shows the sequence from git push through each validation stage and the two possible outcomes: the push proceeds or is blocked.

Pre-push hook execution pipelineSequence diagram showing git push triggering the pre-push hook, which reads stdin to derive the commit range, then runs commit message validation, secret scanning, and dependency audit in sequence. A non-zero exit blocks the push; exit 0 allows it.git pushpre-push hookParse stdinderive commit rangeCommit messageConventional CommitsSecret scangitleaks / truffleHogDependency auditnpm audit / pip-auditexit 0push proceedsexit 1push blockedviolation

Step 1: Create the Hook File Jump to heading

Intent: Register an executable script at the path Git expects, so it fires on every git push invocation in this repository.

# For a single repo โ€” Git looks in .git/hooks/ by default
touch .git/hooks/pre-push
chmod +x .git/hooks/pre-push

If your team uses Husky to distribute hooks via package.json, create the file at .husky/pre-push instead and set core.hooksPath to .husky.

# Husky-managed location (Husky v9+)
mkdir -p .husky
touch .husky/pre-push
chmod +x .husky/pre-push

Verify: git config core.hooksPath โ€” should print .husky if using Husky, or be empty for default .git/hooks.


Step 2: Parse Stdin and Derive the Commit Range Jump to heading

Intent: Determine exactly which commits are outbound so validation targets new work only โ€” not every commit in history.

Git writes one line to the hookโ€™s stdin per ref being pushed, with four space-delimited fields:

<local_ref> <local_sha> <remote_ref> <remote_sha>

When pushing a brand-new branch, remote_sha is the all-zeros SHA (0000000000000000000000000000000000000000). When deleting a branch, local_sha is all zeros โ€” skip both.

#!/usr/bin/env bash
set -euo pipefail

ZERO_SHA="0000000000000000000000000000000000000000"

while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do

  # Skip branch deletions โ€” nothing to validate
  [[ "$local_sha" == "$ZERO_SHA" ]] && continue

  if [[ "$remote_sha" == "$ZERO_SHA" ]]; then
    # New branch: enumerate commits not yet on any remote ref
    commits=$(git rev-list --no-merges "$local_sha" --not --remotes=origin)
  else
    # Existing branch: only the new commits
    commits=$(git rev-list --no-merges "${remote_sha}..${local_sha}")
  fi

done

Verify: Run git push --dry-run origin HEAD with a set -x debug line in the hook to confirm the correct SHAs appear on stdin.


Step 3: Validate Commit Messages Jump to heading

Intent: Reject the push if any outbound commit subject line fails your Conventional Commits or house-format regex before bad history reaches the remote.

CONVENTIONAL_COMMITS_RE='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .+'

for commit in $commits; do
  subject=$(git log -1 --format='%s' "$commit")
  if ! echo "$subject" | grep -qE "$CONVENTIONAL_COMMITS_RE"; then
    echo "ERROR: Commit ${commit:0:8} has a non-conforming message."
    echo "  Subject: $subject"
    echo "  Expected format: type(scope): description"
    exit 1
  fi
done

Verify: Stage a dummy commit with the subject wip and attempt a push โ€” the hook should print the error and exit before the push completes.


Step 4: Run Secret Detection Jump to heading

Intent: Block any push that introduces a credential, API key, or private key into the commit history.

# Requires gitleaks >= 8 installed in PATH
if command -v gitleaks >/dev/null 2>&1; then
  # Scan only the diff introduced by outbound commits
  if ! gitleaks detect \
        --source . \
        --log-opts "${remote_sha:-HEAD~1}..${local_sha}" \
        --no-git \
        --quiet; then
    echo "ERROR: gitleaks detected secrets in the outbound commit range."
    echo "  Run 'gitleaks detect --verbose' locally to inspect findings."
    exit 1
  fi
fi

WARNING: If a secret has already been committed, blocking the push does not remove it from local history. After confirming no push occurred, rotate the credential immediately and use git filter-repo to scrub the secret from the commit graph before pushing to any remote.

Verify: Create a test commit that adds a file containing a dummy AWS key pattern, then run git push --dry-run โ€” the hook should block and name the offending file.


Step 5: Audit Dependency Vulnerabilities Jump to heading

Intent: Prevent known-critical CVEs from shipping by failing the push when a dependency with a high or critical severity appears in npm audit output.

# Runs once per push, not once per ref
if command -v npm >/dev/null 2>&1 && [[ -f package-lock.json ]]; then
  AUDIT_OUTPUT=$(npm audit --omit=dev --json 2>/dev/null || true)
  CRITICAL=$(echo "$AUDIT_OUTPUT" | \
    python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('metadata',{}).get('vulnerabilities',{}).get('critical',0))")
  HIGH=$(echo "$AUDIT_OUTPUT" | \
    python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('metadata',{}).get('vulnerabilities',{}).get('high',0))")

  if [[ "$CRITICAL" -gt 0 || "$HIGH" -gt 0 ]]; then
    echo "ERROR: $CRITICAL critical and $HIGH high vulnerabilities found."
    echo "  Run 'npm audit' for details. Fix or accept each finding before pushing."
    exit 1
  fi
fi

exit 0

Verify: Temporarily downgrade a dependency to a version with a known critical CVE, run git push --dry-run, and confirm the hook prints the vulnerability count and exits 1.


Integration with Adjacent Tools Jump to heading

The pre-push hook operates at the boundary between local development and the remote, which means its scope must be precisely defined relative to the tools that run on either side.

Upstream: commit-time hooks via Local Hook Configuration with Husky Husky manages the pre-commit and commit-msg hooks that run linting through lint-staged and validate individual commit message format. By the time pre-push fires, staged-file checks are already done. Pre-push should not re-run file-level linting โ€” instead it validates the aggregate: all commit subjects in the outbound range, the full diff for secrets, and the dependency manifest state.

Downstream: CI triggered by CI/CD Pipeline Trigger Mapping Once the push succeeds, a remote workflow takes over โ€” typically a full test suite, Docker builds, and integration tests. Define the split explicitly: pre-push covers checks that complete in under 15 seconds (message lint, secret scan, critical CVE audit); CI covers everything that takes longer. This split prevents both gaps (no layer checks something) and wasteful duplication (both layers run the same 10-minute test suite). Coordinate path-based trigger rules in CI with the same path filtering logic you use in the hook to keep the boundary clean.


Troubleshooting Jump to heading

SymptomLikely causeFix
Hook never firesFile not executable or wrong pathchmod +x .git/hooks/pre-push; confirm core.hooksPath
Push blocked on new branch with โ€œbad revisionโ€remote_sha is zero SHA but range uses remote_sha..local_shaGuard zero SHA and use --not --remotes=origin for new branches
gitleaks blocks push on a false positivePattern in .gitleaks.toml is too broadAdd a [[rules.allowlist]] entry with the specific fingerprint or path
npm audit always exits 1 even with no vulnerabilitiesnpm audit exits non-zero for moderate findings tooPass --audit-level=critical to limit blocking to critical severities
Hook runs but $commits is empty, nothing validatedMerge commits filtered out but all outbound commits are mergesRemove --no-merges if you need to validate merge commit messages
Developer bypasses with --no-verify, no visibilityNo server-side receive hook logging bypass eventsAdd a receive.denyNonFastForwards server hook or use a platform-level push rule

Frequently Asked Questions Jump to heading

What is the difference between a pre-commit hook and a pre-push hook? Jump to heading

A pre-commit hook runs before each individual commit is recorded and sees only the staged diff. A pre-push hook runs once per git push invocation, receives all outbound refs via stdin, and can examine every commit in the outbound range โ€” including commits that were recorded days ago but never pushed.

Can I skip the pre-push hook for an emergency deployment? Jump to heading

Yes โ€” git push --no-verify bypasses all local hooks. In regulated environments you should pair local hooks with a server-side receive hook that detects --no-verify pushes (they lack the X-Git-Hook-Validated header or equivalent signal you choose to set) and records them in an immutable audit log for post-incident review.

How do I limit the hook to specific branches, such as main or release/*? Jump to heading

Parse remote_ref from stdin and branch early if the target does not match your protection pattern:

case "$remote_ref" in
  refs/heads/main|refs/heads/release/*)
    : # continue with checks
    ;;
  *)
    exit 0  # no validation required for feature branches
    ;;
esac

How do I handle monorepo pushes without running every check for every service? Jump to heading

Use git diff --name-only on the outbound commit range to determine which paths changed, then conditionally invoke only the checks relevant to those paths. Cache results keyed on the tree SHA so repeated pushes of identical commits are instant.

Why does my hook run for every ref when I push only one branch? Jump to heading

If you push multiple branches simultaneously (git push origin feature-a feature-b), Git writes one stdin line per ref. Your while read loop processes each independently, which is correct โ€” but ensure your dependency audit and other per-push checks run outside the loop so they execute only once.


  • Preventing Broken Builds with Pre-Push Hooks โ€” practical monorepo patterns, selective test execution, and caching intermediate results to keep hook latency under 10 seconds.
  • Local Hook Configuration with Husky โ€” distributing pre-push and other hooks across the team via package.json so every contributor runs the same checks automatically.
  • Lint-Staged Formatting Automation โ€” the commit-time complement to pre-push, running formatters and linters against staged files before a commit is recorded.
  • CI/CD Pipeline Trigger Mapping โ€” defining the explicit boundary between what local hooks verify and what CI verifies, including path-scoped trigger rules that mirror your hookโ€™s file filters.