Git hooks run scripts at key points in your workflow. Use them to catch problems before they become pull requests.

Hook Basics

Hooks live in .git/hooks/. Each hook is an executable script named after the event.

1
2
3
4
5
6
# List available hooks
ls .git/hooks/*.sample

# Make a hook active
cp .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

Common Hooks

HookWhenUse Case
pre-commitBefore commit createdLint, format, test
prepare-commit-msgAfter default messageAdd ticket numbers
commit-msgAfter message enteredValidate format
pre-pushBefore pushRun full tests
post-mergeAfter mergeInstall dependencies
post-checkoutAfter checkoutEnvironment setup

Pre-commit Hook

Basic Linting

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

echo "Running pre-commit checks..."

# Get staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)

# Python files
PYTHON_FILES=$(echo "$STAGED_FILES" | grep '\.py$')
if [ -n "$PYTHON_FILES" ]; then
    echo "Linting Python files..."
    ruff check $PYTHON_FILES || exit 1
    black --check $PYTHON_FILES || exit 1
fi

# JavaScript files
JS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(js|ts)$')
if [ -n "$JS_FILES" ]; then
    echo "Linting JavaScript files..."
    eslint $JS_FILES || exit 1
fi

echo "All checks passed!"

Prevent Debug Statements

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

# Check for debug statements
if git diff --cached | grep -E '(console\.log|debugger|import pdb|breakpoint\(\))'; then
    echo "ERROR: Debug statements found!"
    echo "Remove console.log, debugger, pdb, or breakpoint() before committing."
    exit 1
fi

Check for Secrets

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

# Patterns that might be secrets
PATTERNS=(
    'password\s*=\s*["\047][^"\047]+'
    'api_key\s*=\s*["\047][^"\047]+'
    'secret\s*=\s*["\047][^"\047]+'
    'AWS_SECRET_ACCESS_KEY'
    'PRIVATE KEY'
)

for pattern in "${PATTERNS[@]}"; do
    if git diff --cached | grep -iE "$pattern"; then
        echo "ERROR: Possible secret detected!"
        echo "Pattern: $pattern"
        exit 1
    fi
done

Commit Message Hook

Enforce Conventional Commits

 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 commit pattern
PATTERN="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,50}"

if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
    echo "ERROR: 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 memory leak in worker"
    echo ""
    exit 1
fi

Add Ticket Number

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/bin/bash
# .git/hooks/prepare-commit-msg

COMMIT_MSG_FILE=$1
BRANCH_NAME=$(git symbolic-ref --short HEAD)

# Extract ticket number from branch (e.g., feature/JIRA-123-description)
TICKET=$(echo "$BRANCH_NAME" | grep -oE '[A-Z]+-[0-9]+')

if [ -n "$TICKET" ]; then
    # Prepend ticket number if not already present
    if ! grep -q "$TICKET" "$COMMIT_MSG_FILE"; then
        sed -i.bak "1s/^/[$TICKET] /" "$COMMIT_MSG_FILE"
    fi
fi

Pre-push Hook

Run Tests Before Push

 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/pre-push

echo "Running tests before push..."

# Run test suite
npm test
if [ $? -ne 0 ]; then
    echo "ERROR: Tests failed! Push aborted."
    exit 1
fi

# Check coverage threshold
npm run coverage
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
    echo "ERROR: Coverage below 80%! Current: $COVERAGE%"
    exit 1
fi

echo "All checks passed. Pushing..."

Prevent Force Push to Main

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

PROTECTED_BRANCHES="main master"
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)

# Check if force pushing
while read local_ref local_sha remote_ref remote_sha; do
    if echo "$PROTECTED_BRANCHES" | grep -qw "$CURRENT_BRANCH"; then
        if [ "$remote_sha" != "0000000000000000000000000000000000000000" ]; then
            # Check if this is a force push
            if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
                echo "ERROR: Force push to $CURRENT_BRANCH is not allowed!"
                exit 1
            fi
        fi
    fi
done

Post-merge Hook

Auto-install Dependencies

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

CHANGED_FILES=$(git diff-tree -r --name-only ORIG_HEAD HEAD)

# Check if package.json changed
if echo "$CHANGED_FILES" | grep -q "package.json"; then
    echo "package.json changed, running npm install..."
    npm install
fi

# Check if requirements.txt changed
if echo "$CHANGED_FILES" | grep -q "requirements.txt"; then
    echo "requirements.txt changed, running pip install..."
    pip install -r requirements.txt
fi

# Check if migrations added
if echo "$CHANGED_FILES" | grep -q "migrations/"; then
    echo "New migrations detected!"
    echo "Run: python manage.py migrate"
fi

Using pre-commit Framework

The pre-commit framework manages hooks declaratively.

Installation

1
pip install pre-commit

Configuration

 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
34
35
# .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
        args: ['--maxkb=1000']
      - id: detect-private-key

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

  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.56.0
    hooks:
      - id: eslint
        files: \.[jt]sx?$
        types: [file]

  - repo: local
    hooks:
      - id: pytest
        name: pytest
        entry: pytest
        language: system
        pass_filenames: false
        always_run: true

Setup

1
2
3
4
5
6
7
8
# Install hooks
pre-commit install

# Run against all files
pre-commit run --all-files

# Update hook versions
pre-commit autoupdate

Husky for Node.js

1
2
npm install husky --save-dev
npx husky init
1
2
3
# .husky/pre-commit
npm run lint
npm test
1
2
3
4
5
6
7
8
// package.json
{
  "scripts": {
    "prepare": "husky",
    "lint": "eslint .",
    "test": "jest"
  }
}

Sharing Hooks with Team

Git doesn’t track .git/hooks/. Options:

1. pre-commit Framework

1
2
# Team members run once
pre-commit install

2. Custom Directory

1
2
3
4
5
# Store hooks in repo
mkdir .githooks

# Configure git to use them
git config core.hooksPath .githooks

3. npm/package.json

1
2
3
4
5
{
  "scripts": {
    "prepare": "git config core.hooksPath .githooks"
  }
}

Bypassing Hooks

1
2
3
4
5
# Skip pre-commit hook
git commit --no-verify -m "Emergency fix"

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

Use sparingly—hooks exist for a reason.

Debugging Hooks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Run hook manually
.git/hooks/pre-commit

# Add debugging
#!/bin/bash
set -x  # Print commands
# ... rest of hook

# Check exit codes
echo "Exit code: $?"

Git hooks shift quality checks left—catching issues before they spread. Start with a simple pre-commit lint check, then expand as your team matures.

The best hooks are fast (under 10 seconds) and focused. Slow hooks get bypassed; comprehensive checks belong in CI.