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#
| Hook | When it runs | Use case |
|---|
pre-commit | Before commit is created | Lint, format, run quick tests |
commit-msg | After message is entered | Validate commit message format |
pre-push | Before push to remote | Run tests, check branch |
post-commit | After commit is created | Notifications, update docs |
post-merge | After merge completes | Install dependencies |
post-checkout | After checkout/switch | Install dependencies |
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
|
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 updatedupdate: Per-ref, before it’s updatedpost-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#
- Keep hooks fast — Slow pre-commit hooks kill productivity
- Make hooks skippable — Document when
--no-verify is okay - Fail with helpful messages — Tell users how to fix issues
- Share hooks via repo — Use
.githooks/ or a manager - 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.