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#
| Hook | When | Use Case |
|---|
pre-commit | Before commit created | Lint, format, test |
prepare-commit-msg | After default message | Add ticket numbers |
commit-msg | After message entered | Validate format |
pre-push | Before push | Run full tests |
post-merge | After merge | Install dependencies |
post-checkout | After checkout | Environment 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#
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.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.