Git, CI/CD & GitOpsXXV · HooksHooks
Pre-commit and pre-push — the two most-used client-side hooks
What you'll learn
- Read the index from a pre-commit hook and identify the staged content the hook is checking
- Configure pre-commit to block on format, lint, and secret-scan failures before the commit object is recorded
- Configure pre-push to block on outgoing-pack checks before the push leaves the machine
- Distinguish pre-commit (staged content) from pre-push (outgoing refs and remote) and pick the right hook for each rule
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
Of the dozen or so client-side hooks, two carry almost all of
the production value: pre-commit and pre-push. The others
(prepare-commit-msg, commit-msg, post-commit,
pre-merge-commit, post-merge, post-checkout,
pre-rebase) are useful for specific workflows, but the
production-relevant work - blocking malformed commits, scanning
for secrets, running the test suite - lives in pre-commit and
pre-push. This lesson covers what each hook reads, what each
can block, and the difference between them.
pre-commit — blocking on staged content
pre-commit fires before the commit object is recorded. It
receives no arguments and reads no stdin. Its input is the index:
the staged content that will become the next commit. The hook
can inspect the staged files with git diff --cached and the
list of staged paths with git diff --cached --name-only.
#!/usr/bin/env bash
# .git/hooks/pre-commit
# Block on Terraform formatting, shellcheck, and AWS key patterns
set -euo pipefail
# 1. Format check on staged Terraform files
STAGED_TF=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.tf$')
if [ -n "$STAGED_TF" ]; then
echo "Running terraform fmt on staged files..."
echo "$STAGED_TF" | xargs terraform fmt -check -diff
fi
# 2. Shellcheck on staged shell scripts
STAGED_SH=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(sh|bash)$')
if [ -n "$STAGED_SH" ]; then
echo "Running shellcheck on staged scripts..."
echo "$STAGED_SH" | xargs shellcheck
fi
# 3. Block on AWS access key patterns in any staged file
if git diff --cached | grep -qE 'AKIA[0-9A-Z]{16}'; then
echo "ERROR: staged content matches AWS access key pattern" >&2
exit 1
fi
The hook reads the index (the staged content), inspects the paths the engineer has staged, runs the appropriate tool on each, and exits non-zero if any check fails. The commit is aborted before the commit object is recorded; the engineer sees the hook’s stderr and can fix the issue.
flowchart LR
S["git add paths"] --> IDX["index updated"]
IDX --> C["git commit"]
C --> PC["pre-commit hook"]
PC --> R1{"git diff --cached"}
R1 --> FMT["terraform fmt -check"]
R1 --> LINT["shellcheck"]
R1 --> SCAN["AWS key pattern scan"]
FMT --> J{"all checks pass?"}
LINT --> J
SCAN --> J
J -->|"yes, exit 0"| CMT["commit object recorded"]
J -->|"no, exit non-zero"| AB["commit aborted, stderr printed"]
The three checks illustrate the three categories of pre-commit rule:
- Format.
terraform fmt -check,prettier --check,black --check,gofmt -l. The rule is mechanical; a tool can both detect and auto-fix the violation. - Lint.
shellcheck,eslint,flake8,tflint. The rule is judgement; a tool detects the violation but cannot auto-fix without making a policy choice. - Secret scan.
gitleaks,trufflehog, AWS-key regex. The rule is forensic; a tool scans for known patterns and refuses to allow the pattern to be committed.
pre-push — blocking on outgoing content
pre-push fires before the push leaves the machine, after the
engineer has run git push but before any pack is transmitted.
It receives the remote name as $1 and the remote URL as $2.
It reads the list of refs to be updated on stdin, one per line
in the form <local_ref> <local_oid> <remote_ref> <remote_oid>.
#!/usr/bin/env bash
# .git/hooks/pre-push
# Block on full test suite, dependency audit, and module-version checks
set -euo pipefail
# Read the list of refs to be pushed
while read local_ref local_oid remote_ref remote_oid; do
# Skip branch creation (remote_oid is all zeros)
if [ "$remote_oid" = "0000000000000000000000000000000000000000" ]; then
continue
fi
# Block pushes to main
if [ "$remote_ref" = "refs/heads/main" ]; then
echo "ERROR: direct push to main is forbidden; open a pull request" >&2
exit 1
fi
done
# Run the full test suite on the merge-base with origin/main
MERGE_BASE=$(git merge-base HEAD origin/main 2>/dev/null || echo HEAD)
echo "Running full test suite on $MERGE_BASE..."
git stash --keep-index > /dev/null
git checkout "$MERGE_BASE" > /dev/null 2>&1
make test
TEST_RC=$?
git checkout - > /dev/null 2>&1
git stash pop > /dev/null 2>&1 || true
exit "$TEST_RC"
The example illustrates the two categories of pre-push rule:
- Outgoing-ref checks. Read the list of refs being pushed
from stdin; refuse if any ref is forbidden (e.g. direct
push to
main), if any ref is being deleted, if any force-push is detected (old_oid not an ancestor of new_oid). - Outgoing-content checks. Check out the merge-base with the upstream, run a slow check (full test suite, dependency audit, plan generation), then restore the working tree.
The advantage of pre-push over pre-commit is timing. Pre-commit runs on every commit, including the 30 commits an engineer makes while iterating on a feature branch; pre-push runs once per push, which is the cadence at which a slower check is acceptable.
When to use pre-commit versus pre-push
The rule of thumb:
- Pre-commit for fast checks (under 10 seconds) that run on every commit: format, lint, secret scan, staged-file policy.
- Pre-push for slow checks (10s to a few minutes) that run once per push: full test suite, dependency audit, plan generation, module-version check.
flowchart TB
subgraph COMMIT["git commit cycle (runs many times per hour)"]
PC["pre-commit"]
PCF["format / lint / secret scan"]
end
subgraph PUSH["git push cycle (runs a few times per day)"]
PP["pre-push"]
PPT["full test suite / dep audit / plan"]
end
PC --> PCF
PP --> PPT
PCF -->|"fast feedback\nunder 10s"| OK1["commit allowed"]
PPT -->|"slower check\n10s to a few minutes"| OK2["push allowed"]
A check that fits in either slot can go in either slot. The trade-off is feedback speed (pre-commit) versus check coverage (pre-push). A secret scan that completes in 200ms belongs in pre-commit (every commit); a dependency audit that takes 45s belongs in pre-push (every push). A check that takes minutes belongs in CI, not in a client-side hook.
Production discipline
- Keep pre-commit fast. A pre-commit hook that takes more than 10 seconds is bypassed. Format, lint, and secret scan belong here.
- Move slow checks to pre-push. Full test suites, dependency audits, and plan generation belong in pre-push, not pre-commit. The slower cadence matches the cost.
- Push the slowest checks to CI. A check that takes minutes belongs in CI, where it runs once per push against a clean environment. Client-side hooks are not a substitute for CI; they are a faster first pass.
- Make every hook script idempotent. A pre-commit hook that is safe to re-run after a fix is a hook the engineer will not bypass. A hook that has side effects (creating a file, sending a notification) needs to be careful about re-runs.
- Test the hook on the same shell the engineer uses.
Hooks run in the engineer’s shell, with the engineer’s
$PATH. A hook that works in CI but fails on a developer laptop is a hook that will be bypassed with--no-verify.
Cross-course references
- Git, CI/CD & GitOps - Part VI (Staging) lesson 05
explains why the index is the canonical input to a
pre-commit hook and why
git diff --cachedis the pre-commit lint surface. - Git, CI/CD & GitOps - Part VI (Staging) lesson 04 explains why staging a file (rather than committing it directly) is what makes pre-commit possible.
- CI/CD Pipeline Patterns - Parts II (PipelineStages) and VIII (BranchPolicies) cover the hosted equivalent of pre-push (required status checks) that runs in CI.
Quiz
Knowledge check · 4 questions
Q1. A team wants to block any commit that contains a staged AWS access key. Which hook should host the check, and what input does the hook read?
Q2. A pre-push hook can read the list of refs being pushed on stdin and refuse the push if any of those refs is `refs/heads/main`, because that is the most common policy a pre-push hook enforces.
Q3. Distinguish pre-commit from pre-push in terms of cadence, cost tolerance, and the question each hook answers.
Q4. Diagnose why a pre-commit hook that runs the full Terraform plan is bypassed within a week, and recommend the correct split between pre-commit, pre-push, and CI.
A team installs a pre-commit hook that runs `terraform plan` against the staging backend on every commit. The plan takes 90 seconds. Within a week, every engineer has switched to `git commit --no-verify` because the hook slows down every commit. The team asks how to keep the plan check without the bypass.
Passing score: 75%. Answers are checked in this browser.