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.
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-repoto 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Hook never fires | File not executable or wrong path | chmod +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_sha | Guard zero SHA and use --not --remotes=origin for new branches |
gitleaks blocks push on a false positive | Pattern in .gitleaks.toml is too broad | Add a [[rules.allowlist]] entry with the specific fingerprint or path |
npm audit always exits 1 even with no vulnerabilities | npm audit exits non-zero for moderate findings too | Pass --audit-level=critical to limit blocking to critical severities |
Hook runs but $commits is empty, nothing validated | Merge commits filtered out but all outbound commits are merges | Remove --no-merges if you need to validate merge commit messages |
Developer bypasses with --no-verify, no visibility | No server-side receive hook logging bypass events | Add 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.
Related Jump to heading
- 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-pushand other hooks across the team viapackage.jsonso 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.