Skip to content

Git Hooks and Automation

Git hooks are scripts that run automatically at specific points in the Git workflow - before a commit, after a merge, before a push. They let you enforce coding standards, run tests, validate commit messages, and automate repetitive tasks. This guide covers every hook, how to write them, hook management frameworks, and Git's built-in tools for debugging and forensic investigation.


How Hooks Work

Hooks are executable scripts stored in .git/hooks/. When Git reaches a trigger point (like committing), it checks for a hook with the corresponding name and runs it if found.

  • Hooks must be executable (chmod +x .git/hooks/pre-commit)
  • They can be written in any language (bash, Python, Ruby, Node.js - as long as the shebang line is correct)
  • Client-side hooks run on your machine. They're not pushed or shared through the repository (.git/hooks/ is local).
  • Server-side hooks run on the remote when receiving pushes.
  • Hooks that exit with non-zero status abort the operation they guard.

Hooks are local

Client-side hooks live in .git/hooks/, which isn't tracked by Git. You can't enforce them through the repository alone. That's why hook frameworks (covered below) exist - they let you commit hook definitions that teammates install locally.


Client-Side Hooks

Pre-Commit Hooks

Hook Runs Purpose
pre-commit Before commit message editor opens Validate the code being committed
prepare-commit-msg After default message is created, before editor opens Modify the commit message template
commit-msg After you write the message, before commit is created Validate the commit message format
post-commit After commit is created Notifications, logging

pre-commit

The most commonly used hook. It runs before the commit message editor opens. If it exits non-zero, the commit is aborted. Use it for linting, formatting checks, and preventing debug code from being committed.

#!/bin/bash
# .git/hooks/pre-commit - Check for debug statements

# Check staged Python files for debug prints
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')

if [ -n "$FILES" ]; then
    if grep -n 'import pdb\|breakpoint()\|print(' $FILES; then
        echo "ERROR: Debug statements found in staged files."
        echo "Remove them before committing."
        exit 1
    fi
fi

exit 0

commit-msg

Receives the commit message file path as its argument. Use it to enforce message conventions:

#!/bin/bash
# .git/hooks/commit-msg - Enforce Conventional Commits format

MSG_FILE=$1
MSG=$(cat "$MSG_FILE")

# Check for conventional commit prefix
if ! echo "$MSG" | grep -qE '^(feat|fix|docs|refactor|test|chore|style|perf|ci|build|revert)(\(.+\))?: .+'; then
    echo "ERROR: Commit message must follow Conventional Commits format:"
    echo "  feat: add new feature"
    echo "  fix(auth): resolve login timeout"
    echo "  docs: update API guide"
    echo ""
    echo "Your message: $MSG"
    exit 1
fi

exit 0

Other Client-Side Hooks

Hook Runs Purpose
pre-rebase Before rebase starts Prevent rebasing certain branches
pre-push Before push transmits data Run tests before pushing
post-checkout After git checkout/git switch Set up environment, update dependencies
post-merge After a successful merge Install dependencies, rebuild
pre-auto-gc Before automatic garbage collection Notify or prevent GC

The pre-push hook is particularly useful for running a quick test suite before pushing:

#!/bin/bash
# .git/hooks/pre-push - Run tests before push

echo "Running tests before push..."
npm test

if [ $? -ne 0 ]; then
    echo "Tests failed. Push aborted."
    exit 1
fi

exit 0

Server-Side Hooks

Server-side hooks run on the remote repository when receiving pushes. They're managed by the server administrator, not individual developers.

Hook Runs Purpose
pre-receive Before any refs are updated Global policy enforcement
update Once per branch being updated Per-branch policy enforcement
post-receive After all refs are updated Notifications, CI triggers, deploys

pre-receive is the enforcement point for server-side rules. If it exits non-zero, the entire push is rejected.


Hook Frameworks

Since hooks aren't committed to the repository, teams need a way to share and enforce them. Hook frameworks solve this.

pre-commit (Python)

pre-commit is a framework that manages hook installation from a shared config file:

# Install
pip install pre-commit

# Create .pre-commit-config.yaml in your repo
# Install hooks based on the config
pre-commit install
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.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/astral-sh/ruff-pre-commit
    rev: v0.3.0
    hooks:
      - id: ruff
        args: ['--fix']
      - id: ruff-format

Husky (Node.js)

Husky integrates with npm/yarn projects:

npx husky init
echo "npm test" > .husky/pre-commit
echo "npx commitlint --edit \$1" > .husky/commit-msg

Lefthook

Lefthook is a fast, polyglot hook manager:

# lefthook.yml
pre-commit:
  parallel: true
  commands:
    lint:
      glob: "*.{js,ts}"
      run: npx eslint {staged_files}
    format:
      glob: "*.py"
      run: ruff format --check {staged_files}

Git Bisect: Binary Search for Bugs

git bisect performs a binary search through commit history to find which commit introduced a bug. Instead of checking every commit, it cuts the search space in half each step.

Manual Bisect

# Start bisecting
git bisect start

# Mark the current commit as bad (has the bug)
git bisect bad

# Mark an older commit as good (doesn't have the bug)
git bisect good v1.0

# Git checks out a commit halfway between good and bad
# Test it, then mark:
git bisect good    # if this commit doesn't have the bug
git bisect bad     # if this commit has the bug

# Git narrows the range and checks out the next midpoint
# Repeat until the first bad commit is found

# When done
git bisect reset

Automated Bisect

If you have a script that returns 0 for good and non-zero for bad:

git bisect start HEAD v1.0
git bisect run npm test
# or
git bisect run python -m pytest tests/test_auth.py

Git runs the script at each step automatically and reports the first bad commit.


Git Blame and Forensic Investigation

git blame

git blame annotates each line of a file with the commit that last modified it:

# Blame a file
git blame src/auth.py

# Ignore whitespace changes
git blame -w src/auth.py

# Show original author even after move/copy
git blame -C src/auth.py

# Blame a specific range of lines
git blame -L 10,20 src/auth.py

# Show blame at a specific commit (before a refactor)
git blame v1.0 -- src/auth.py

Code Search with git log

# Find commits where a string was added/removed (pickaxe)
git log -S "authenticate" --oneline

# Find commits where a regex was added/removed in the diff
git log -G "def authenticate\(" --oneline

# Search with patch output to see the actual changes
git log -S "authenticate" -p

# Combine with file path
git log -S "authenticate" -- src/auth.py

Exercises


Further Reading


Previous: GitHub, GitLab, and Bitbucket | Next: Git Security | Back to Index

Comments