Code review catches problems. CI catches problems. But the fastest feedback loop? Catching problems before you even commit.
Git hooks run scripts at key points in your workflow. Use them to lint, test, and validate — automatically.
Where Hooks Live#
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 (chmod +x).
The Essential Hooks#
pre-commit: Validate Before Committing#
Runs before git commit completes. Exit non-zero to abort.
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
| #!/usr/bin/env bash
# .git/hooks/pre-commit
set -euo pipefail
echo "Running pre-commit checks..."
# Lint staged files only
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts)$' || true)
if [[ -n "$STAGED_FILES" ]]; then
echo "Linting JavaScript/TypeScript..."
echo "$STAGED_FILES" | xargs npx eslint --fix
git add $STAGED_FILES # Re-stage fixed files
fi
# Check for debug statements
if git diff --cached | grep -E '(console\.log|debugger|binding\.pry|import pdb)'; then
echo "❌ Debug statements detected. Remove before committing."
exit 1
fi
# Check for secrets
if git diff --cached | grep -iE '(api_key|password|secret).*=.*['\''"][^'\''"]+['\''"]'; then
echo "⚠️ Possible secret detected. Review before committing."
exit 1
fi
echo "✅ Pre-commit checks passed"
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| #!/usr/bin/env bash
# .git/hooks/commit-msg
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Conventional commits format
PATTERN="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}$"
if ! echo "$COMMIT_MSG" | head -1 | grep -qE "$PATTERN"; then
echo "❌ Invalid commit message format."
echo ""
echo "Expected: <type>(<scope>): <subject>"
echo "Types: feat, fix, docs, style, refactor, test, chore"
echo "Example: feat(auth): add password reset flow"
echo ""
echo "Your message: $COMMIT_MSG"
exit 1
fi
|
pre-push: Run Tests 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
| #!/usr/bin/env bash
# .git/hooks/pre-push
set -euo pipefail
BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Skip for certain branches
if [[ "$BRANCH" == "wip/"* ]]; then
echo "Skipping pre-push checks for WIP branch"
exit 0
fi
echo "Running tests before push..."
npm test
# Prevent pushing to main directly
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
echo "❌ Direct push to $BRANCH is not allowed. Use a pull request."
exit 1
fi
echo "✅ Pre-push checks passed"
|
prepare-commit-msg: Auto-fill Commit Messages#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| #!/usr/bin/env bash
# .git/hooks/prepare-commit-msg
COMMIT_MSG_FILE="$1"
COMMIT_SOURCE="$2"
# Only modify new commits (not merges, amends, etc.)
if [[ -z "$COMMIT_SOURCE" ]]; then
BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Extract ticket number from branch name
if [[ "$BRANCH" =~ ^([A-Z]+-[0-9]+) ]]; then
TICKET="${BASH_REMATCH[1]}"
# Prepend ticket to commit message
sed -i.bak "1s/^/[$TICKET] /" "$COMMIT_MSG_FILE"
fi
fi
|
Branch PROJ-123-add-login becomes commit [PROJ-123] your message.
Managing Hooks Across Teams#
Hooks live in .git/hooks/, which isn’t version-controlled. Solutions:
Option 1: Committed hooks directory#
1
2
3
4
5
6
7
8
9
| # Store hooks in repo
mkdir -p .githooks
cp .git/hooks/pre-commit .githooks/
# Configure git to use it
git config core.hooksPath .githooks
# Or in .gitconfig for all repos
git config --global core.hooksPath ~/.githooks
|
Option 2: Husky (Node.js projects)#
1
2
| npm install husky --save-dev
npx husky init
|
1
2
3
4
5
6
| // package.json
{
"scripts": {
"prepare": "husky"
}
}
|
1
2
3
| # .husky/pre-commit
npm run lint
npm run test
|
Option 3: pre-commit framework (Python)#
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-added-large-files
- repo: https://github.com/psf/black
rev: 24.1.0
hooks:
- id: black
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest
language: system
types: [python]
pass_filenames: false
|
1
2
| pip install pre-commit
pre-commit install
|
This is my preferred approach — declarative, version-controlled, and extensible.
Server-Side Hooks#
For shared repositories (GitLab, self-hosted Git), server-side hooks enforce rules for everyone:
pre-receive: Validate Incoming Pushes#
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
| #!/usr/bin/env bash
# Run on Git server
while read oldrev newrev refname; do
# Reject large files
LARGE_FILES=$(git rev-list --objects "$oldrev".."$newrev" | \
git cat-file --batch-check='%(objectname) %(objecttype) %(objectsize) %(rest)' | \
awk '$3 > 10485760 {print $4}')
if [[ -n "$LARGE_FILES" ]]; then
echo "❌ Files larger than 10MB detected:"
echo "$LARGE_FILES"
exit 1
fi
# Require signed commits
UNSIGNED=$(git rev-list --no-walk "$newrev" 2>/dev/null | \
while read commit; do
if ! git verify-commit "$commit" 2>/dev/null; then
echo "$commit"
fi
done)
if [[ -n "$UNSIGNED" ]]; then
echo "❌ Unsigned commits detected. Please sign your commits."
exit 1
fi
done
|
update: Per-Branch Rules#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| #!/usr/bin/env bash
# Arguments: refname oldrev newrev
REFNAME="$1"
OLDREV="$2"
NEWREV="$3"
# Protect release branches
if [[ "$REFNAME" == "refs/heads/release/"* ]]; then
# Only allow merge commits
if ! git log --merges "$OLDREV".."$NEWREV" | grep -q .; then
echo "❌ Release branches only accept merge commits"
exit 1
fi
fi
|
Practical Patterns#
Speed Up with Staged-Only Checks#
Don’t lint the entire codebase — just what’s changing:
1
2
3
4
5
6
| # Get staged files of specific types
staged_py=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$' || true)
staged_js=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts)$' || true)
[[ -n "$staged_py" ]] && echo "$staged_py" | xargs black --check
[[ -n "$staged_js" ]] && echo "$staged_js" | xargs eslint
|
Bypass When Needed#
1
2
3
4
5
| # Skip hooks for this commit
git commit --no-verify -m "WIP: broken but saving state"
# Skip hooks for push
git push --no-verify
|
Use sparingly. If you’re bypassing constantly, fix your hooks.
Timeout Long-Running Hooks#
1
2
3
4
5
6
7
| #!/usr/bin/env bash
# Wrap tests with timeout
timeout 60 npm test || {
echo "⚠️ Tests timed out or failed"
exit 1
}
|
What to Check Where#
| Hook | Good For | Avoid |
|---|
| pre-commit | Linting, formatting, quick checks | Full test suite, slow operations |
| commit-msg | Message format validation | Nothing else |
| pre-push | Test suite, integration tests | Anything taking > 2 minutes |
| pre-receive | Server-side enforcement | Duplicating client-side checks |
Keep pre-commit fast (< 5 seconds). Move slow checks to pre-push or CI.
The Payoff#
Last week, a pre-commit hook caught an AWS access key I accidentally staged. One grep pattern, one blocked commit, one avoided incident.
Git hooks won’t replace CI. But they’ll catch the obvious stuff before you push, saving review cycles and keeping your commit history clean.
Set them up once, benefit forever.
Computing Arts is automation for practitioners. More at computingarts.com.