Git hooks are your last line of defense before code ever touches a remote repository. While CI/CD pipelines catch issues after you push, hooks catch them before you even commit. With Git hooks, you can auto-format code, enforce commit message conventions, run tests, and prevent secrets from leaking into your repository. In this hands-on guide, you'll build a complete suite of Git hooks that transform your development workflow.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Understand what Git hooks are and how they integrate into the commit lifecycle
- Create client-side hooks for pre-commit, pre-push, and commit-msg events
- Write bash and Python hook scripts that enforce code quality automatically
- Use the pre-commit framework to manage hooks across your team
- Prevent secrets, large files, and bad commits from reaching your repository
- Share hooks with your team using Git templates and dedicated repositories
2. Why Git Hooks?
Real-world scenario: Your team has a CI/CD pipeline that runs tests on every push. But someone pushes a commit containing an AWS access key. The secret is now in the git history forever, even after you force-push a fix. Git hooks can catch this before the commit is ever created. Another common scenario: your project uses commitizen conventions, but half the team writes commit messages like "fixed stuff" or "update." A commit-msg hook enforces the format before the commit is saved.
Key benefits:
- ✅ Catches issues before CI consumes time and resources
- ✅ Enforces team conventions locally, not via code review nagging
- ✅ Runs entirely offline no internet or GitHub required
- ✅ Composable and sharable across team members
- ✅ Prevents secrets and credentials from entering the repository
3. Core Concepts
What Are Git Hooks?
Git hooks are scripts that Git executes before or after certain events: commit, push, merge, rebase, and more. They live in the .git/hooks/ directory of every repository. Each hook is a named script file that Git runs automatically when the corresponding event triggers.
There are two categories of hooks:
- Client-side hooks run on your local machine (commit, push, merge)
- Server-side hooks run on the Git server (receive, update)
This lesson focuses on client-side hooks, which are available to every developer regardless of hosting platform.
Hook Lifecycle and Execution Order
When you run git commit, Git executes hooks in this order:
pre-commit— before the commit object is created. Return non-zero to abort.prepare-commit-msg— after the default message is generated, before the editor opens.commit-msg— after the user writes the message. Great for validation.post-commit— after the commit is created. Used for notifications.
For git push, the relevant hooks are:
pre-push— before any data is sent. Ideal for running test suites.post-push— after the push completes. Used for CI trigger notifications.
How Hooks Work
A hook is simply an executable script. Git passes information through environment variables and stdin. The hook exits with status code 0 to allow the operation, or non-zero to abort. Hooks can be written in any language: bash, Python, Ruby, Node.js, or even compiled binaries.
# Anatomy of a Git hook
# File: .git/hooks/pre-commit
#!/bin/bash
set -euo pipefail
echo "Running pre-commit checks..."
# Run linter
if ! flake8 .; then
echo "ERROR: Linting failed. Fix issues before committing."
exit 1
fi
# Check for secrets
if git diff --cached | grep -qE '(AWS_ACCESS_KEY|SECRET_KEY)'; then
echo "ERROR: Potential secret detected in staged changes. Aborting."
exit 1
fi
echo "All pre-commit checks passed."
4. Building Your First Git Hooks
Step 1: Create a pre-commit Hook for Code Quality
Navigate to any Git repository and create a pre-commit hook that automatically runs the linter and formatter on staged files only. This ensures you never commit code that violates style rules.
cd /path/to/your/project
cat > .git/hooks/pre-commit << 'HOOK'
#!/bin/bash
set -euo pipefail
# Run on staged Python files only
STAGED_PYTHON=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$' || true)
if [ -z "$STAGED_PYTHON" ]; then
exit 0
fi
echo "Running linter on staged Python files..."
flake8 $STAGED_PYTHON
echo "Running formatter check..."
black --check $STAGED_PYTHON
# Remove trailing whitespace from all staged files
for file in $STAGED_PYTHON; do
sed -i 's/[[:space:]]*$//' "$file"
git add "$file"
done
HOOK
chmod +x .git/hooks/pre-commit
Step 2: Create a commit-msg Hook for Commit Conventions
Enforce conventional commit format like type(scope): description. This is essential for auto-generated changelogs and semantic versioning.
cat > .git/hooks/commit-msg << 'HOOK'
#!/bin/bash
set -euo pipefail
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Skip merge commits and fixup commits
if echo "$COMMIT_MSG" | grep -qE '^(Merge|fixup!|squash!)'; then
exit 0
fi
# Conventional commit pattern: type(scope): description
PATTERN='^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\\(.+\\))?: .+$'
if ! echo "$COMMIT_MSG" | head -1 | grep -qE "$PATTERN"; then
echo "ERROR: Commit message must follow conventional commit format:"
echo " type(scope): description"
echo "Examples:"
echo " feat(api): add user authentication endpoint"
echo " fix(db): resolve connection pool exhaustion"
echo " docs(readme): update installation instructions"
echo ""
echo "Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert"
exit 1
fi
echo "Commit message format validated."
HOOK
chmod +x .git/hooks/commit-msg
Step 3: Create a pre-push Hook to Run Tests
The pre-push hook runs before any data leaves your machine. Use it to run the full test suite, preventing broken code from ever reaching CI.
cat > .git/hooks/pre-push << 'HOOK'
#!/bin/bash
set -euo pipefail
# Abort push if disabled env var is set
if [ "${GIT_HOOKS_DISABLED:-}" = "1" ]; then
echo "Hooks disabled via GIT_HOOKS_DISABLED=1, skipping pre-push checks."
exit 0
fi
echo "Running pre-push checks..."
# Check for large files (>5MB)
REMOTE="$1"
URL="$2"
for file in $(git diff --stat --cached HEAD | awk '{print $1}'); do
SIZE=$(git show HEAD:"$file" 2>/dev/null | wc -c)
if [ "$SIZE" -gt 5242880 ]; then
echo "ERROR: File '$file' is larger than 5MB ($((SIZE/1024/1024))MB)."
echo "Use git-lfs or remove the file before pushing."
exit 1
fi
done
# Run test suite
if [ -f Makefile ] && grep -q '^test' Makefile; then
echo "Running tests..."
make test
elif [ -f pyproject.toml ]; then
echo "Running pytest..."
python -m pytest
fi
echo "All pre-push checks passed."
HOOK
chmod +x .git/hooks/pre-push
Step 4: Prevent Secrets from Leaking
Secret leakage is one of the most expensive mistakes a developer can make. Here is a dedicated hook that scans staged changes for common secret patterns and blocks the commit.
cat > .git/hooks/pre-commit << 'HOOK'
#!/bin/bash
set -euo pipefail
# Source: .git/hooks/pre-commit-secrets
# This script prevents secrets from being committed
SECRET_PATTERNS=(
'AKIA[0-9A-Z]{16}' # AWS Access Key
'sk-[a-zA-Z0-9]{32,}' # OpenAI / API keys
'ghp_[a-zA-Z0-9]{36}' # GitHub Personal Access Token
'gho_[a-zA-Z0-9]{36}' # GitHub OAuth Token
'xox[baprs]-[a-zA-Z0-9-]{24,}' # Slack tokens
'-----BEGIN (RSA |EC )?PRIVATE KEY' # Private keys
)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
for file in $STAGED_FILES; do
if [ ! -f "$file" ]; then continue; fi
# Check each pattern against staged content
for pattern in "${SECRET_PATTERNS[@]}"; do
if git show :"$file" | grep -qE "$pattern"; then
echo "ERROR: Potential secret detected in $file"
echo "Pattern matched: $pattern"
echo "Remove the secret before committing, or add it to .gitallowed"
exit 1
fi
done
done
echo "Secret scan passed."
HOOK
chmod +x .git/hooks/pre-commit
5. The pre-commit Framework
Writing hooks by hand is educational, but managing them across a team of 10 developers is impractical. The pre-commit framework (Python package pre-commit) solves this by managing hooks declaratively through a .pre-commit-config.yaml file in your repository root.
# Install the pre-commit framework
pip install pre-commit
# Start using pre-commit in your project
cd /path/to/your/project
# Create .pre-commit-config.yaml
cat > .pre-commit-config.yaml << 'YAML'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=5000']
- id: detect-private-key
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 7.1.0
hooks:
- id: flake8
args: ['--max-line-length=88']
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
YAML
# Install the hooks into .git/hooks/
pre-commit install
# Run against all files (first time)
pre-commit run --all-files
# Run only on staged files (same as commit would do)
pre-commit run
Sharing Hooks with Your Team
The beauty of the pre-commit framework is that .pre-commit-config.yaml lives in the repository itself. Every developer who clones the repo runs pre-commit install once, and they get the exact same hooks automatically. When you update the config file, everyone picks up the changes on their next clone or pull.
6. Advanced Hook Techniques
Python Hooks for Complex Logic
When bash is too limiting, write hooks in Python. This is particularly useful for complex validation like checking API spec compatibility or running custom business rules.
#!/usr/bin/env python3
"""pre-commit hook: validate database migration naming."""
import os
import re
import sys
from pathlib import Path
MIGRATION_DIR = Path("migrations/versions")
MIGRATION_PATTERN = re.compile(r"^[0-9]{4}_[a-z0-9_]+\\.py$")
errors = []
if MIGRATION_DIR.exists():
for migration in MIGRATION_DIR.iterdir():
if migration.suffix == ".py" and not MIGRATION_PATTERN.match(migration.name):
errors.append(f"Invalid migration name: {migration.name}")
if errors:
print("\n".join(errors))
sys.exit(1)
# Check for model field changes (simplified example)
staged = os.popen("git diff --cached --name-only --diff-filter=ACM").read().strip()
if "models.py" in staged or "models/" in staged:
print("INFO: Model changes detected. Did you create a migration?")
sys.exit(0)
Skipping Hooks Temporarily
Sometimes you need to bypass hooks for legitimate reasons (e.g., a WIP commit or an emergency fix). Git provides two mechanisms:
git commit --no-verifyor-nskips the pre-commit and commit-msg hooksgit push --no-verifyskips the pre-push hookGIT_HOOKS_DISABLED=1 git commit— if your hooks respect this env var convention
Use these sparingly. Every bypassed hook is a risk of bad code entering the repository.
7. Git Template Hooks
Instead of copying hooks into every repository manually, configure a Git template directory that automatically seeds new repositories with your hooks.
# Create a template directory
mkdir -p ~/.git-templates/hooks
# Copy your hooks there
cp .git/hooks/pre-commit ~/.git-templates/hooks/
cp .git/hooks/commit-msg ~/.git-templates/hooks/
cp .git/hooks/pre-push ~/.git-templates/hooks/
chmod +x ~/.git-templates/hooks/*
# Configure Git to use the template
git config --global init.templateDir ~/.git-templates
# Now every git init or git clone will include your hooks
git clone https://github.com/example/new-project.git
ls -la new-project/.git/hooks/ # Pre-populated with your hooks
8. Common Errors & Solutions
- Error: "git commit hangs and never completes" — Your hook script has an infinite loop or is waiting for stdin. Make sure hooks that read stdin are properly handled. For pre-commit hooks that take no input, redirect stdin:
git commit < /dev/nullfor testing. - Error: "AssertionError: The pre-commit hook failed" — The hook exited with a non-zero status. Check the hook output above the error. Fix the linting issue or use
git commit --no-verifyto bypass temporarily. - Error: "permission denied: .git/hooks/pre-commit" — Your hook script is not executable. Run
chmod +x .git/hooks/pre-committo make it executable. - Error: "Bad exit status from /usr/bin/env" — Your shebang line references an interpreter that does not exist. Verify the path:
which python3vs#!/usr/bin/env python3. - Error: "No such file or directory" in hook output — The hook uses a relative path that doesn't exist from Git's working directory. Use absolute paths or check that the referenced files exist before running commands.
- Error: "pre-commit framework: Failed to install hooks" — Conflicting Python versions or missing dependencies. Try
pip install --upgrade pre-commitand runpre-commit cleanto clear the cache.
9. Summary Checklist
- Understand the hook lifecycle: pre-commit, commit-msg, pre-push
- Created a pre-commit hook for linting staged files
- Created a commit-msg hook for conventional commit format
- Created a pre-push hook for running tests and blocking large files
- Built a secret scanner to prevent credential leaks
- Installed and configured the pre-commit framework with a .pre-commit-config.yaml
- Learned to skip hooks with --no-verify when necessary
- Set up a global Git template directory for team-wide hooks
10. Practice Exercise
Create a complete hook suite for a Python project using the pre-commit framework:
- Create a new Git repository and initialize it
- Add a
.pre-commit-config.yamlwith the following hooks: trailing-whitespace, end-of-file-fixer, check-yaml, detect-private-key, black, flake8, and mypy - Install the hooks and run them against all existing files
- Create a test Python file with intentional linting errors and verify the hook blocks the commit
- Add a custom local hook that runs
pyteston staged test files - Push the
.pre-commit-config.yamlto a shared repository for your team
Bonus: Write a Python hook that checks for TODO and FIXME comments and prints a warning, but does not block the commit (exit 0).
11. Next Steps
Now that you can automate quality gates at every stage of the development process, you are ready to explore more advanced Git and GitHub workflows:
- Git Tags, Releases, and Semantic Versioning — How to tag releases, automate version bumps, and generate changelogs from conventional commits
- Open Source Contribution Workflows — Forking, pull request templates, CODEOWNERS, and collaboration at scale
- GitHub Projects and Issues Automation — Automating project management with GitHub Actions triggers
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment