Code review catches problems. CI catches problems. But the fastest feedback loop? Catching problems before you even commit.

Git hooks run scripts at key points in your workflow. Use them to lint, test, and validate — automatically.

Where Hooks Live

1
2
3
4
5
6
ls .git/hooks/
# applypatch-msg.sample  pre-commit.sample
# commit-msg.sample      pre-push.sample
# post-update.sample     pre-rebase.sample
# pre-applypatch.sample  prepare-commit-msg.sample
# pre-merge-commit.sample  update.sample

Remove .sample to activate. Hooks must be executable (chmod +x).

The Essential Hooks

pre-commit: Validate Before Committing

Runs before git commit completes. Exit non-zero to abort.

 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
#!/usr/bin/env bash
# .git/hooks/pre-commit

set -euo pipefail

echo "Running pre-commit checks..."

# Lint staged files only
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts)$' || true)
if [[ -n "$STAGED_FILES" ]]; then
    echo "Linting JavaScript/TypeScript..."
    echo "$STAGED_FILES" | xargs npx eslint --fix
    git add $STAGED_FILES  # Re-stage fixed files
fi

# Check for debug statements
if git diff --cached | grep -E '(console\.log|debugger|binding\.pry|import pdb)'; then
    echo "❌ Debug statements detected. Remove before committing."
    exit 1
fi

# Check for secrets
if git diff --cached | grep -iE '(api_key|password|secret).*=.*['\''"][^'\''"]+['\''"]'; then
    echo "⚠️  Possible secret detected. Review before committing."
    exit 1
fi

echo "✅ Pre-commit checks passed"

commit-msg: Enforce Commit Message Format

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

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

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

if ! echo "$COMMIT_MSG" | head -1 | grep -qE "$PATTERN"; then
    echo "❌ Invalid commit message format."
    echo ""
    echo "Expected: <type>(<scope>): <subject>"
    echo "Types: feat, fix, docs, style, refactor, test, chore"
    echo "Example: feat(auth): add password reset flow"
    echo ""
    echo "Your message: $COMMIT_MSG"
    exit 1
fi

pre-push: Run Tests 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
#!/usr/bin/env bash
# .git/hooks/pre-push

set -euo pipefail

BRANCH=$(git rev-parse --abbrev-ref HEAD)

# Skip for certain branches
if [[ "$BRANCH" == "wip/"* ]]; then
    echo "Skipping pre-push checks for WIP branch"
    exit 0
fi

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

# Prevent pushing to main directly
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
    echo "❌ Direct push to $BRANCH is not allowed. Use a pull request."
    exit 1
fi

echo "✅ Pre-push checks passed"

prepare-commit-msg: Auto-fill Commit Messages

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

COMMIT_MSG_FILE="$1"
COMMIT_SOURCE="$2"

# Only modify new commits (not merges, amends, etc.)
if [[ -z "$COMMIT_SOURCE" ]]; then
    BRANCH=$(git rev-parse --abbrev-ref HEAD)
    
    # Extract ticket number from branch name
    if [[ "$BRANCH" =~ ^([A-Z]+-[0-9]+) ]]; then
        TICKET="${BASH_REMATCH[1]}"
        # Prepend ticket to commit message
        sed -i.bak "1s/^/[$TICKET] /" "$COMMIT_MSG_FILE"
    fi
fi

Branch PROJ-123-add-login becomes commit [PROJ-123] your message.

Managing Hooks Across Teams

Hooks live in .git/hooks/, which isn’t version-controlled. Solutions:

Option 1: Committed hooks directory

1
2
3
4
5
6
7
8
9
# Store hooks in repo
mkdir -p .githooks
cp .git/hooks/pre-commit .githooks/

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

# Or in .gitconfig for all repos
git config --global core.hooksPath ~/.githooks

Option 2: Husky (Node.js projects)

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

Option 3: pre-commit framework (Python)

 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-added-large-files

  - repo: https://github.com/psf/black
    rev: 24.1.0
    hooks:
      - id: black

  - repo: local
    hooks:
      - id: pytest
        name: pytest
        entry: pytest
        language: system
        types: [python]
        pass_filenames: false
1
2
pip install pre-commit
pre-commit install

This is my preferred approach — declarative, version-controlled, and extensible.

Server-Side Hooks

For shared repositories (GitLab, self-hosted Git), server-side hooks enforce rules for everyone:

pre-receive: Validate Incoming Pushes

 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
#!/usr/bin/env bash
# Run on Git server

while read oldrev newrev refname; do
    # Reject large files
    LARGE_FILES=$(git rev-list --objects "$oldrev".."$newrev" | \
        git cat-file --batch-check='%(objectname) %(objecttype) %(objectsize) %(rest)' | \
        awk '$3 > 10485760 {print $4}')
    
    if [[ -n "$LARGE_FILES" ]]; then
        echo "❌ Files larger than 10MB detected:"
        echo "$LARGE_FILES"
        exit 1
    fi
    
    # Require signed commits
    UNSIGNED=$(git rev-list --no-walk "$newrev" 2>/dev/null | \
        while read commit; do
            if ! git verify-commit "$commit" 2>/dev/null; then
                echo "$commit"
            fi
        done)
    
    if [[ -n "$UNSIGNED" ]]; then
        echo "❌ Unsigned commits detected. Please sign your commits."
        exit 1
    fi
done

update: Per-Branch Rules

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#!/usr/bin/env bash
# Arguments: refname oldrev newrev

REFNAME="$1"
OLDREV="$2"
NEWREV="$3"

# Protect release branches
if [[ "$REFNAME" == "refs/heads/release/"* ]]; then
    # Only allow merge commits
    if ! git log --merges "$OLDREV".."$NEWREV" | grep -q .; then
        echo "❌ Release branches only accept merge commits"
        exit 1
    fi
fi

Practical Patterns

Speed Up with Staged-Only Checks

Don’t lint the entire codebase — just what’s changing:

1
2
3
4
5
6
# Get staged files of specific types
staged_py=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$' || true)
staged_js=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts)$' || true)

[[ -n "$staged_py" ]] && echo "$staged_py" | xargs black --check
[[ -n "$staged_js" ]] && echo "$staged_js" | xargs eslint

Bypass When Needed

1
2
3
4
5
# Skip hooks for this commit
git commit --no-verify -m "WIP: broken but saving state"

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

Use sparingly. If you’re bypassing constantly, fix your hooks.

Timeout Long-Running Hooks

1
2
3
4
5
6
7
#!/usr/bin/env bash
# Wrap tests with timeout

timeout 60 npm test || {
    echo "⚠️  Tests timed out or failed"
    exit 1
}

What to Check Where

HookGood ForAvoid
pre-commitLinting, formatting, quick checksFull test suite, slow operations
commit-msgMessage format validationNothing else
pre-pushTest suite, integration testsAnything taking > 2 minutes
pre-receiveServer-side enforcementDuplicating client-side checks

Keep pre-commit fast (< 5 seconds). Move slow checks to pre-push or CI.

The Payoff

Last week, a pre-commit hook caught an AWS access key I accidentally staged. One grep pattern, one blocked commit, one avoided incident.

Git hooks won’t replace CI. But they’ll catch the obvious stuff before you push, saving review cycles and keeping your commit history clean.

Set them up once, benefit forever.


Computing Arts is automation for practitioners. More at computingarts.com.