Git hooks are scripts that run automatically at specific points in your Git workflow. Use them to catch problems before they become PR comments. Here’s how to set them up effectively.

Hook Basics

Git hooks live in .git/hooks/. They’re executable scripts that run at specific events:

1
2
3
4
5
6
.git/hooks/
├── pre-commit      # Before commit is created
├── commit-msg      # After commit message is entered
├── pre-push        # Before push to remote
├── post-merge      # After merge completes
└── ...

To enable a hook, create an executable script with the hook’s name:

1
2
3
4
5
6
#!/bin/bash
# .git/hooks/pre-commit

echo "Running pre-commit checks..."
npm run lint || exit 1
npm run test || exit 1

Make it executable:

1
chmod +x .git/hooks/pre-commit

Pre-Commit: The Gatekeeper

The most useful hook. Runs before every commit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/bin/bash
# .git/hooks/pre-commit

set -e  # Exit on any error

echo "🔍 Running pre-commit checks..."

# Only check staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)

# Lint staged Python files
PYTHON_FILES=$(echo "$STAGED_FILES" | grep '\.py$' || true)
if [ -n "$PYTHON_FILES" ]; then
    echo "Linting Python files..."
    echo "$PYTHON_FILES" | xargs ruff check
    echo "$PYTHON_FILES" | xargs ruff format --check
fi

# Lint staged JavaScript files
JS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(js|ts|jsx|tsx)$' || true)
if [ -n "$JS_FILES" ]; then
    echo "Linting JavaScript/TypeScript files..."
    echo "$JS_FILES" | xargs npx eslint
fi

# Check for secrets
echo "Checking for secrets..."
git diff --cached --diff-filter=ACMR | grep -E '(password|secret|api_key|token)\s*=' && {
    echo "❌ Possible secrets detected in staged changes"
    exit 1
}

echo "✅ Pre-commit checks passed"

Commit-Msg: Enforce Conventions

Validate commit message format:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
# .git/hooks/commit-msg

COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")

# Conventional Commits format: type(scope): description
PATTERN="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}$"

if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
    echo "❌ Invalid commit message format"
    echo ""
    echo "Expected: type(scope): description"
    echo "Types: feat, fix, docs, style, refactor, test, chore"
    echo ""
    echo "Examples:"
    echo "  feat(auth): add OAuth2 support"
    echo "  fix: resolve null pointer exception"
    echo "  docs(readme): update installation steps"
    exit 1
fi

Pre-Push: Last Line of Defense

Run comprehensive checks before pushing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
# .git/hooks/pre-push

set -e

echo "🚀 Running pre-push checks..."

# Run full test suite
echo "Running tests..."
npm test

# Type check
echo "Type checking..."
npx tsc --noEmit

# Check for uncommitted changes
if [ -n "$(git status --porcelain)" ]; then
    echo "❌ Uncommitted changes detected"
    git status --short
    exit 1
fi

echo "✅ Pre-push checks passed"

Sharing Hooks with Your Team

The .git/hooks directory isn’t tracked. Share hooks through your repo:

Option 1: Custom hooks directory

1
2
3
4
5
# In your repo
mkdir -p .githooks

# Configure Git to use it
git config core.hooksPath .githooks

Add to your README:

1
2
## Setup
Run `git config core.hooksPath .githooks` to enable Git hooks.

Option 2: Install script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash
# scripts/install-hooks.sh

HOOK_DIR=".git/hooks"
SOURCE_DIR="scripts/hooks"

for hook in "$SOURCE_DIR"/*; do
    hook_name=$(basename "$hook")
    cp "$hook" "$HOOK_DIR/$hook_name"
    chmod +x "$HOOK_DIR/$hook_name"
    echo "Installed $hook_name"
done

Using Pre-Commit Framework

For complex setups, use the pre-commit framework:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# .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-json
      - id: check-added-large-files

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.1.6
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.55.0
    hooks:
      - id: eslint
        files: \.(js|jsx|ts|tsx)$

Install:

1
2
pip install pre-commit
pre-commit install

Now pre-commit runs automatically on every commit.

Husky for JavaScript Projects

For Node.js projects, Husky is the standard:

1
2
npm install husky --save-dev
npx husky init

Create hooks:

1
2
3
# .husky/pre-commit
npm run lint
npm run test
1
2
# .husky/commit-msg
npx commitlint --edit $1

Add to package.json:

1
2
3
4
5
{
  "scripts": {
    "prepare": "husky"
  }
}

Bypassing Hooks (When Necessary)

Sometimes you need to skip hooks:

1
2
3
4
5
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "WIP: work in progress"

# Skip pre-push hooks
git push --no-verify

Use sparingly. If you’re bypassing hooks frequently, fix the hooks.

Performance Tips

Hooks should be fast. Slow hooks get bypassed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/bin/bash
# Only check changed files, not entire codebase

STAGED=$(git diff --cached --name-only --diff-filter=ACMR)

# Bad: lint everything
npm run lint  # 30 seconds

# Good: lint only staged files
echo "$STAGED" | grep '\.js$' | xargs npx eslint  # 2 seconds

For expensive checks, use pre-push instead of pre-commit.

Real-World Example

A complete pre-commit hook for a Python project:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/bin/bash
set -e

STAGED=$(git diff --cached --name-only --diff-filter=ACMR)
PYTHON_FILES=$(echo "$STAGED" | grep '\.py$' || true)

if [ -z "$PYTHON_FILES" ]; then
    echo "No Python files staged, skipping checks"
    exit 0
fi

echo "📝 Staged Python files:"
echo "$PYTHON_FILES" | sed 's/^/  /'

echo ""
echo "🔍 Running ruff..."
echo "$PYTHON_FILES" | xargs ruff check --fix
echo "$PYTHON_FILES" | xargs ruff format

echo ""
echo "🔍 Running mypy..."
echo "$PYTHON_FILES" | xargs mypy --ignore-missing-imports

echo ""
echo "🧪 Running affected tests..."
pytest --co -q 2>/dev/null | grep -E "test.*\.py" | head -20 | xargs -r pytest -x

echo ""
echo "✅ All checks passed!"

Quick Reference

HookWhen It RunsUse For
pre-commitBefore commit createdLinting, formatting, secrets
commit-msgAfter message enteredCommit message format
pre-pushBefore pushFull tests, type checking
post-mergeAfter mergeDependency updates
post-checkoutAfter checkoutEnvironment setup

Git hooks turn “remember to lint” into “can’t commit without linting.” Automate what you can, and save code review time for actual code review.