Hooks and Automation
A team adds
prettier --writeto their CI. Every PR with unformatted code fails the check. Engineers fix, push again, wait 8 minutes for CI, fix, wait, fix. They are frustrated. Someone suggests addingprettierto a pre-commit hook — now formatting happens automatically when they commit, and CI almost never fails on it. Productivity jumps. The whole debate is whether work should happen locally (hooks) or in the pipeline (CI). The right answer is usually both, at different layers.Git hooks are shell scripts that Git runs at specific moments in the commit/push lifecycle. They are ideal for fast feedback — catch issues in seconds, on the developer's machine, before they are ever pushed. CI is for the slow, thorough, authoritative checks. This lesson covers the hook types, how to install them, the
pre-commitframework that makes them team-wide, and the trade-offs with CI.
The Hook Types
Git hooks are shell scripts in .git/hooks/. They fire at specific moments. The useful ones:
| Hook | When it runs | Typical use |
|---|---|---|
pre-commit | Before the commit is created | Linting, formatting, fast tests |
prepare-commit-msg | Before the commit message editor opens | Auto-populate messages (e.g., include ticket ID) |
commit-msg | After the message is written | Validate message format (Conventional Commits) |
post-commit | After commit is created | Notifications, side effects (rare) |
pre-push | Before push to remote | Run tests, prevent pushes to main |
pre-rebase | Before rebase starts | Refuse rebase of protected branches |
post-merge | After pull/merge completes | Auto-rebuild dependencies, migrations |
pre-receive, post-receive, update | Server-side (on remotes) | Enforce policies on push target |
Client-side hooks (pre-commit, commit-msg, pre-push) run on the developer's machine. Server-side hooks (pre-receive) run on the remote (rare in modern workflows; managed platforms have PR-based controls instead).
Creating a Hook Manually
# Each hook is a script at .git/hooks/<name>
# Git ships sample hooks with a .sample suffix
ls .git/hooks/
# applypatch-msg.sample pre-commit.sample pre-push.sample ...
# Create a real hook by saving the file without .sample
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh
# Refuse commits that contain "TODO" without a ticket number
if git diff --cached | grep -E '^\+.*\bTODO\b[^#]*$' > /dev/null; then
echo "TODO without ticket number. Add 'TODO(TICKET-123):'"
exit 1
fi
EOF
chmod +x .git/hooks/pre-commit
# Now every commit runs this check
git commit -m "test"
# If a staged line added 'TODO foo', the commit fails with the message
Key properties
- Executable.
chmod +xrequired. - First line is a shebang.
#!/bin/sh,#!/bin/bash,#!/usr/bin/env python3, etc. - Exit code decides. 0 = allow; non-zero = block.
- Stdout/stderr are visible to the user.
.git/hooks/is local. Not cloned, not pushed. Each developer must install their own.
That last point is the problem: hooks do not travel with the repo. A hook you write solves it for you, but not for the team. Enter the pre-commit framework.
The pre-commit Framework
pre-commit is a Python tool (installable via pip or Homebrew) that makes hook management repo-level and team-shareable:
# Install once
pip install pre-commit
# or: brew install pre-commit
Each repo has a .pre-commit-config.yaml:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=500']
- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
hooks:
- id: ruff
args: [--fix]
Install into the repo:
pre-commit install
# Installs a hook at .git/hooks/pre-commit that runs the framework
Now every commit automatically runs:
trailing-whitespace— strip trailing whitespace from modified lines.end-of-file-fixer— ensure files end with a newline.check-yaml— validate YAML syntax.check-added-large-files— block commits with files > 500 KB.black— auto-format Python.ruff— lint Python, auto-fix what it can.
Why this is a big deal
- Config in the repo.
.pre-commit-config.yamlis committed. Everyone who clones gets it. - One command to activate.
pre-commit install— a README line. - Hook versions pinned.
rev: v0.3.0makes builds reproducible. - Thousands of hooks available. Every popular linter and formatter has a pre-commit hook.
- Run on CI too.
pre-commit run --all-filesin CI ensures forgotten installs do not bypass checks.
Popular hooks
# Go
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-vet
- id: go-mod-tidy
# JavaScript / TypeScript
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.0.0
hooks:
- id: eslint
# Security
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
# General
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: detect-private-key
- id: no-commit-to-branch
args: [--branch, main] # refuse commits directly to main
The pre-commit framework + .pre-commit-config.yaml is the standard professional answer to "how do we share Git hooks?" It turns hooks from a personal tool into a team-wide one, with version-pinned, CI-verified, easy-to-install checks. Adopting it is a single-digit number of minutes and pays off for years.
Hooks vs CI: Where Each Fits
| Check | Pre-commit hook | CI |
|---|---|---|
| Fast lint / format | ✓ immediate feedback | ✓ as a safety net |
| Unit tests (< 10s) | Sometimes | Always |
| Full test suite (minutes) | ✗ too slow locally | ✓ |
| Build / compile | Depends on speed | ✓ |
| Security scans | ✓ for quick ones (secrets) | ✓ for deep ones (CVE scans) |
| Code style | ✓ auto-fix on commit | ✓ verify in CI |
| Commit message format | ✓ (via commit-msg hook) | ✓ (redundant check) |
| Integration tests | ✗ | ✓ |
| Deploy / release | ✗ | ✓ |
The layers principle
- Pre-commit: fast, local, blocks obviously-wrong work before it enters the repo. Optional to bypass with
--no-verifyfor emergencies. - Pre-push: slower local checks before work hits the remote. Useful for "run tests before I push."
- CI: authoritative, thorough, required. Whatever passes CI is what merges.
Hooks are convenience; CI is policy. Hooks speed up the dev loop; CI enforces quality at the merge gate. Do both.
--no-verify is the escape hatch
git commit --no-verify -m "hotfix: emergency rollback"
git push --no-verify
Skips client-side hooks. Use for emergencies. CI still runs — you cannot merge broken code just by skipping hooks locally.
The commit-msg Hook
Validates the commit message after you write it:
# .git/hooks/commit-msg
#!/bin/sh
# Enforce Conventional Commits format: type(scope): subject
commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")
pattern='^(feat|fix|docs|style|refactor|test|chore|perf|build|ci)(\(.+\))?: .{1,72}'
if ! echo "$commit_msg" | head -n 1 | grep -qE "$pattern"; then
echo "Commit message does not match Conventional Commits format."
echo "Example: feat(auth): add login endpoint"
exit 1
fi
Or via pre-commit / commitizen:
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.2.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
This catches bad messages at commit time (not at push / PR time). Fast feedback, no wasted context switches.
The pre-push Hook
Runs before git push. Good place for:
- Running unit tests ("do not push broken code").
- Preventing push to protected branches ("do not push to main").
- Running scans that are too slow for every commit but fast enough for pre-push.
# .git/hooks/pre-push
#!/bin/sh
protected_branch='main'
current_branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')
if [ "$current_branch" = "$protected_branch" ]; then
echo "Pushing directly to $protected_branch is not allowed."
exit 1
fi
# Run tests
npm test || { echo "Tests failed; not pushing"; exit 1; }
Server-Side Hooks (Rare Today)
On self-hosted Git servers (GitLab, Gitea, self-hosted GitHub Enterprise, raw git init --bare), you can install server-side hooks that run on the remote:
pre-receive— runs before a push is accepted. Reject bad pushes.update— per-ref equivalent, runs once per pushed branch.post-receive— runs after accepting. Trigger CI, send notifications.
Example: reject pushes with commits not matching a policy:
# /path/to/bare-repo.git/hooks/pre-receive
#!/bin/sh
while read oldrev newrev refname; do
for commit in $(git rev-list $oldrev..$newrev); do
msg=$(git log -1 --format=%s $commit)
if ! echo "$msg" | grep -qE '^(feat|fix)'; then
echo "Commit $commit has non-compliant message."
exit 1
fi
done
done
On managed platforms (GitHub.com, GitLab.com, Bitbucket Cloud), server-side hooks are not accessible. Use branch protection rules, required status checks, and PR review requirements instead — they accomplish the same goals with better UX.
Auto-Formatting Workflows
The sweet spot for hooks: auto-fixers that edit your code in place on commit.
# .pre-commit-config.yaml
- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
hooks:
- id: ruff
args: [--fix]
When you commit:
- pre-commit runs black on staged files.
- If black made changes, the hook "fails" and shows what it fixed.
- You
git addthe updated files and re-commit. - Second time, black has nothing to change; commit proceeds.
To reduce friction, add pre-commit's auto-stage:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
hooks:
- id: ruff-format # equivalent to ruff 0.3+; reformats in-place and stages
Many teams configure auto-format hooks with always_run: true so every commit is guaranteed-formatted.
Auto-formatters in pre-commit are a productivity win. Engineers stop thinking about style entirely. Code reviews stop debating formatting. CI never fails on lint. The one-time cost (adopt a formatter, reformat the whole repo, add .git-blame-ignore-revs) is paid back in days.
Dealing With Long-Running Hooks
Some checks (type-check, full lint, security scan) take 30+ seconds. Too slow for every commit. Options:
1. Run only on changed files
- id: mypy
files: \.py$
# (default — only runs on staged .py files)
pre-commit runs each hook only against files matching its files pattern. Much faster than "lint the whole repo."
2. Move to pre-push instead of pre-commit
- id: mypy
stages: [pre-push]
# runs on `git push`, not every commit
3. Move to CI entirely
Some checks belong in CI, not as hooks. Full build, integration tests, security scans over the whole tree — these are CI's job.
4. Parallel execution
pre-commit runs hooks in parallel by default when possible. For faster overall time, enable:
default_language_version:
python: python3.11
# ...
- id: fast-hook
# no special args
- id: another-fast-hook
# no special args
# Both will run in parallel
CI-Side pre-commit
Even with hooks installed locally, engineers can --no-verify. CI should re-run the checks to catch bypasses:
# .github/workflows/ci.yml
- name: Run pre-commit
run: |
pip install pre-commit
pre-commit run --all-files --show-diff-on-failure
This is the belt-and-suspenders pattern: hooks speed up development; CI enforces policy.
Custom Hooks In .pre-commit-config.yaml
For project-specific checks:
- repo: local
hooks:
- id: check-database-migrations
name: Check database migrations
entry: scripts/check-migrations.sh
language: script
files: ^migrations/
- id: no-print-in-production
name: No print() in production code
entry: bash -c 'grep -rn "print(" src/ && exit 1 || exit 0'
language: system
pass_filenames: false
Any executable is a valid hook. Languages supported: python, node, ruby, go, rust, docker, system (shell), script (a path in the repo).
Hook-Skipping Workflow
Sometimes you genuinely need to commit without hooks (disaster recovery, half-finished work stash):
# Skip hooks once
git commit --no-verify -m "wip"
git push --no-verify
# Skip via env var (for scripted use)
SKIP=ruff,black git commit -m "temp: skip formatters this time"
# Skip entirely (revert with pre-commit install)
pre-commit uninstall
Teams sometimes disable hooks during large refactors ("we're reformatting everything; stop auto-formatting temporarily"). Re-enable afterward.
Security Hooks
Some of the highest-value hooks:
Secret detection
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
Catches AWS keys, private SSH keys, API tokens, etc. in staged content before the commit. Prevents the ugly "I committed my GCP service account JSON" incident.
Large file detection
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-added-large-files
args: ['--maxkb=500']
Prevents accidental commits of huge binaries. For files that legitimately need to be tracked, use Git LFS.
Private key detection
- id: detect-private-key
Part of pre-commit-hooks. Catches -----BEGIN RSA PRIVATE KEY----- blobs.
Secret-detection hooks are a safety net, not a guarantee. Sophisticated secrets (obfuscated tokens, entropy-encoded keys) may slip through. Combine with: GitHub secret scanning (free for public repos), TruffleHog in CI for deeper scans, and — most importantly — rotation discipline (if a secret was ever committed anywhere, rotate it; do not trust history rewrites).
Team Adoption
A realistic rollout:
Step 1: propose and agree
Discuss in a team meeting. Get buy-in on what hooks and why. Resistance: "another slow thing on my commit." Address with speed expectations and auto-fix positioning.
Step 2: baseline format
Run pre-commit run --all-files once; commit the reformat as a single commit. Add the SHA to .git-blame-ignore-revs:
# .git-blame-ignore-revs
abc123def456abcd # Mass reformat
GitHub honors this file automatically in its blame UI. Locally, configure:
git config blame.ignoreRevsFile .git-blame-ignore-revs
Step 3: add to CI
# .github/workflows/ci.yml
- uses: pre-commit/action@v3.0.1
CI rejects PRs that fail hooks — enforces the policy even for engineers who forgot to pre-commit install.
Step 4: add to onboarding
The new-hire checklist should include pre-commit install. Or use direnv / Makefile / setup scripts to auto-install.
Step 5: evolve
Add more hooks as needs surface. Review quarterly — remove ones that are no longer adding value, add ones that address recent recurring issues.
Key Concepts Summary
- Git hooks are shell scripts at specific lifecycle moments (pre-commit, commit-msg, pre-push, etc.).
- Hooks are local (
.git/hooks/); they do not travel with the repo by default. - The
pre-commitframework (pre-commit.com) makes hooks team-shared via.pre-commit-config.yaml. - Hook types: pre-commit (fast, blocks bad commits), commit-msg (validate message), pre-push (pre-remote verification).
- Hooks for speed, CI for authority. Both layers are valuable.
- Auto-format on commit is the highest-value pattern — style debates disappear.
- Secrets, large files, private keys are detectable via standard hooks.
--no-verifyis the escape hatch for emergencies; CI catches bypasses.- Server-side hooks exist but are rare; platforms use branch protection instead.
.git-blame-ignore-revspreserves blame after mass reformat commits.
Common Mistakes
- Writing personal hooks in
.git/hooks/without sharing. Pointless unless everyone has the same setup. - Making hooks too slow. 30-second pre-commit = engineers disable them. Keep fast; move slow checks to pre-push or CI.
- Hooks without CI enforcement.
--no-verifybypasses; CI is the real gatekeeper. - Not pinning hook versions (
rev). Behavior changes across versions; builds become non-reproducible. - Applying reformat hooks without a baseline reformat. Every PR touches unrelated lines forever after.
- Forgetting
.git-blame-ignore-revsafter a mass reformat.git blamepoints at the reformatter for every line. - Secret detection in hooks but no rotation policy. Secret leaks are not fixable just by detection; they need rotation.
- Custom Python/Node hooks in
.pre-commit-config.yamlwithout listing them asrepo: local. Hook fails to find the tool. - Running
pre-commithooks on a CI machine without installing the tool.pip install pre-commitin CI setup, always. - Running style hooks on generated / vendored code. Exclude via
exclude: ^vendor/|^gen/.
Your team wants to enforce that no commit message ever contains the word 'WIP' (to prevent work-in-progress commits landing on main). You want this applied to every team member without each person manually installing a hook. What is the professional approach?