Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXXV · HooksHooks

Pre-commit and pre-push — the two most-used client-side hooks

Intermediate⏱ ~22 mingit

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

Not yet marked complete on this device.

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

  1. Keep pre-commit fast. A pre-commit hook that takes more than 10 seconds is bypassed. Format, lint, and secret scan belong here.
  2. 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.
  3. 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.
  4. 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.
  5. 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 --cached is 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

  1. 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?

  2. 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.

  3. Q3. Distinguish pre-commit from pre-push in terms of cadence, cost tolerance, and the question each hook answers.

  4. 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.