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:

  1. pre-commit — before the commit object is created. Return non-zero to abort.
  2. prepare-commit-msg — after the default message is generated, before the editor opens.
  3. commit-msg — after the user writes the message. Great for validation.
  4. post-commit — after the commit is created. Used for notifications.

For git push, the relevant hooks are:

  1. pre-push — before any data is sent. Ideal for running test suites.
  2. 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."
Anatomy of a Git hook script

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
Creating a pre-commit hook that lints staged files

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
Enforcing conventional commit format with a commit-msg hook

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
pre-push hook that blocks large files and runs tests

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
Pre-commit hook that scans for secrets before every 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
Setting up the pre-commit framework with linting, formatting, and security hooks

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)
A Python pre-commit hook that validates database migration naming conventions

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-verify or -n skips the pre-commit and commit-msg hooks
  • git push --no-verify skips the pre-push hook
  • GIT_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
Setting up a global Git template directory for 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/null for 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-verify to bypass temporarily.
  • Error: "permission denied: .git/hooks/pre-commit" — Your hook script is not executable. Run chmod +x .git/hooks/pre-commit to 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 python3 vs #!/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-commit and run pre-commit clean to 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:

  1. Create a new Git repository and initialize it
  2. Add a .pre-commit-config.yaml with the following hooks: trailing-whitespace, end-of-file-fixer, check-yaml, detect-private-key, black, flake8, and mypy
  3. Install the hooks and run them against all existing files
  4. Create a test Python file with intentional linting errors and verify the hook blocks the commit
  5. Add a custom local hook that runs pytest on staged test files
  6. Push the .pre-commit-config.yaml to 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

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen July 22, 2026

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment