Overview
The earlier code quality issues are caught, the cheaper they are to fix. Catching issues at commit time is faster than at CI time, and catching them at CI time is faster than fixing them after they cause production incidents. Git Hooks provide the ability to automatically run checks at critical points — commit and push — including code formatting, linting, commit message validation, and test execution. This article covers everything from Git Hooks fundamentals to toolchain practices, building a code quality automation system from local to CI.
References: Git Hooks Official Documentation, pre-commit Official Site
I. The Git Hooks System
1.1 Hooks Overview
Git Hooks are scripts that Git automatically executes at specific operations (such as commit, push, merge). They are stored in the .git/hooks/ directory:
| Hook Name | Trigger Timing | Common Use Cases | Skippable |
|---|---|---|---|
pre-commit | Before commit execution | Code formatting, lint checks | --no-verify |
prepare-commit-msg | Before editing commit message | Auto-generate commit message template | - |
commit-msg | After commit message is written | Validate commit message format | --no-verify |
post-commit | After commit completes | Notifications, statistics | No |
pre-push | Before push execution | Run tests, prevent pushing to protected branches | --no-verify |
pre-merge-commit | Before merge completes | Pre-merge checks | - |
post-merge | After merge completes | Restore dependencies, update submodules | No |
pre-rebase | Before rebase execution | Prevent rebasing pushed commits | - |
post-checkout | After branch switch | Restore environment | No |
1.2 Native Hook Example
#!/usr/bin/env bash
# .git/hooks/pre-commit
set -euo pipefail
echo "Running pre-commit checks..."
# 1. Check for leftover debug code
if git diff --cached | grep -E '^\+.*\b(debugger|console\.log|print\(|fmt\.Println)\b'; then
echo "✗ Debug code found, please remove before committing"
exit 1
fi
# 2. Check for unprocessed TODO/FIXME
if git diff --cached | grep -E '^\+.*\b(TODO|FIXME|HACK|XXX)\b'; then
echo "⚠ TODO/FIXME found, please confirm if action is needed"
# Don't block the commit, just warn
fi
# 3. Check for trailing whitespace
if git diff --cached | grep -E '^\+.*[ \t]+$'; then
echo "✗ Trailing whitespace found, please clean up"
# Auto-fix
git diff --cached --name-only | xargs sed -i 's/[[:space:]]*$//'
echo "Trailing whitespace auto-cleaned, please re-run git add"
exit 1
fi
# 4. Check file permissions
while IFS= read -r file; do
if [[ -f "$file" ]] && [[ -x "$file" ]] && [[ "$file" != *.sh ]]; then
echo "✗ $file should not have execute permission"
exit 1
fi
done < <(git diff --cached --name-only --diff-filter=ACM)
echo "✓ Pre-commit checks passed"
1.3 Limitations of Native Hooks
Problem 1: .git/hooks/ is not under version control, cannot be shared across the team
Problem 2: Each developer needs to manually install hooks
Problem 3: Managing dependencies and execution order between hooks is difficult
Problem 4: Multi-language projects require configuring multiple tools, manual management is complex
The solution is to use Hook management tools: pre-commit (Python ecosystem) and Husky (Node.js ecosystem).
II. pre-commit
2.1 Installation
# Install via pip
pip install pre-commit
# macOS
brew install pre-commit
# Verify
pre-commit --version
2.2 Configuration File
Create .pre-commit-config.yaml in the project root:
# .pre-commit-config.yaml
default_language_version:
python: python3
node: 20.0.0
# Exclude paths
exclude: |
(?x)^(
vendor/.*|
third_party/.*|
.*\.pb\.go|
.*\.gen\.go|
docs/.*
)$
repos:
# === General checks ===
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace # Remove trailing whitespace
- id: end-of-file-fixer # Ensure files end with a newline
- id: check-yaml # YAML syntax check
- id: check-json # JSON syntax check
- id: check-merge-conflict # Check for merge conflict markers
- id: check-added-large-files # Prevent committing large files
args: ['--maxkb=500']
- id: check-case-conflict # Check for filename case conflicts
- id: check-symlinks # Check symbolic links
- id: detect-private-key # Detect private keys
- id: mixed-line-ending # Normalize line endings
args: ['--fix=lf']
- id: requirements-txt-fixer # Fix requirements.txt
# === Python projects ===
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff # Lint
args: ['--fix']
- id: ruff-format # Format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
args: ['--config-file=pyproject.toml']
# === Go projects ===
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-imports
- id: go-mod-tidy
- id: go-unit-tests
args: ['-short']
- repo: https://github.com/golangci/golangci-lint
rev: v1.59.0
hooks:
- id: golangci-lint
args: ['--config=.golangci.yml']
# === JavaScript/TypeScript projects ===
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.3.0
hooks:
- id: eslint
files: \.(js|ts|jsx|tsx)$
types: [file]
args: ['--fix']
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
types_or: [javascript, ts, css, html, json, yaml, markdown]
# === Shell scripts ===
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.8.0
hooks:
- id: shfmt
args: ['-i', '4', '-w']
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
args: ['-x']
# === Security checks ===
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
# === Commit message checks ===
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.2.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert]
# === Dockerfile ===
- repo: https://github.com/hadolint/hadolint
rev: v2.12.0
hooks:
- id: hadolint-docker
args: ['--ignore', 'DL3008']
# === Markdown ===
- repo: https://github.com/igorshubovych/markdownlint-cli
rev: v0.41.0
hooks:
- id: markdownlint
args: ['--fix']
2.3 Installation and Usage
# Install hooks to .git/hooks/
pre-commit install
# Also install commit-msg hook
pre-commit install --hook-type commit-msg
# Manually run all checks
pre-commit run --all-files
# Run only a specific hook
pre-commit run trailing-whitespace --all-files
# Check only staged files
pre-commit run
# Update hook versions
pre-commit autoupdate
# Clean cache
pre-commit clean
pre-commit gc
# Show status
pre-commit run --show-stages
2.4 Local Hook Configuration
Developers can add local custom hooks alongside .pre-commit-config.yaml:
repos:
# ... other hooks ...
# Local custom hooks
- repo: local
hooks:
- id: go-build
name: Go Build
entry: go build ./...
language: system
files: \.go$
pass_filenames: false
- id: go-test
name: Go Test
entry: go test -short ./...
language: system
files: \.go$
pass_filenames: false
stages: [push] # Only run on pre-push
- id: custom-check
name: Custom Check
entry: ./scripts/custom-check.sh
language: script
files: \.(go|py|js|ts)$
2.5 Running pre-commit in CI
# .github/workflows/pre-commit.yml
name: Pre-commit Checks
on:
pull_request:
push:
branches: [main]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install pre-commit
run: pip install pre-commit
- name: Cache pre-commit
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
run: pre-commit run --all-files
III. Husky
3.1 Installation
# Initialize npm project (if not already)
npm init -y
# Install Husky
npm install --save-dev husky
# Initialize Husky
npx husky init
# This creates the .husky/ directory and adds a prepare script to package.json
3.2 Configuring Hooks
# Create pre-commit hook
echo 'npx lint-staged' > .husky/pre-commit
# Create commit-msg hook
echo 'npx --no-install commitlint --edit $1' > .husky/commit-msg
# Create pre-push hook
echo 'npm test' > .husky/pre-push
3.3 lint-staged
lint-staged only checks staged files, avoiding running checks on the entire project:
// package.json
{
"lint-staged": {
"*.{js,ts,jsx,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss,html,json,md,yaml,yml}": [
"prettier --write"
],
"*.py": [
"ruff check --fix",
"ruff format"
],
"*.go": [
"gofmt -w",
"goimports -w"
],
"*.sh": [
"shfmt -w",
"shellcheck"
],
"Dockerfile": [
"hadolint"
]
}
}
3.4 Complete Node.js Project Configuration
// package.json
{
"name": "my-project",
"version": "1.0.0",
"scripts": {
"prepare": "husky",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"test": "jest",
"test:watch": "jest --watch"
},
"devDependencies": {
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"eslint": "^9.0.0",
"prettier": "^3.0.0",
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0"
},
"lint-staged": {
"*.{js,ts}": ["eslint --fix", "prettier --write"],
"*.{json,md,yaml}": ["prettier --write"]
}
}
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
[
'feat', // New feature
'fix', // Bug fix
'docs', // Documentation changes
'style', // Code formatting (no functional impact)
'refactor', // Refactoring (no functional impact)
'perf', // Performance optimization
'test', // Tests
'build', // Build system or external dependency changes
'ci', // CI configuration changes
'chore', // Miscellaneous (no source code or test changes)
'revert' // Revert
]
],
'subject-max-length': [2, 'always', 72],
'body-max-line-length': [1, 'always', 100],
}
};
3.5 .husky Directory Structure
.husky/
├── _/ # Husky internal files (do not modify)
│ ├── h
│ └── husky.sh
├── pre-commit # pre-commit hook
├── commit-msg # commit-msg hook
├── pre-push # pre-push hook
└── post-merge # post-merge hook
#!/usr/bin/env sh
# .husky/pre-commit
# Husky v9+ format (no need to source husky.sh)
# Run lint-staged
npx lint-staged
# Run type checking
npx tsc --noEmit
# Check for console.log
if git diff --cached | grep -E '^\+.*console\.log'; then
echo "✗ Please remove console.log"
exit 1
fi
IV. commitlint
4.1 Commit Message Convention
Conventional Commits is the most widely used commit message convention:
<type>(<scope>): <subject>
<body>
<footer>
| Type | Description | Example |
|---|---|---|
feat | New feature | feat(auth): add OAuth2 login |
fix | Bug fix | fix(api): fix user list pagination error |
docs | Documentation | docs: update README installation steps |
style | Formatting | style: unify indentation to 2 spaces |
refactor | Refactoring | refactor(db): refactor connection pool management |
perf | Performance | perf(query): optimize N+1 slow query |
test | Tests | test(auth): add login unit tests |
build | Build | build: upgrade Go to 1.22 |
ci | CI | ci: add GitHub Actions workflow |
chore | Miscellaneous | chore: update dependency versions |
revert | Revert | revert: revert feat(auth) commit |
4.2 commitlint Configuration
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
// Type enum
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert']
],
// Type must be lowercase
'type-case': [2, 'always', 'lower-case'],
// Type must not be empty
'type-empty': [2, 'never'],
// Scope format
'scope-case': [2, 'always', 'lower-case'],
// Subject must not be empty
'subject-empty': [2, 'never'],
// Subject must not end with .
'subject-full-stop': [2, 'never', '.'],
// Subject case
'subject-case': [0], // Disabled (allows non-English)
// Subject max length
'subject-max-length': [2, 'always', 72],
// Subject min length
'subject-min-length': [2, 'always', 3],
// Header max length
'header-max-length': [2, 'always', 100],
// Body max line length
'body-max-line-length': [1, 'always', 100],
// Footer max line length
'footer-max-line-length': [1, 'always', 100],
}
};
4.3 Validating Commit Messages
# Install
npm install --save-dev @commitlint/cli @commitlint/config-conventional
# Validate commit message
echo "feat: add new feature" | npx commitlint
# ✓ passes
echo "update something" | npx commitlint
# ✗ fails: missing type
echo "FEAT: add feature" | npx commitlint
# ✗ fails: type must be lowercase
# Validate from file
npx commitlint --edit .git/COMMIT_EDITMSG
# Validate from git log
git log --format=%s | npx commitlint
4.4 Interactive Commit Tool: commitizen
# Install
npm install --save-dev commitizen cz-conventional-changelog
# Configure
echo '{"config": {"commitizen": {"path": "cz-conventional-changelog"}}}' >> package.json
# Use interactive commit
npx cz
# ? Select the type of change that you're committing:
# ❯ feat: A new feature
# fix: A bug fix
# docs: Documentation only changes
# ...
# ? What is the scope of this change (e.g. component or file name)?
# ? Write a short, imperative tense description of the change:
# ? Provide a longer description of the change:
# ? Are there any breaking changes?
# ? Does this change affect any open issues?
V. Automated Test Triggering
5.1 Running Fast Tests in pre-commit
#!/usr/bin/env sh
# .husky/pre-commit
# Fast checks (< 10 seconds)
npx lint-staged
# Type checking
npx tsc --noEmit
# Run only affected tests
# Get staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(test|spec)\.(ts|js)$')
if [ -n "$STAGED_FILES" ]; then
echo "Running related tests..."
npx jest --findRelatedTests $STAGED_FILES --passWithNoTests
fi
5.2 Running Full Tests in pre-push
#!/usr/bin/env sh
# .husky/pre-push
echo "Running pre-push checks..."
# 1. Full test suite
echo "Running test suite..."
npm test
if [ $? -ne 0 ]; then
echo "✗ Tests failed, push blocked"
echo "To skip, use: git push --no-verify"
exit 1
fi
# 2. Check if pushing to a protected branch
PROTECTED_BRANCHES="main master release/*"
BRANCH=$(git rev-parse --abbrev-ref HEAD)
for protected in $PROTECTED_BRANCHES; do
if [[ "$BRANCH" == $protected ]]; then
echo "⚠ You are pushing to a protected branch: $BRANCH"
echo "Confirm push? (y/N)"
read -r response
if [ "$response" != "y" ] && [ "$response" != "Y" ]; then
echo "Push cancelled"
exit 1
fi
fi
done
# 3. Check if main branch sync is needed
if [[ "$BRANCH" != "main" ]] && [[ "$BRANCH" != "master" ]]; then
MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | awk '{print $NF}')
BEHIND=$(git rev-list --count HEAD..origin/$MAIN_BRANCH 2>/dev/null || echo 0)
if [ "$BEHIND" -gt 0 ]; then
echo "⚠ Current branch is behind $MAIN_BRANCH by $BEHIND commit(s)"
echo "Consider rebasing: git rebase origin/$MAIN_BRANCH"
fi
fi
echo "✓ Pre-push checks passed"
5.3 Go Project Test Hook
#!/usr/bin/env bash
# .husky/pre-commit (Go project)
set -euo pipefail
# Get staged Go files
STAGED_GO_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$' || true)
if [ -z "$STAGED_GO_FILES" ]; then
exit 0
fi
echo "Running Go code checks..."
# 1. Format
echo " Formatting code..."
gofmt -w $STAGED_GO_FILES
git add $STAGED_GO_FILES
# 2. goimports
if command -v goimports &>/dev/null; then
echo " Organizing imports..."
goimports -w $STAGED_GO_FILES
git add $STAGED_GO_FILES
fi
# 3. golangci-lint
echo " Running golangci-lint..."
golangci-lint run --new-from-rev=HEAD $STAGED_GO_FILES
# 4. Build check
echo " Build check..."
go build ./...
# 5. Run related tests
echo " Running tests..."
for file in $STAGED_GO_FILES; do
# Find corresponding test file
dir=$(dirname "$file")
pkg=$(basename "$dir")
test_file="${dir}/${pkg}_test.go"
if [ -f "$test_file" ]; then
go test -short -count=1 "./${dir}/..."
fi
done
echo "✓ Go code checks passed"
VI. CI and Local Hook Coordination
6.1 Layered Check Strategy
Local pre-commit: Formatting + fast lint + type checking (< 10s)
Local pre-push: Full test suite (< 60s)
CI (PR): Full lint + tests + security scan + build (< 5min)
CI (main): Deploy to staging + integration tests (< 10min)
6.2 Single Source of Configuration
Avoid configuration drift between local and CI by using the same configuration:
# .github/workflows/quality.yml
name: Code Quality
on: [pull_request]
jobs:
# Same checks as pre-commit
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install pre-commit
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
- run: pre-commit run --all-files
# CI-only checks
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy
run: |
trivy fs --severity HIGH,CRITICAL .
- name: Run GoSec
run: |
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec ./...
# Test coverage
test-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- run: go test -race -coverprofile=coverage.out ./...
- name: Check coverage
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
echo "Coverage: $COVERAGE"
# Minimum coverage requirement
if (( $(echo "$COVERAGE < 70.0" | bc -l) )); then
echo "✗ Coverage below 70%"
exit 1
fi
6.3 Managing Hook Skips
--no-verify is a double-edged sword: sometimes you need to skip (e.g., hotfixes), but abuse renders hooks useless.
# .husky/pre-commit
#!/usr/bin/env sh
# Allow skipping via commit message containing [skip lint]
COMMIT_MSG=$(cat .git/COMMIT_EDITMSG 2>/dev/null || echo "")
if echo "$COMMIT_MSG" | grep -q '\[skip lint\]'; then
echo "⚠ Skipping lint checks (commit message contains [skip lint])"
exit 0
fi
npx lint-staged
# CI: detect commits that skipped hooks
name: Verify Hooks
on: [pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for --no-verify commits
run: |
# Check if PR commits skipped hooks
# If pre-commit finds issues in CI but not locally,
# the developer likely used --no-verify
if pre-commit run --all-files 2>&1 | grep -q "Failed"; then
echo "✗ Code checks failed (local checks may have been skipped with --no-verify)"
exit 1
fi
VII. Team Standards Enforcement
7.1 Quick Project Initialization
#!/usr/bin/env bash
# setup-git-hooks.sh - One-click Git Hooks configuration for projects
set -euo pipefail
PROJECT_TYPE="${1:-go}" # go, node, python, mixed
echo "Configuring Git Hooks ($PROJECT_TYPE project)..."
# 1. Copy configuration files
case "$PROJECT_TYPE" in
go)
cat > .pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: check-added-large-files
args: ['--maxkb=500']
- id: detect-private-key
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-imports
- id: go-mod-tidy
- repo: https://github.com/golangci/golangci-lint
rev: v1.59.0
hooks:
- id: golangci-lint
- repo: local
hooks:
- id: go-build
name: Go Build
entry: go build ./...
language: system
files: \.go$
pass_filenames: false
EOF
;;
node)
npm install --save-dev husky lint-staged eslint prettier \
@commitlint/cli @commitlint/config-conventional
npx husky init
echo 'npx lint-staged' > .husky/pre-commit
echo 'npx --no-install commitlint --edit $1' > .husky/commit-msg
;;
python)
cat > .pre-commit-config.yaml << 'EOF'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff
args: ['--fix']
- id: ruff-format
EOF
;;
esac
# 2. Install pre-commit (if used)
if [ -f .pre-commit-config.yaml ]; then
pip install pre-commit 2>/dev/null || true
pre-commit install
pre-commit install --hook-type commit-msg
echo "✓ pre-commit installed"
fi
# 3. Add commitlint (if using Husky)
if [ -d .husky ]; then
cat > commitlint.config.js << 'EOF'
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'subject-max-length': [2, 'always', 72],
}
};
EOF
echo "✓ commitlint configured"
fi
# 4. Add to .gitignore
grep -qxF '.husky/_/' .gitignore 2>/dev/null || echo '.husky/_/' >> .gitignore
echo ""
echo "Git Hooks configuration complete!"
echo " - Code formatting and lint run automatically on commit"
echo " - Commit messages must follow Conventional Commits"
echo " - To skip: git commit --no-verify"
7.2 Team Onboarding Documentation
# Code Quality Tooling Setup
## Initial Setup
1. Install dependencies:
```bash
# Python tools
pip install pre-commit
# Node.js tools (if project includes frontend code)
npm install
Install Git Hooks:
pre-commit install pre-commit install --hook-type commit-msg # or npm run prepare # HuskyVerify:
pre-commit run --all-files
Commit Message Format
<type>(<scope>): <subject>
<body>
<footer>
Types: feat | fix | docs | style | refactor | perf | test | build | ci | chore | revert
Examples:
feat(auth): add OAuth2 login support
fix(api): fix user list pagination error (#123)
docs: update deployment documentation
Skipping Checks
Skip in emergencies:
git commit --no-verify -m "hotfix: urgent production issue"
Note: CI will re-run checks. Skipping local checks does not skip CI.
### 7.3 Gradual Rollout Strategy
Phase 1 (Weeks 1-2): Observation period
- Install hooks but set to warning mode (don’t block commits)
- Collect common issues, count violations
Phase 2 (Weeks 3-4): Auto-formatting
- Enable auto-formatting (gofmt, prettier, ruff format)
- Enable trailing-whitespace, end-of-file-fixer
- Developer experience: formatting auto-corrected on commit, no extra burden
Phase 3 (Weeks 5-6): Lint enforcement
- Enable lint checks, block non-compliant commits
- Provide fix guides and documentation
- Allow [skip lint] for emergencies
Phase 4 (Weeks 7-8): Commit message standards
- Enable commitlint
- Pair with commitizen interactive tool to lower the barrier
Phase 5 (Ongoing): CI enforcement
- CI runs the same checks as local
- Gradually increase coverage thresholds
- Integrate security scanning into the pipeline
## VIII. Common Issues and Solutions
### 8.1 Hooks Are Too Slow
```yaml
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
# Only check staged files, not the entire project
- id: eslint-staged
name: ESLint (staged only)
entry: npx eslint
language: system
files: \.(js|ts)$
# pre-commit only passes staged files by default
# Separate fast and slow checks
- id: type-check
name: TypeScript Type Check
entry: npx tsc --noEmit
language: system
files: \.ts$
pass_filenames: false
stages: [push] # Move to pre-push
8.2 Hooks Pass Locally but Fail in CI
# Common cause: local cache causing inconsistent results
# Clean pre-commit cache
pre-commit clean
pre-commit gc
# Reinstall
pre-commit install
pre-commit run --all-files
# Ensure no cache is used in CI
pre-commit run --all-files --show-diff-on-failure
8.3 Multi-Language Project Configuration
# .pre-commit-config.yaml - Multi-language project
repos:
# General checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks: &default_hooks
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: detect-private-key
# Python code
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff
args: ['--fix']
- id: ruff-format
# Go code
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-imports
# Shell scripts
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
# Dockerfile
- repo: https://github.com/hadolint/hadolint
rev: v2.12.0
hooks:
- id: hadolint
Summary
Git Hooks are the first line of defense in code quality automation. Their value lies in intercepting issues before they enter the codebase. Key takeaways:
- Hooks are a native capability; tools are the management layer: Git has a built-in Hooks mechanism, but
.git/hooks/is not under version control. pre-commit and Husky solve the problem of “configuration sharing and automated installation” - pre-commit is ideal for multi-language projects: Through
.pre-commit-config.yaml, it uniformly manages checking tools for all languages, with a rich community ecosystem — Go/Python/Shell/JSON/YAML all have ready-made hooks - Husky + lint-staged is the Node.js standard: Husky manages hook installation, lint-staged checks only staged files — together they enable efficient incremental checking
- commitlint standardizes commit messages: The Conventional Commits format not only makes git log cleaner but also enables automatic changelog generation and semantic versioning
- Layered checks avoid slowing down development: pre-commit handles formatting and fast lint (< 10s), pre-push handles full tests (< 60s), CI handles security scanning and coverage checks (< 5min). Each layer does only what it should
- CI is the ultimate safeguard: Local hooks can be skipped with
--no-verify, but CI cannot. CI runs the same check configuration as local, ensuring that even if local checks were skipped, issues are caught at the PR stage - Gradual rollout is key: Don’t enable all checks at once. Observe → auto-fix → enforce lint → commit standards → CI enforcement — phase it in to give the team time to adapt
The ultimate goal of Git Hooks is not to block commits, but to ensure that every commit automatically meets team standards. When formatting, linting, and testing become automatic behaviors at commit time, developers don’t need to “remember” the rules — the rules enforce themselves.
References & Acknowledgments
This article referenced the following materials during writing. We thank the original authors for their contributions:
- Git Hooks Official Documentation — Git-scm, referenced for Git Hooks Official Documentation
- pre-commit Official Site — Pre-commit, referenced for pre-commit Official Site