概述
代码质量问题越早发现,修复成本越低。在提交阶段拦截比在 CI 阶段拦截快,在 CI 阶段拦截比在生产环境出问题后修复快。Git Hooks 提供了在提交和推送的关键节点自动执行检查的能力——格式化代码、运行 lint、验证提交信息、执行测试。从 Git Hooks 原理到工具链实践,构建一套从本地到 CI 的代码质量自动化体系。
一、Git Hooks 体系
1.1 Hooks 概览
Git Hooks 是 Git 在特定操作(如 commit、push、merge)发生时自动执行的脚本,存放在 .git/hooks/ 目录中:
| Hook 名称 | 触发时机 | 常见用途 | 是否可跳过 |
|---|---|---|---|
pre-commit | commit 执行前 | 代码格式化、lint 检查 | --no-verify |
prepare-commit-msg | 编辑提交信息前 | 自动生成提交信息模板 | - |
commit-msg | 提交信息写入后 | 验证提交信息格式 | --no-verify |
post-commit | commit 完成后 | 通知、统计 | 否 |
pre-push | push 执行前 | 运行测试、防止推送到 protected 分支 | --no-verify |
pre-merge-commit | merge 完成前 | 合并前检查 | - |
post-merge | merge 完成后 | 恢复依赖、更新子模块 | 否 |
pre-rebase | rebase 执行前 | 防止 rebase 已推送的提交 | - |
post-checkout | 切换分支后 | 恢复环境 | 否 |
1.2 原生 Hook 示例
#!/usr/bin/env bash
# .git/hooks/pre-commit
set -euo pipefail
echo "执行提交前检查..."
# 1. 检查是否有调试代码遗留
if git diff --cached | grep -E '^\+.*\b(debugger|console\.log|print\(|fmt\.Println)\b'; then
echo "✗ 发现调试代码,请移除后再提交"
exit 1
fi
# 2. 检查是否有 TODO/FIXME 未处理
if git diff --cached | grep -E '^\+.*\b(TODO|FIXME|HACK|XXX)\b'; then
echo "⚠ 发现 TODO/FIXME,确认是否需要处理"
# 不阻止提交,仅提醒
fi
# 3. 检查行尾空格
if git diff --cached | grep -E '^\+.*[ \t]+$'; then
echo "✗ 发现行尾空格,请清理"
# 自动修复
git diff --cached --name-only | xargs sed -i 's/[[:space:]]*$//'
echo "已自动清理行尾空格,请重新 git add"
exit 1
fi
# 4. 检查文件权限
while IFS= read -r file; do
if [[ -f "$file" ]] && [[ -x "$file" ]] && [[ "$file" != *.sh ]]; then
echo "✗ $file 不应有执行权限"
exit 1
fi
done < <(git diff --cached --name-only --diff-filter=ACM)
echo "✓ 提交前检查通过"
1.3 原生 Hook 的局限
问题1:.git/hooks/ 不在版本控制中,无法团队共享
问题2:每个开发者需要手动安装 hook
问题3:Hook 之间的依赖和执行顺序难以管理
问题4:跨语言项目需要配置多种工具,手动管理复杂
解决方案就是使用 Hook 管理工具:pre-commit(Python 生态)和 Husky(Node.js 生态)。
二、pre-commit
2.1 安装
# 通过 pip 安装
pip install pre-commit
# macOS
brew install pre-commit
# 验证
pre-commit --version
2.2 配置文件
在项目根目录创建 .pre-commit-config.yaml:
# .pre-commit-config.yaml
default_language_version:
python: python3
node: 20.0.0
# 排除路径
exclude: |
(?x)^(
vendor/.*|
third_party/.*|
.*\.pb\.go|
.*\.gen\.go|
docs/.*
)$
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 # YAML 语法检查
- id: check-json # JSON 语法检查
- id: check-merge-conflict # 检查合并冲突标记
- id: check-added-large-files # 防止提交大文件
args: ['--maxkb=500']
- id: check-case-conflict # 检查文件名大小写冲突
- id: check-symlinks # 检查符号链接
- id: detect-private-key # 检测私钥
- id: mixed-line-ending # 统一行尾
args: ['--fix=lf']
- id: requirements-txt-fixer # 修复 requirements.txt
# === Python 项目 ===
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff # Lint
args: ['--fix']
- id: ruff-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 项目 ===
- 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 项目 ===
- 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 脚本 ===
- 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']
# === 安全检查 ===
- 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
# === 提交信息检查 ===
- 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 安装与使用
# 安装 hook 到 .git/hooks/
pre-commit install
# 同时安装 commit-msg hook
pre-commit install --hook-type commit-msg
# 手动运行所有检查
pre-commit run --all-files
# 只运行指定 hook
pre-commit run trailing-whitespace --all-files
# 只检查暂存的文件
pre-commit run
# 更新 hook 版本
pre-commit autoupdate
# 清理缓存
pre-commit clean
pre-commit gc
# 查看状态
pre-commit run --show-stages
2.4 本地 Hook 配置
开发者可以在 .pre-commit-config.yaml 之外添加本地自定义 hook:
repos:
# ... 其他 hook ...
# 本地自定义 hook
- 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] # 只在 pre-push 时运行
- id: custom-check
name: Custom Check
entry: ./scripts/custom-check.sh
language: script
files: \.(go|py|js|ts)$
2.5 CI 中运行 pre-commit
# .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
三、Husky
3.1 安装
# 初始化 npm 项目(如果还没有)
npm init -y
# 安装 Husky
npm install --save-dev husky
# 初始化 Husky
npx husky init
# 这会创建 .husky/ 目录并在 package.json 中添加 prepare 脚本
3.2 配置 Hook
# 创建 pre-commit hook
echo 'npx lint-staged' > .husky/pre-commit
# 创建 commit-msg hook
echo 'npx --no-install commitlint --edit $1' > .husky/commit-msg
# 创建 pre-push hook
echo 'npm test' > .husky/pre-push
3.3 lint-staged
lint-staged 只检查暂存区的文件,避免对整个项目运行检查:
// 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 完整的 Node.js 项目配置
// 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', // 新功能
'fix', // 修复 bug
'docs', // 文档变更
'style', // 代码格式(不影响功能)
' refactor', // 重构(不影响功能)
'perf', // 性能优化
'test', // 测试
'build', // 构建系统或外部依赖变更
'ci', // CI 配置变更
'chore', // 杂项(不修改源码或测试)
'revert' // 回滚
]
],
'subject-max-length': [2, 'always', 72],
'body-max-line-length': [1, 'always', 100],
}
};
3.5 .husky 目录结构
.husky/
├── _/ # Husky 内部文件(不要修改)
│ ├── 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+ 格式(不需要 source husky.sh)
# 运行 lint-staged
npx lint-staged
# 运行类型检查
npx tsc --noEmit
# 检查是否有 console.log
if git diff --cached | grep -E '^\+.*console\.log'; then
echo "✗ 请移除 console.log"
exit 1
fi
四、commitlint
4.1 提交信息规范
Conventional Commits 是最广泛使用的提交信息规范:
<type>(<scope>): <subject>
<body>
<footer>
| 类型 | 说明 | 示例 |
|---|---|---|
feat | 新功能 | feat(auth): 添加 OAuth2 登录 |
fix | Bug 修复 | fix(api): 修复用户列表分页错误 |
docs | 文档 | docs: 更新 README 安装步骤 |
style | 格式 | style: 统一缩进为 2 空格 |
refactor | 重构 | refactor(db): 重构连接池管理 |
perf | 性能 | perf(query): 优化慢查询 N+1 问题 |
test | 测试 | test(auth): 添加登录单元测试 |
build | 构建 | build: 升级 Go 到 1.22 |
ci | CI | ci: 添加 GitHub Actions 工作流 |
chore | 杂项 | chore: 更新依赖版本 |
revert | 回滚 | revert: 回滚 feat(auth) 提交 |
4.2 commitlint 配置
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
// 类型枚举
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert']
],
// 类型必须小写
'type-case': [2, 'always', 'lower-case'],
// 类型不能为空
'type-empty': [2, 'never'],
// 作用域格式
'scope-case': [2, 'always', 'lower-case'],
// 主题不能为空
'subject-empty': [2, 'never'],
// 主题不能以 . 结尾
'subject-full-stop': [2, 'never', '.'],
// 主题大小写
'subject-case': [0], // 禁用(允许中文)
// 主题最大长度
'subject-max-length': [2, 'always', 72],
// 主题最小长度
'subject-min-length': [2, 'always', 3],
// 头部最大长度
'header-max-length': [2, 'always', 100],
// body 每行最大长度
'body-max-line-length': [1, 'always', 100],
// footer 最大行长度
'footer-max-line-length': [1, 'always', 100],
}
};
4.3 验证提交信息
# 安装
npm install --save-dev @commitlint/cli @commitlint/config-conventional
# 验证提交信息
echo "feat: add new feature" | npx commitlint
# ✓ 通过
echo "update something" | npx commitlint
# ✗ 失败:缺少 type
echo "FEAT: add feature" | npx commitlint
# ✗ 失败:type 必须小写
# 从文件验证
npx commitlint --edit .git/COMMIT_EDITMSG
# 从 git log 验证
git log --format=%s | npx commitlint
4.4 交互式提交工具 commitizen
# 安装
npm install --save-dev commitizen cz-conventional-changelog
# 配置
echo '{"config": {"commitizen": {"path": "cz-conventional-changelog"}}}' >> package.json
# 使用交互式提交
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?
五、自动化测试触发
5.1 pre-commit 运行快速测试
#!/usr/bin/env sh
# .husky/pre-commit
# 快速检查(< 10 秒)
npx lint-staged
# 类型检查
npx tsc --noEmit
# 只运行受影响的测试
# 获取暂存的文件
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(test|spec)\.(ts|js)$')
if [ -n "$STAGED_FILES" ]; then
echo "运行相关测试..."
npx jest --findRelatedTests $STAGED_FILES --passWithNoTests
fi
5.2 pre-push 运行完整测试
#!/usr/bin/env sh
# .husky/pre-push
echo "执行推送前检查..."
# 1. 完整测试套件
echo "运行测试套件..."
npm test
if [ $? -ne 0 ]; then
echo "✗ 测试失败,推送已阻止"
echo "如需跳过,使用: git push --no-verify"
exit 1
fi
# 2. 检查是否在推送受保护分支
PROTECTED_BRANCHES="main master release/*"
BRANCH=$(git rev-parse --abbrev-ref HEAD)
for protected in $PROTECTED_BRANCHES; do
if [[ "$BRANCH" == $protected ]]; then
echo "⚠ 你正在推送受保护分支: $BRANCH"
echo "确认推送?(y/N)"
read -r response
if [ "$response" != "y" ] && [ "$response" != "Y" ]; then
echo "推送已取消"
exit 1
fi
fi
done
# 3. 检查是否需要同步 main 分支
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 "⚠ 当前分支落后 $MAIN_BRANCH $BEHIND 个提交"
echo "建议先 rebase: git rebase origin/$MAIN_BRANCH"
fi
fi
echo "✓ 推送前检查通过"
5.3 Go 项目测试 Hook
#!/usr/bin/env bash
# .husky/pre-commit (Go 项目)
set -euo pipefail
# 获取暂存的 Go 文件
STAGED_GO_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$' || true)
if [ -z "$STAGED_GO_FILES" ]; then
exit 0
fi
echo "执行 Go 代码检查..."
# 1. 格式化
echo " 格式化代码..."
gofmt -w $STAGED_GO_FILES
git add $STAGED_GO_FILES
# 2. goimports
if command -v goimports &>/dev/null; then
echo " 整理 import..."
goimports -w $STAGED_GO_FILES
git add $STAGED_GO_FILES
fi
# 3. golangci-lint
echo " 运行 golangci-lint..."
golangci-lint run --new-from-rev=HEAD $STAGED_GO_FILES
# 4. 编译检查
echo " 编译检查..."
go build ./...
# 5. 运行相关测试
echo " 运行测试..."
for file in $STAGED_GO_FILES; do
# 找到对应的测试文件
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 代码检查通过"
六、CI 与本地 Hook 配合
6.1 分层检查策略
本地 pre-commit: 格式化 + 快速 lint + 类型检查 (< 10s)
本地 pre-push: 完整测试套件 (< 60s)
CI (PR): 全量 lint + 测试 + 安全扫描 + 构建 (< 5min)
CI (main): 部署到 staging + 集成测试 (< 10min)
6.2 统一配置源
避免本地和 CI 配置不一致,使用同一份配置:
# .github/workflows/quality.yml
name: Code Quality
on: [pull_request]
jobs:
# 与 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 独有的检查
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:
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"
# 最低覆盖率要求
if (( $(echo "$COVERAGE < 70.0" | bc -l) )); then
echo "✗ 覆盖率低于 70%"
exit 1
fi
6.3 跳过 Hook 的管理
--no-verify 是双刃剑:有时需要跳过(如紧急修复),但滥用会让 Hook 形同虚设。
# .husky/pre-commit
#!/usr/bin/env sh
# 允许通过提交信息中的 [skip lint] 跳过
COMMIT_MSG=$(cat .git/COMMIT_EDITMSG 2>/dev/null || echo "")
if echo "$COMMIT_MSG" | grep -q '\[skip lint\]'; then
echo "⚠ 跳过 lint 检查(提交信息包含 [skip lint])"
exit 0
fi
npx lint-staged
# CI 中检测跳过 Hook 的提交
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: |
# 检查 PR 中的提交是否跳过了 Hook
# 如果 pre-commit 在 CI 中发现问题但本地没有,
# 说明开发者用了 --no-verify
if pre-commit run --all-files 2>&1 | grep -q "Failed"; then
echo "✗ 代码检查未通过(可能使用了 --no-verify 跳过本地检查)"
exit 1
fi
七、团队规范落地
7.1 快速初始化新项目
#!/usr/bin/env bash
# setup-git-hooks.sh - 一键配置项目 Git Hooks
set -euo pipefail
PROJECT_TYPE="${1:-go}" # go, node, python, mixed
echo "配置 Git Hooks ($PROJECT_TYPE 项目)..."
# 1. 复制配置文件
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. 安装 pre-commit(如果使用)
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 已安装"
fi
# 3. 添加 commitlint(如果使用 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 已配置"
fi
# 4. 添加到 .gitignore
grep -qxF '.husky/_/' .gitignore 2>/dev/null || echo '.husky/_/' >> .gitignore
echo ""
echo "Git Hooks 配置完成!"
echo " - 提交时自动检查代码格式和 lint"
echo " - 提交信息需遵循 Conventional Commits 规范"
echo " - 如需跳过: git commit --no-verify"
7.2 团队 onboarding 文档
# 代码质量工具配置
## 首次设置
1. 安装依赖:
```bash
# Python 工具
pip install pre-commit
# Node.js 工具(如果项目包含前端代码)
npm install
安装 Git Hooks:
pre-commit install pre-commit install --hook-type commit-msg # 或 npm run prepare # Husky验证:
pre-commit run --all-files
提交信息格式
<type>(<scope>): <subject>
<body>
<footer>
类型:feat | fix | docs | style | refactor | perf | test | build | ci | chore | revert
示例:
feat(auth): 添加 OAuth2 登录支持
fix(api): 修复用户列表分页错误 (#123)
docs: 更新部署文档
跳过检查
紧急情况可跳过:
git commit --no-verify -m "hotfix: 紧急修复生产问题"
注意:CI 会再次运行检查,跳过本地检查不代表跳过 CI。
### 7.3 渐进式推广策略
阶段1(第1-2周):观察期
- 安装 Hook 但设为 warning 模式(不阻止提交)
- 收集常见问题,统计违规次数
阶段2(第3-4周):格式化自动修复
- 启用自动格式化(gofmt、prettier、ruff format)
- 启用 trailing-whitespace、end-of-file-fixer
- 开发者感受:提交时自动修正格式,无额外负担
阶段3(第5-6周):Lint 强制执行
- 启用 lint 检查,阻止不合规提交
- 提供修复指南和文档
- 允许 [skip lint] 紧急跳过
阶段4(第7-8周):提交信息规范
- 启用 commitlint
- 配合 commitizen 交互式提交工具降低门槛
阶段5(持续):CI 强制执行
- CI 运行与本地相同的检查
- 覆盖率门槛逐步提高
- 安全扫描纳入流水线
## 八、常见问题与解决方案
### 8.1 Hook 执行太慢
```yaml
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
# 只检查暂存文件,不检查全项目
- id: eslint-staged
name: ESLint (staged only)
entry: npx eslint
language: system
files: \.(js|ts)$
# pre-commit 默认只传暂存文件
# 分离快速检查和慢速检查
- id: type-check
name: TypeScript Type Check
entry: npx tsc --noEmit
language: system
files: \.ts$
pass_filenames: false
stages: [push] # 移到 pre-push
8.2 Hook 在 CI 中失败但本地通过
# 常见原因:本地缓存导致结果不一致
# 清理 pre-commit 缓存
pre-commit clean
pre-commit gc
# 重新安装
pre-commit install
pre-commit run --all-files
# CI 中确保不使用缓存
pre-commit run --all-files --show-diff-on-failure
8.3 多语言项目配置
# .pre-commit-config.yaml - 多语言项目
repos:
# 通用检查
- 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 代码
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff
args: ['--fix']
- id: ruff-format
# Go 代码
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-imports
# Shell 脚本
- 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
总结
Git Hooks 是代码质量自动化的第一道防线,它的价值在于在问题进入代码库之前就拦截。回顾本文核心要点:
- Hooks 是原生能力,工具是管理手段:Git 自带 Hooks 机制,但
.git/hooks/不在版本控制中。pre-commit 和 Husky 解决的是"配置共享和自动化安装"的问题 - pre-commit 适合多语言项目:通过
.pre-commit-config.yaml统一管理所有语言的检查工具,社区生态丰富,Go/Python/Shell/JSON/YAML 都有现成 hook - Husky + lint-staged 是 Node.js 标配:Husky 管理 Hook 安装,lint-staged 只检查暂存文件,两者配合实现高效的增量检查
- commitlint 规范提交信息:Conventional Commits 格式不仅让 git log 更整洁,还能自动生成 changelog、自动决定语义化版本号
- 分层检查避免拖慢开发:pre-commit 做格式化和快速 lint(< 10s),pre-push 做完整测试(< 60s),CI 做安全扫描和覆盖率检查(< 5min)。每层只做自己该做的事
- CI 是最终保障:本地 Hook 可以
--no-verify跳过,但 CI 不能。CI 运行与本地相同的检查配置,确保即使跳过了本地检查,问题也会在 PR 阶段被发现 - 渐进式推广是关键:不要一次性启用所有检查。先观察→自动修复→lint 强制→提交规范→CI 强制,分阶段推进,给团队适应时间
Git Hooks 的终极目标不是阻止提交,而是让开发者每次提交的代码都自动符合团队标准。当格式化、lint、测试都变成提交时的自动行为时,开发者就不需要"记住"这些规则——规则自己会执行。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- Git Hooks 官方文档 — Git-scm,参考了Git Hooks 官方文档相关内容
- pre-commit 官网 — Pre-commit,参考了pre-commit 官网相关内容