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.
What Belongs in the Last Hook Before the Network Jump to heading
The push is a meaningful boundary: it is the last moment a change is entirely private, and the first moment it becomes someone elseβs problem. That makes it the natural home for a specific class of check β the ones whose value comes from catching something before anyone else sees it, and whose cost is acceptable a few times a day.
Three categories qualify.
Checks that prevent an irreversible disclosure. A credential in a commit is the clearest example. Before the push it is a line you delete; after it, it is on a server, possibly in a fork, and the remediation is rotation plus a coordinated history rewrite. The asymmetry between those two costs is enormous, which is why a secret scan earns its place here even though it is not free.
Checks that prevent breaking shared state. A push that puts a non-compiling commit on a branch other people build from costs every one of them a confused few minutes. A fast build or type check catches most of those, and the developer who caused it is the cheapest person to fix it because they still have the context.
Checks that depend on the push itself. Branch naming, ref protection, and anything that reasons about what is being published rather than what is in the working tree can only run here. These are also the checks worth mirroring on the server, since the local copy is advisory.
Everything else β full test suites, integration tests, dependency audits, licence checks β belongs in the pipeline. The distinguishing question is not whether the check is valuable but whether catching the problem eight minutes later, in a job nobody is waiting on, would have cost anything. For most checks the honest answer is no, and moving them off the push path is what keeps the ones that remain fast enough to be tolerated.
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 |
Measuring the hook, not guessing at it Jump to heading
The single most useful piece of information about a push hook is how long it actually takes on a real change, and almost nobody measures it. Time the hook on a representative push, write the number in a comment at the top of the script, and re-measure whenever a check is added. A hook with a documented budget invites the question βdoes this new check fit?β β which is exactly the conversation that prevents a fast hook becoming a slow one, one reasonable addition at a time. Without a number written down, every individual addition looks affordable, and the total is discovered only when somebody finally says the hook has become intolerable and removes all of it.
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.