Git Hooks 与自动化
2026/3/20大约 8 分钟
Git Hooks 与自动化
使用 Git Hooks 实现工作流自动化
Git Hooks 概述
什么是 Git Hooks?
Git Hooks 是在 Git 执行特定操作时自动运行的脚本。它们可以用于自动化任务、执行检查、强制规范等。
客户端 Hooks(本地执行)
提交相关:
| Hook | 说明 |
|---|---|
pre-commit | 提交前检查 |
prepare-commit-msg | 准备提交消息 |
commit-msg | 验证提交消息 |
post-commit | 提交后操作 |
其他操作:
| Hook | 说明 |
|---|---|
pre-rebase | 变基前检查 |
post-checkout | 切换分支后 |
post-merge | 合并后操作 |
pre-push | 推送前检查 |
服务端 Hooks(远程执行)
| Hook | 说明 |
|---|---|
pre-receive | 接收推送前 |
update | 每个分支更新前 |
post-receive | 接收推送后 |
Hook 位置:
.git/hooks/> 启用方式:删除.sample后缀并添加可执行权限
Hooks 目录结构
.git/hooks/
├── applypatch-msg.sample
├── commit-msg.sample
├── fsmonitor-watchman.sample
├── post-update.sample
├── pre-applypatch.sample
├── pre-commit.sample
├── pre-merge-commit.sample
├── pre-push.sample
├── pre-rebase.sample
├── pre-receive.sample
├── prepare-commit-msg.sample
├── push-to-checkout.sample
└── update.sample
客户端 Hooks
pre-commit Hook
在 git commit 执行前运行,可以用于代码检查、格式化等。
#!/bin/bash
# .git/hooks/pre-commit
echo "Running pre-commit checks..."
# 检查是否有调试代码
if git diff --cached --name-only | xargs grep -l 'console.log\|debugger' 2>/dev/null; then
echo "❌ Error: Found debug statements"
echo "Please remove console.log or debugger before committing"
exit 1
fi
# 运行 ESLint(只检查暂存的文件)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.jsx\?$\|\.tsx\?$')
if [ -n "$STAGED_FILES" ]; then
echo "Running ESLint..."
echo "$STAGED_FILES" | xargs npx eslint
if [ $? -ne 0 ]; then
echo "❌ ESLint failed"
exit 1
fi
fi
# 运行 Prettier 检查
echo "Checking code formatting..."
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
if [ -n "$STAGED_FILES" ]; then
echo "$STAGED_FILES" | xargs npx prettier --check
if [ $? -ne 0 ]; then
echo "❌ Formatting check failed"
echo "Run 'npx prettier --write' to fix"
exit 1
fi
fi
# 运行单元测试(可选,可能较慢)
# echo "Running tests..."
# npm test
# if [ $? -ne 0 ]; then
# echo "❌ Tests failed"
# exit 1
# fi
echo "✅ All pre-commit checks passed"
exit 0
commit-msg Hook
验证提交消息格式。
#!/bin/bash
# .git/hooks/commit-msg
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# 提交消息格式正则
PATTERN="^(feat|fix|docs|style|refactor|perf|test|chore|revert)(\(.+\))?: .{1,50}"
# 允许合并提交和 revert 提交
if [[ "$COMMIT_MSG" =~ ^Merge ]] || [[ "$COMMIT_MSG" =~ ^Revert ]]; then
exit 0
fi
if ! [[ "$COMMIT_MSG" =~ $PATTERN ]]; then
echo "❌ Invalid commit message format"
echo ""
echo "Expected format: <type>(<scope>): <subject>"
echo ""
echo "Types: feat, fix, docs, style, refactor, perf, test, chore, revert"
echo ""
echo "Examples:"
echo " feat(auth): add OAuth2 login"
echo " fix(ui): resolve button alignment"
echo " docs: update README"
echo ""
exit 1
fi
# 检查标题长度
FIRST_LINE=$(echo "$COMMIT_MSG" | head -n 1)
if [ ${#FIRST_LINE} -gt 72 ]; then
echo "❌ Commit message title too long (max 72 characters)"
exit 1
fi
echo "✅ Commit message format is valid"
exit 0
prepare-commit-msg Hook
自动修改或添加提交消息内容。
#!/bin/bash
# .git/hooks/prepare-commit-msg
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
SHA1=$3
# 获取当前分支名
BRANCH_NAME=$(git symbolic-ref --short HEAD 2>/dev/null)
# 从分支名提取 Issue 编号(如 feature/PROJ-123-description)
ISSUE_ID=$(echo "$BRANCH_NAME" | grep -oE '[A-Z]+-[0-9]+')
# 如果找到 Issue 编号且不是 amend/merge/squash
if [ -n "$ISSUE_ID" ] && [ "$COMMIT_SOURCE" != "merge" ]; then
# 检查提交消息中是否已包含 Issue 编号
if ! grep -q "$ISSUE_ID" "$COMMIT_MSG_FILE"; then
# 在提交消息末尾添加 Issue 引用
echo "" >> "$COMMIT_MSG_FILE"
echo "Refs: $ISSUE_ID" >> "$COMMIT_MSG_FILE"
fi
fi
# 添加模板提示(只对新提交)
if [ "$COMMIT_SOURCE" = "" ]; then
cat >> "$COMMIT_MSG_FILE" << 'EOF'
# ────────────────────────────────────────────
# Commit Message Format:
# <type>(<scope>): <subject>
#
# Types: feat, fix, docs, style, refactor, perf, test, chore
# ────────────────────────────────────────────
EOF
fi
pre-push Hook
推送前执行检查。
#!/bin/bash
# .git/hooks/pre-push
# 获取远程和 URL
remote="$1"
url="$2"
# 保护分支检查
protected_branch="main"
current_branch=$(git symbolic-ref --short HEAD)
# 检查是否推送到保护分支
while read local_ref local_sha remote_ref remote_sha; do
if [[ "$remote_ref" == "refs/heads/$protected_branch" ]]; then
echo "❌ Direct push to '$protected_branch' is not allowed"
echo "Please create a pull request instead"
exit 1
fi
done
# 运行测试
echo "Running tests before push..."
npm test
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Push aborted."
exit 1
fi
# 检查是否有未推送的依赖更新
if git diff HEAD origin/$current_branch -- package-lock.json > /dev/null 2>&1; then
if [ -n "$(git diff HEAD origin/$current_branch -- package-lock.json)" ]; then
echo "⚠️ Warning: package-lock.json has been modified"
echo "Make sure dependencies are properly updated"
fi
fi
echo "✅ Pre-push checks passed"
exit 0
post-checkout Hook
分支切换或文件检出后运行。
#!/bin/bash
# .git/hooks/post-checkout
PREV_HEAD=$1
NEW_HEAD=$2
BRANCH_CHECKOUT=$3 # 1=分支切换, 0=文件检出
# 只在分支切换时执行
if [ "$BRANCH_CHECKOUT" = "1" ]; then
echo "Switched branch, checking for updates..."
# 检查 package.json 是否变化
if [ -n "$(git diff $PREV_HEAD $NEW_HEAD -- package.json)" ]; then
echo "📦 package.json changed, running npm install..."
npm install
fi
# 检查数据库迁移
if [ -n "$(git diff $PREV_HEAD $NEW_HEAD -- migrations/)" ]; then
echo "🗄️ Migrations changed, you may need to run migrations"
fi
# 检查环境配置
if [ -n "$(git diff $PREV_HEAD $NEW_HEAD -- .env.example)" ]; then
echo "⚙️ .env.example changed, check your local .env file"
fi
fi
post-merge Hook
合并完成后运行。
#!/bin/bash
# .git/hooks/post-merge
# 检查依赖变化
CHANGED_FILES=$(git diff-tree -r --name-only --no-commit-id HEAD@{1} HEAD)
if echo "$CHANGED_FILES" | grep -q "package.json"; then
echo "📦 package.json changed, running npm install..."
npm install
fi
if echo "$CHANGED_FILES" | grep -q "requirements.txt"; then
echo "🐍 requirements.txt changed, updating Python packages..."
pip install -r requirements.txt
fi
if echo "$CHANGED_FILES" | grep -qE "migrations/"; then
echo "🗄️ Database migrations changed"
echo "Run 'npm run migrate' or 'python manage.py migrate'"
fi
服务端 Hooks
pre-receive Hook
在服务器接收推送前运行,可以拒绝整个推送。
#!/bin/bash
# hooks/pre-receive (服务端)
while read oldrev newrev refname; do
# 获取分支名
branch=$(echo "$refname" | sed 's/refs\/heads\///')
# 保护分支检查
if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
# 检查是否是强制推送
if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then
MERGE_BASE=$(git merge-base $oldrev $newrev)
if [ "$MERGE_BASE" != "$oldrev" ]; then
echo "❌ Force push to $branch is not allowed"
exit 1
fi
fi
fi
# 检查提交作者
for commit in $(git rev-list $oldrev..$newrev); do
author_email=$(git log -1 --format="%ae" $commit)
if ! echo "$author_email" | grep -qE "@company\.com$"; then
echo "❌ Commit $commit has invalid author email: $author_email"
echo "Please use your @company.com email"
exit 1
fi
done
# 检查大文件
for commit in $(git rev-list $oldrev..$newrev); do
large_files=$(git diff-tree --no-commit-id -r $commit | \
awk '{print $4, $6}' | \
while read mode file; do
size=$(git cat-file -s $mode 2>/dev/null || echo 0)
if [ "$size" -gt 10485760 ]; then # 10MB
echo "$file ($size bytes)"
fi
done)
if [ -n "$large_files" ]; then
echo "❌ Large files detected in commit $commit:"
echo "$large_files"
echo "Please use Git LFS for large files"
exit 1
fi
done
done
exit 0
update Hook
针对每个分支单独检查。
#!/bin/bash
# hooks/update (服务端)
refname="$1"
oldrev="$2"
newrev="$3"
branch=$(echo "$refname" | sed 's/refs\/heads\///')
# 不同分支不同策略
case "$branch" in
main|master)
# 只允许快进合并
if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then
merge_base=$(git merge-base $oldrev $newrev)
if [ "$merge_base" != "$oldrev" ]; then
echo "❌ Non-fast-forward update to $branch rejected"
exit 1
fi
fi
;;
release/*)
# 发布分支:检查版本标签
echo "Updating release branch: $branch"
;;
feature/*|fix/*)
# 功能分支:无特殊限制
;;
*)
# 其他分支:警告
echo "⚠️ Pushing to non-standard branch: $branch"
;;
esac
exit 0
post-receive Hook
推送完成后运行,常用于触发部署。
#!/bin/bash
# hooks/post-receive (服务端)
while read oldrev newrev refname; do
branch=$(echo "$refname" | sed 's/refs\/heads\///')
case "$branch" in
main)
echo "🚀 Deploying to production..."
# 触发生产部署
curl -X POST "https://deploy.example.com/hooks/production" \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-d "{\"ref\": \"$newrev\"}"
;;
develop)
echo "🧪 Deploying to staging..."
# 触发测试环境部署
curl -X POST "https://deploy.example.com/hooks/staging" \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-d "{\"ref\": \"$newrev\"}"
;;
release/*)
echo "📦 Creating release build..."
# 触发发布构建
;;
esac
# 发送通知
curl -X POST "https://slack.example.com/webhook" \
-H "Content-Type: application/json" \
-d "{\"text\": \"Branch $branch updated to $newrev\"}"
done
Husky - 现代 Git Hooks 管理
安装和配置
# 安装 Husky
npm install husky --save-dev
# 初始化 Husky
npx husky install
# 添加到 package.json 的 prepare 脚本
npm pkg set scripts.prepare="husky install"
# 创建 pre-commit hook
npx husky add .husky/pre-commit "npm run lint"
# 创建 commit-msg hook
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
Husky 配置文件
# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# 运行 lint-staged
npx lint-staged
# 运行测试
npm test
# .husky/commit-msg
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# 验证提交消息
npx --no -- commitlint --edit "$1"
# .husky/pre-push
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# 运行完整测试
npm run test:ci
# 类型检查
npm run type-check
lint-staged - 只检查暂存文件
配置
// package.json
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{css,scss,less}": ["stylelint --fix", "prettier --write"],
"*.{json,md,yml,yaml}": ["prettier --write"],
"*.py": ["black", "isort"]
}
}
// lint-staged.config.js(更复杂的配置)
module.exports = {
"*.{js,jsx,ts,tsx}": (filenames) => {
const files = filenames.join(" ");
return [
`eslint --fix ${files}`,
`prettier --write ${files}`,
// 只对变更的文件运行相关测试
`jest --findRelatedTests ${files} --passWithNoTests`,
];
},
"*.css": ["stylelint --fix", "prettier --write"],
"*.md": ["prettier --write", "markdownlint"],
// 运行类型检查(不限于特定文件)
"*.ts?(x)": () => "tsc --noEmit",
};
Commitlint - 提交消息规范
安装
npm install --save-dev @commitlint/cli @commitlint/config-conventional
配置
// commitlint.config.js
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
// 类型枚举
"type-enum": [
2,
"always",
[
"feat", // 新功能
"fix", // Bug 修复
"docs", // 文档
"style", // 格式
"refactor", // 重构
"perf", // 性能
"test", // 测试
"chore", // 杂项
"revert", // 回滚
"ci", // CI 配置
"build", // 构建
],
],
// 标题大小写
"subject-case": [2, "always", "lower-case"],
// 标题长度
"subject-max-length": [2, "always", 72],
// 正文换行长度
"body-max-line-length": [2, "always", 100],
// 不允许空标题
"subject-empty": [2, "never"],
// 不允许空类型
"type-empty": [2, "never"],
},
};
自定义规则
// commitlint.config.js
module.exports = {
extends: ["@commitlint/config-conventional"],
plugins: ["commitlint-plugin-function-rules"],
rules: {
// 自定义规则:检查 Issue 引用
"function-rules/references-empty": [
2,
"always",
(parsed) => {
const { footer } = parsed;
const hasReference = footer && /Refs: [A-Z]+-\d+/.test(footer);
if (!hasReference) {
return [
false,
"Commit must reference an issue (e.g., Refs: PROJ-123)",
];
}
return [true];
},
],
},
};
完整项目配置示例
package.json
{
"name": "my-project",
"scripts": {
"prepare": "husky install",
"lint": "eslint src --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,css,json}\"",
"type-check": "tsc --noEmit",
"test": "jest",
"test:ci": "jest --ci --coverage",
"commit": "git-cz"
},
"devDependencies": {
"@commitlint/cli": "^18.0.0",
"@commitlint/config-conventional": "^18.0.0",
"commitizen": "^4.3.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^8.0.0",
"husky": "^8.0.0",
"lint-staged": "^15.0.0",
"prettier": "^3.0.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css}": ["prettier --write"]
},
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
}
项目结构
project/
├── .husky/
│ ├── _/
│ │ └── husky.sh
│ ├── pre-commit
│ ├── commit-msg
│ └── pre-push
├── .github/
│ ├── workflows/
│ │ └── ci.yml
│ └── CODEOWNERS
├── src/
├── commitlint.config.js
├── lint-staged.config.js
├── .eslintrc.js
├── .prettierrc
├── package.json
└── README.md
自动化工作流示例
自动版本更新
#!/bin/bash
# scripts/release.sh
# 获取当前版本
current_version=$(node -p "require('./package.json').version")
echo "Current version: $current_version"
# 根据提交历史确定版本类型
if git log --format=%s $(git describe --tags --abbrev=0)..HEAD | grep -q "^feat"; then
version_type="minor"
elif git log --format=%s $(git describe --tags --abbrev=0)..HEAD | grep -q "BREAKING CHANGE"; then
version_type="major"
else
version_type="patch"
fi
echo "Version bump type: $version_type"
# 更新版本
npm version $version_type -m "chore: release v%s"
# 推送标签
git push --follow-tags
# 创建 GitHub Release
new_version=$(node -p "require('./package.json').version")
gh release create "v$new_version" --generate-notes
自动 CHANGELOG 生成
# 安装 conventional-changelog
npm install -g conventional-changelog-cli
# 生成 CHANGELOG
conventional-changelog -p angular -i CHANGELOG.md -s
# 或使用 standard-version
npx standard-version