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.
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#
| Hook | When It Runs | Use For |
|---|
| pre-commit | Before commit created | Linting, formatting, secrets |
| commit-msg | After message entered | Commit message format |
| pre-push | Before push | Full tests, type checking |
| post-merge | After merge | Dependency updates |
| post-checkout | After checkout | Environment 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.