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 createdprepare-commit-msg — Before commit message editor openscommit-msg — After commit message is enteredpre-push — Before push to remote
Server-side hooks (repository server):
pre-receive — Before accepting pushpost-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
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
|
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
|
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
|
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.