Git hooks are scripts that run at specific points in the Git workflow. They’re the first line of defense against bad commits — catching issues before they pollute your repository history.

Set them up once, forget about them, and let automation enforce quality.

Hook Types

Client-side hooks (your machine):

  • pre-commit — Before commit is created
  • prepare-commit-msg — Before commit message editor opens
  • commit-msg — After commit message is entered
  • pre-push — Before push to remote

Server-side hooks (repository server):

  • pre-receive — Before accepting push
  • post-receive — After push is accepted

Basic Setup

Hooks live in .git/hooks/. Create an executable script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# .git/hooks/pre-commit
#!/bin/bash
echo "Running pre-commit checks..."

# Run linter
npm run lint
if [ $? -ne 0 ]; then
    echo "❌ Linting failed. Fix errors before committing."
    exit 1
fi

echo "✅ All checks passed"
1
chmod +x .git/hooks/pre-commit

Share Hooks with the Team

.git/hooks/ isn’t tracked by Git. Use a hooks directory in your repo:

1
2
3
4
5
# Create hooks directory
mkdir -p .githooks

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

Or use a tool like Husky (Node.js) or pre-commit (Python).

Husky (Node.js Projects)

1
2
npm install --save-dev husky
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"
  }
}

Pre-commit Framework (Python)

1
pip install pre-commit
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# .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/psf/black
    rev: 24.1.0
    hooks:
      - id: black

  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
1
pre-commit install

Common Pre-Commit Checks

Linting

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/bin/bash
# Run on staged files only
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts)$')

if [ -n "$FILES" ]; then
    npx eslint $FILES
    if [ $? -ne 0 ]; then
        exit 1
    fi
fi

Formatting

1
2
3
4
5
6
7
8
#!/bin/bash
# Auto-format and re-stage
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts)$')

if [ -n "$FILES" ]; then
    npx prettier --write $FILES
    git add $FILES
fi

Type Checking

1
2
3
4
5
6
#!/bin/bash
npx tsc --noEmit
if [ $? -ne 0 ]; then
    echo "❌ TypeScript errors found"
    exit 1
fi

Tests

1
2
3
#!/bin/bash
# Run tests related to changed files
npm test -- --findRelatedTests $(git diff --cached --name-only)

Secrets Detection

1
2
3
4
5
6
#!/bin/bash
# Check for potential secrets
if git diff --cached | grep -iE '(password|secret|api_key|token)\s*[=:]\s*["\x27][^"\x27]+["\x27]'; then
    echo "❌ Potential secret detected in commit"
    exit 1
fi

Or use dedicated tools:

1
2
3
4
5
# .pre-commit-config.yaml
- repo: https://github.com/Yelp/detect-secrets
  rev: v1.4.0
  hooks:
    - id: detect-secrets

Commit Message Validation

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

commit_msg=$(cat "$1")

# Conventional commits format
if ! echo "$commit_msg" | grep -qE "^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,50}"; then
    echo "❌ Invalid commit message format"
    echo "Expected: type(scope): description"
    echo "Example: feat(auth): add login endpoint"
    exit 1
fi

With commitlint:

1
npm install --save-dev @commitlint/{cli,config-conventional}
1
2
3
4
// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional']
};
1
2
# .husky/commit-msg
npx commitlint --edit $1

Pre-Push Hooks

Heavier checks that shouldn’t run on every commit:

 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 pre-push checks..."

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

# Build check
npm run build
if [ $? -ne 0 ]; then
    echo "❌ Build failed. Push aborted."
    exit 1
fi

echo "✅ Pre-push checks passed"

Lint-Staged

Only check files that are being committed:

1
npm install --save-dev lint-staged
1
2
3
4
5
6
7
8
// package.json
{
  "lint-staged": {
    "*.{js,ts}": ["eslint --fix", "prettier --write"],
    "*.{json,md}": ["prettier --write"],
    "*.py": ["black", "flake8"]
  }
}
1
2
# .husky/pre-commit
npx lint-staged

Bypassing Hooks

When you need to skip hooks (use sparingly):

1
2
3
4
5
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "emergency fix"

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

Server-Side Hooks

Enforce rules on the server (can’t be bypassed):

 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
# hooks/pre-receive (on Git server)

while read oldrev newrev refname; do
    # Prevent force push to main
    if [ "$refname" = "refs/heads/main" ]; then
        if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then
            forced=$(git rev-list $newrev..$oldrev)
            if [ -n "$forced" ]; then
                echo "❌ Force push to main is not allowed"
                exit 1
            fi
        fi
    fi
    
    # Check commit message format
    for commit in $(git rev-list $oldrev..$newrev); do
        msg=$(git log --format=%s -n 1 $commit)
        if ! echo "$msg" | grep -qE "^(feat|fix|docs|style|refactor|test|chore)"; then
            echo "❌ Invalid commit message: $msg"
            exit 1
        fi
    done
done

GitHub Actions as Hooks

For checks that need CI environment:

 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
# .github/workflows/pr-checks.yml
name: PR Checks

on: [pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build

Performance Tips

Keep pre-commit hooks fast (< 5 seconds):

1
2
3
4
5
6
7
8
# Only check changed files
git diff --cached --name-only --diff-filter=ACM

# Run checks in parallel
npm run lint & npm run typecheck & wait

# Cache expensive operations
# (tools like eslint and prettier cache by default)

Move slow checks to pre-push or CI.


Git hooks shift quality enforcement left — problems are caught before they’re committed, not in code review or CI. The few seconds of pre-commit checks save minutes of back-and-forth later.

Start with linting and formatting. Add type checking and tests. Use lint-staged to keep it fast. The repository that enforces standards automatically is the repository that maintains them.