Git hooks are scripts that run automatically at specific points in the Git workflow. They’re perfect for enforcing standards, running tests, and automating tedious tasks—all before code leaves your machine.

Hook Locations

Hooks live in .git/hooks/. Git creates sample files on git init:

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:

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

Common Hooks

HookWhen it runsUse case
pre-commitBefore commit is createdLint, format, run quick tests
commit-msgAfter message is enteredValidate commit message format
pre-pushBefore push to remoteRun tests, check branch
post-commitAfter commit is createdNotifications, update docs
post-mergeAfter merge completesInstall dependencies
post-checkoutAfter checkout/switchInstall dependencies

Pre-commit: Lint and Format

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

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

# Run linter on staged JS files
JS_FILES=$(echo "$STAGED" | grep '\.js$')
if [ -n "$JS_FILES" ]; then
    echo "Linting JavaScript..."
    npx eslint $JS_FILES
    if [ $? -ne 0 ]; then
        echo "ESLint failed. Fix errors before committing."
        exit 1
    fi
fi

# Run formatter and re-stage
PYTHON_FILES=$(echo "$STAGED" | grep '\.py$')
if [ -n "$PYTHON_FILES" ]; then
    echo "Formatting Python..."
    black $PYTHON_FILES
    git add $PYTHON_FILES
fi

exit 0

Commit-msg: Enforce Format

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

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

# Require conventional commit format
PATTERN="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,50}"

if ! echo "$MSG" | grep -qE "$PATTERN"; then
    echo "Invalid commit message format."
    echo "Expected: type(scope): description"
    echo "Example: feat(auth): add password reset"
    exit 1
fi

Pre-push: Run Tests

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

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

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

# Prevent push to main
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" = "main" ]; then
    echo "Direct push to main is not allowed."
    echo "Create a pull request instead."
    exit 1
fi

exit 0

Post-merge: Install Dependencies

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

# Check if package.json changed
CHANGED=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)

if echo "$CHANGED" | grep -q "package.json"; then
    echo "package.json changed. Running npm install..."
    npm install
fi

if echo "$CHANGED" | grep -q "requirements.txt"; then
    echo "requirements.txt changed. Running pip install..."
    pip install -r requirements.txt
fi

Sharing Hooks with Your Team

.git/hooks/ isn’t tracked by Git. Solutions:

Option 1: Git config

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

# Tell Git to use them
git config core.hooksPath .githooks

Option 2: Symlink on setup

1
2
# In your setup script
ln -sf ../../.githooks/pre-commit .git/hooks/pre-commit

Option 3: Use a hook manager

Husky (Node.js):

1
2
3
4
5
6
7
8
9
// package.json
{
  "husky": {
    "hooks": {
      "pre-commit": "npm run lint",
      "pre-push": "npm test"
    }
  }
}

pre-commit (Python):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 23.1.0
    hooks:
      - id: black
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.4.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
1
pre-commit install  # Sets up hooks

Bypassing Hooks

When you need to skip (use sparingly):

1
2
3
4
5
# Skip pre-commit and commit-msg
git commit --no-verify -m "WIP: quick fix"

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

Practical Examples

Check for debug statements:

1
2
3
4
5
6
7
#!/bin/bash
# Prevent committing console.log, debugger, etc.

if git diff --cached | grep -E '(console\.log|debugger|binding\.pry)'; then
    echo "Found debug statements. Remove before committing."
    exit 1
fi

Check for large files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/bin/bash
# Prevent committing files over 5MB

MAX_SIZE=5242880  # 5MB in bytes

for file in $(git diff --cached --name-only); do
    if [ -f "$file" ]; then
        SIZE=$(wc -c < "$file")
        if [ $SIZE -gt $MAX_SIZE ]; then
            echo "File $file is too large ($SIZE bytes)"
            exit 1
        fi
    fi
done

Auto-update version:

1
2
3
4
5
6
7
#!/bin/bash
# post-commit: Update version file

VERSION=$(git describe --tags --always)
echo "$VERSION" > VERSION
git add VERSION
git commit --amend --no-edit --no-verify

Notify on push:

1
2
3
4
5
6
#!/bin/bash
# post-push (via wrapper)

BRANCH=$(git rev-parse --abbrev-ref HEAD)
curl -X POST "https://slack.webhook.url" \
    -d "{\"text\": \"Pushed to $BRANCH\"}"

Server-side Hooks

For shared repos (bare repositories):

  • pre-receive: Before any refs are updated
  • update: Per-ref, before it’s updated
  • post-receive: After all refs updated
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/bin/bash
# hooks/update - prevent force push to main

REF=$1
OLD=$2
NEW=$3

if [ "$REF" = "refs/heads/main" ]; then
    if ! git merge-base --is-ancestor $OLD $NEW; then
        echo "Force push to main is not allowed."
        exit 1
    fi
fi

Debugging Hooks

1
2
3
4
5
6
7
8
# Make hook verbose
set -x  # At top of script

# Test hook manually
.git/hooks/pre-commit

# Check exit code
echo $?

Hook Best Practices

  1. Keep hooks fast — Slow pre-commit hooks kill productivity
  2. Make hooks skippable — Document when --no-verify is okay
  3. Fail with helpful messages — Tell users how to fix issues
  4. Share hooks via repo — Use .githooks/ or a manager
  5. Test locally, enforce in CI — Hooks are suggestions; CI is the gatekeeper

Git hooks are your first line of defense against bad commits. Use them to catch issues early, before they become PR comments or CI failures.