跳转到内容
关系图谱

Git hooks 详解

3,361 字 14 分钟

属性 1
status
学习中

Git hooks 详解

Git hooks 是在特定 Git 事件发生时自动执行的脚本。它们是 Git 内置的自动化机制,可以在提交、推送、合并等操作前后触发自定义逻辑,用于代码质量检查、规范强制、自动化测试等。

一句话理解

Hooks 就是 Git 操作的拦截器——在 commit/push/merge 等动作的关键节点自动运行你的脚本。


核心概念

Hooks 的分类

分类Hook触发时机可阻止操作?
提交pre-commitcommit 执行前
prepare-commit-msg编辑器打开前(可修改消息)
commit-msg用户输入提交消息后
post-commitcommit 完成后
推送pre-pushpush 执行前
合并post-mergemerge 完成后
pre-rebaserebase 执行前
检出post-checkoutcheckout/switch 完成后
其他pre-auto-gc自动 gc 前
post-rewriteamend/rebase 重写提交后
服务端pre-receive服务端接收 push 前
update每个分支更新前
post-receive服务端接收 push 后

Hooks 的基本规则

bash
# 1. 位置:.git/hooks/ 目录下
ls .git/hooks/
# pre-commit.sample
# commit-msg.sample
# pre-push.sample
# ...(Git 默认提供 .sample 文件)

# 2. 启用:去掉 .sample 后缀
cp .git/hooks/pre-commit.sample .git/hooks/pre-commit

# 3. 权限:必须可执行(Linux/macOS)
chmod +x .git/hooks/pre-commit

# 4. 退出码:
# - 返回 0 = 成功,操作继续
# - 返回非 0 = 失败,操作被阻止(仅限 pre-* hooks)

# 5. 跳过 hooks(紧急情况)
git commit --no-verify    # 跳过 pre-commit 和 commit-msg
git push --no-verify      # 跳过 pre-push

客户端 Hooks 详解

pre-commit

最常用的 hook,在 git commit 执行前运行。常用于代码质量检查。

bash
#!/bin/bash
# .git/hooks/pre-commit

# 1. 运行 linter
echo "🔍 运行代码检查..."
npx eslint --cache $(git diff --cached --name-only --diff-filter=d -- '*.ts' '*.tsx' '*.js' '*.jsx')
if [ $? -ne 0 ]; then
    echo "❌ ESLint 检查失败,请修复后再提交"
    exit 1
fi

# 2. 运行格式化检查
echo "🎨 检查代码格式..."
npx prettier --check $(git diff --cached --name-only --diff-filter=d -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.css' '*.json')
if [ $? -ne 0 ]; then
    echo "❌ 格式不规范,请运行 npx prettier --write 修复"
    exit 1
fi

# 3. 检查是否有调试代码
echo "🐛 检查调试代码..."
STAGED_FILES=$(git diff --cached --name-only --diff-filter=d)
if echo "$STAGED_FILES" | xargs grep -l "console\.log\|debugger\|TODO:" 2>/dev/null; then
    echo "⚠️ 发现调试代码或 TODO,确认是否需要清理"
    # 这里可以选择 exit 1 阻止提交,或只是警告
fi

# 4. 检查文件大小
MAX_SIZE=5242880  # 5MB
for file in $(git diff --cached --name-only --diff-filter=d); do
    SIZE=$(wc -c < "$file" 2>/dev/null || echo 0)
    if [ "$SIZE" -gt "$MAX_SIZE" ]; then
        echo "❌ 文件 $file 太大 ($(($SIZE/1024/1024))MB),请使用 Git LFS"
        exit 1
    fi
done

echo "✅ 所有检查通过"
exit 0

commit-msg

验证提交消息格式,常用于强制 Conventional Commits 规范。

bash
#!/bin/bash
# .git/hooks/commit-msg

MSG_FILE=$1
MSG=$(cat "$MSG_FILE")

# Conventional Commits 格式验证
# type(scope): description
PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,100}$"

# 允许 merge 和 revert 提交
if echo "$MSG" | head -1 | grep -qE "^(Merge|Revert) "; then
    exit 0
fi

if ! echo "$MSG" | head -1 | grep -qE "$PATTERN"; then
    echo "❌ 提交消息格式不符合 Conventional Commits 规范"
    echo ""
    echo "正确格式: <type>(<scope>): <description>"
    echo ""
    echo "type 可选值:"
    echo "  feat     新功能"
    echo "  fix      Bug 修复"
    echo "  docs     文档变更"
    echo "  style    代码格式(不影响逻辑)"
    echo "  refactor 重构(不增加功能/不修复 bug)"
    echo "  perf     性能优化"
    echo "  test     测试相关"
    echo "  build    构建系统/外部依赖"
    echo "  ci       CI 配置"
    echo "  chore    其他杂务"
    echo ""
    echo "示例:"
    echo "  feat(auth): 添加 OAuth2 登录"
    echo "  fix: 修复支付金额精度问题"
    echo ""
    echo "你的提交消息: $MSG"
    exit 1
fi

exit 0

prepare-commit-msg

在编辑器打开前自动修改提交消息。可用于自动添加分支名、Issue 编号等。

bash
#!/bin/bash
# .git/hooks/prepare-commit-msg

COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2  # message, template, merge, squash, commit

# 不处理 merge/squash 等自动生成的消息
if [ "$COMMIT_SOURCE" = "merge" ] || [ "$COMMIT_SOURCE" = "squash" ]; then
    exit 0
fi

# 从分支名提取 Issue 编号
BRANCH=$(git branch --show-current)
ISSUE=$(echo "$BRANCH" | grep -oE '[A-Z]+-[0-9]+' | head -1)

if [ -n "$ISSUE" ]; then
    # 如果消息中还没有 Issue 编号,自动添加
    if ! grep -q "$ISSUE" "$COMMIT_MSG_FILE"; then
        sed -i "1s/$/ [$ISSUE]/" "$COMMIT_MSG_FILE"
    fi
fi

pre-push

git push 执行前运行。用于推送前的最后检查。

bash
#!/bin/bash
# .git/hooks/pre-push

REMOTE="$1"
URL="$2"

# 1. 禁止 push 到受保护分支
CURRENT=$(git rev-parse --abbrev-ref HEAD)
PROTECTED="main master develop"

for branch in $PROTECTED; do
    if [ "$CURRENT" = "$branch" ]; then
        echo "❌ 禁止直接推送到 $branch!请使用 Pull Request。"
        exit 1
    fi
done

# 2. push 前运行测试
echo "🧪 运行测试..."
npm test -- --watchAll=false
if [ $? -ne 0 ]; then
    echo "❌ 测试失败,请修复后再推送"
    exit 1
fi

# 3. 检查是否有未提交的修改
if ! git diff --quiet || ! git diff --cached --quiet; then
    echo "⚠️ 有未提交的修改,建议先提交或 stash"
fi

echo "✅ 推送检查通过"
exit 0

post-checkout / post-merge

在切换分支或合并后运行。常用于自动安装依赖。

bash
#!/bin/bash
# .git/hooks/post-checkout 或 .git/hooks/post-merge

# 检查 package.json 是否有变化
PREV_HEAD=$1
POST_HEAD=$2
IS_BRANCH_CHECKOUT=$3  # 1 = 分支切换, 0 = 文件检出

# 只在分支切换时运行(post-checkout)
if [ "$IS_BRANCH_CHECKOUT" = "0" ]; then
    exit 0
fi

# 检查依赖文件是否有变化
CHANGED_FILES=$(git diff --name-only $PREV_HEAD $POST_HEAD 2>/dev/null)

if echo "$CHANGED_FILES" | grep -q "package-lock.json\|package.json"; then
    echo "📦 检测到依赖变化,自动安装..."
    npm install
fi

if echo "$CHANGED_FILES" | grep -q "requirements.txt\|Pipfile.lock"; then
    echo "🐍 检测到 Python 依赖变化..."
    pip install -r requirements.txt
fi

if echo "$CHANGED_FILES" | grep -q "Cargo.lock\|Cargo.toml"; then
    echo "🦀 检测到 Rust 依赖变化..."
    cargo build
fi

# 检查数据库迁移
if echo "$CHANGED_FILES" | grep -q "migrations/"; then
    echo "🗄️ 检测到数据库迁移变化,请运行迁移命令"
fi

服务端 Hooks

pre-receive

在服务端接收 push 前运行。可用于强制策略检查。

bash
#!/bin/bash
# hooks/pre-receive(服务端)

while read OLDSHA NEWSHA REFNAME; do
    BRANCH=$(echo "$REFNAME" | sed 's|refs/heads/||')
    
    # 1. 禁止 force push 到 main
    if [ "$BRANCH" = "main" ]; then
        # 检查是否是 force push(OLDSHA 不是 NEWSHA 的祖先)
        if ! git merge-base --is-ancestor "$OLDSHA" "$NEWSHA" 2>/dev/null; then
            echo "❌ 禁止 force push 到 main!"
            exit 1
        fi
    fi
    
    # 2. 检查提交消息格式
    for sha in $(git rev-list $OLDSHA..$NEWSHA); do
        MSG=$(git log --format=%s -1 $sha)
        if ! echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|Merge|Revert)"; then
            echo "❌ 提交 $sha 的消息不符合规范: $MSG"
            exit 1
        fi
    done
    
    # 3. 检查大文件
    for sha in $(git rev-list $OLDSHA..$NEWSHA); do
        git diff-tree --no-commit-id -r $sha | while read MODE_OLD MODE_NEW SHA_OLD SHA_NEW STATUS FILE; do
            SIZE=$(git cat-file -s "$SHA_NEW" 2>/dev/null || echo 0)
            if [ "$SIZE" -gt 10485760 ]; then  # 10MB
                echo "❌ 文件 $FILE ($((SIZE/1024/1024))MB) 超过大小限制"
                exit 1
            fi
        done
    done
done

exit 0

post-receive

push 成功后触发。常用于自动部署、通知等。

bash
#!/bin/bash
# hooks/post-receive(服务端)

while read OLDSHA NEWSHA REFNAME; do
    BRANCH=$(echo "$REFNAME" | sed 's|refs/heads/||')
    
    # 推送到 main 时触发部署
    if [ "$BRANCH" = "main" ]; then
        echo "🚀 触发生产环境部署..."
        /opt/deploy/deploy-production.sh &
    fi
    
    # 推送到 develop 时触发 staging 部署
    if [ "$BRANCH" = "develop" ]; then
        echo "🔧 触发 staging 环境部署..."
        /opt/deploy/deploy-staging.sh &
    fi
    
    # 发送通知
    AUTHOR=$(git log -1 --format='%an' $NEWSHA)
    SUBJECT=$(git log -1 --format='%s' $NEWSHA)
    curl -s -X POST "https://hooks.slack.com/services/xxx" \
        -d "{\"text\": \"[$BRANCH] $AUTHOR: $SUBJECT\"}" > /dev/null
done

使用 Husky 管理 Hooks

为什么用 Husky?

.git/hooks/ 中的脚本不受版本控制(.git 目录不会被提交)。Husky 解决了这个问题,让 hooks 可以共享给整个团队。

安装和配置 Husky

bash
# 安装 Husky v9+
npm install -D husky

# 初始化
npx husky init
# 创建 .husky/ 目录和 pre-commit hook

# 项目结构
# .husky/
#   pre-commit      ← hooks 在这里
#   commit-msg
#   pre-push
# package.json      ← "prepare": "husky"

配置 pre-commit(lint-staged)

bash
# 安装 lint-staged
npm install -D lint-staged

# .husky/pre-commit
npx lint-staged

# package.json
{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss}": [
      "prettier --write"
    ],
    "*.{json,md}": [
      "prettier --write"
    ]
  }
}

配置 commit-msg(commitlint)

bash
# 安装 commitlint
npm install -D @commitlint/cli @commitlint/config-conventional

# 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'
    ]],
    'subject-max-length': [2, 'always', 100],
  }
};

# .husky/commit-msg
npx --no -- commitlint --edit $1

配置 pre-push

bash
# .husky/pre-push
npm test -- --watchAll=false

Husky 完整配置示例

json
// package.json
{
  "scripts": {
    "prepare": "husky",
    "lint": "eslint src/",
    "format": "prettier --write src/",
    "test": "jest",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "@commitlint/cli": "^18.0.0",
    "@commitlint/config-conventional": "^18.0.0"
  },
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,css}": ["prettier --write"]
  }
}

其他 Hooks 管理工具

pre-commit(Python 生态)

yaml
# .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
        args: ['--maxkb=1000']

  - 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
bash
# 安装
pip install pre-commit
pre-commit install

# 对所有文件运行
pre-commit run --all-files

# 更新 hooks 版本
pre-commit autoupdate

lefthook(Go 生态,跨语言)

yaml
# lefthook.yml
pre-commit:
  parallel: true
  commands:
    lint:
      glob: "*.{ts,tsx,js,jsx}"
      run: npx eslint {staged_files}
    format:
      glob: "*.{ts,tsx,js,jsx,css,json,md}"
      run: npx prettier --check {staged_files}
    typecheck:
      run: npx tsc --noEmit

commit-msg:
  commands:
    commitlint:
      run: npx commitlint --edit {1}

pre-push:
  commands:
    test:
      run: npm test -- --watchAll=false
bash
# 安装
# Windows: scoop install lefthook
# macOS: brew install lefthook
lefthook install

实际项目中的 Hooks 场景

场景 1:防止提交敏感信息

bash
#!/bin/bash
# .git/hooks/pre-commit 或 .husky/pre-commit

# 检查暂存文件中是否有敏感信息
STAGED=$(git diff --cached --name-only --diff-filter=d)

PATTERNS=(
    "AKIA[0-9A-Z]{16}"                    # AWS Access Key
    "password\s*=\s*['\"][^'\"]+['\"]"     # 硬编码密码
    "-----BEGIN (RSA |DSA |EC )?PRIVATE KEY-----"  # 私钥
    "sk_live_[a-zA-Z0-9]{24,}"            # Stripe 密钥
    "ghp_[a-zA-Z0-9]{36}"                 # GitHub PAT
    "xox[baprs]-[a-zA-Z0-9-]+"            # Slack Token
)

FOUND=0
for file in $STAGED; do
    for pattern in "${PATTERNS[@]}"; do
        if git diff --cached "$file" | grep -qE "$pattern"; then
            echo "❌ 在 $file 中发现疑似敏感信息: $pattern"
            FOUND=1
        fi
    done
done

if [ "$FOUND" -eq 1 ]; then
    echo ""
    echo "请将敏感信息移到环境变量或 .env 文件(已加入 .gitignore)"
    echo "如确认安全,使用 git commit --no-verify 跳过检查"
    exit 1
fi

场景 2:自动生成 CHANGELOG

bash
#!/bin/bash
# .git/hooks/post-commit

# 从 Conventional Commits 自动提取变更日志
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LAST_TAG" ]; then
    RANGE="HEAD"
else
    RANGE="$LAST_TAG..HEAD"
fi

echo "# Changelog (since $LAST_TAG)" > CHANGELOG-DRAFT.md
echo "" >> CHANGELOG-DRAFT.md

echo "## Features" >> CHANGELOG-DRAFT.md
git log $RANGE --oneline --grep="^feat" --format="- %s (%h)" >> CHANGELOG-DRAFT.md
echo "" >> CHANGELOG-DRAFT.md

echo "## Bug Fixes" >> CHANGELOG-DRAFT.md
git log $RANGE --oneline --grep="^fix" --format="- %s (%h)" >> CHANGELOG-DRAFT.md
echo "" >> CHANGELOG-DRAFT.md

echo "## Other Changes" >> CHANGELOG-DRAFT.md
git log $RANGE --oneline --grep="^docs\|^chore\|^refactor\|^perf" --format="- %s (%h)" >> CHANGELOG-DRAFT.md

场景 3:自动运行数据库迁移检查

bash
#!/bin/bash
# .git/hooks/post-merge

# 检查是否有新的数据库迁移文件
CHANGED=$(git diff HEAD@{1} --name-only --diff-filter=A | grep "migrations/" || true)

if [ -n "$CHANGED" ]; then
    echo "🗄️ 检测到新的数据库迁移:"
    echo "$CHANGED"
    echo ""
    echo "请运行以下命令应用迁移:"
    echo "  python manage.py migrate"
    echo "  # 或"
    echo "  npx prisma migrate deploy"
fi

场景 4:分支命名规范检查

bash
#!/bin/bash
# .git/hooks/pre-push

BRANCH=$(git rev-parse --abbrev-ref HEAD)

# 允许的分支名模式
PATTERN="^(main|develop|release/.*|feature/.*|fix/.*|hotfix/.*|chore/.*|docs/.*)$"

if ! echo "$BRANCH" | grep -qE "$PATTERN"; then
    echo "❌ 分支名 '$BRANCH' 不符合命名规范"
    echo ""
    echo "允许的格式:"
    echo "  feature/描述    — 功能开发"
    echo "  fix/描述        — Bug 修复"
    echo "  hotfix/描述     — 紧急修复"
    echo "  release/v*      — 发布分支"
    echo "  chore/描述      — 杂务"
    echo "  docs/描述       — 文档"
    exit 1
fi

场景 5:Windows PowerShell Hooks

powershell
#!/usr/bin/env pwsh
# .git/hooks/pre-commit(PowerShell 版本)

$staged = git diff --cached --name-only --diff-filter=d | Where-Object { $_ -match '\.(ts|tsx|js|jsx)$' }

if ($staged) {
    Write-Host "🔍 运行 ESLint..." -ForegroundColor Cyan
    npx eslint $staged
    if ($LASTEXITCODE -ne 0) {
        Write-Host "❌ ESLint 检查失败" -ForegroundColor Red
        exit 1
    }
    
    Write-Host "🎨 运行 Prettier..." -ForegroundColor Cyan
    npx prettier --check $staged
    if ($LASTEXITCODE -ne 0) {
        Write-Host "❌ 格式检查失败" -ForegroundColor Red
        exit 1
    }
}

Write-Host "✅ 检查通过" -ForegroundColor Green
exit 0

Hooks 调试与管理

调试技巧

bash
# 1. 手动运行 hook 脚本
./.git/hooks/pre-commit

# 2. 查看 hook 执行过程
GIT_TRACE=1 git commit -m "test"

# 3. 临时跳过 hooks
git commit --no-verify -m "紧急修复,跳过检查"
git push --no-verify

# 4. 检查 hook 是否可执行
ls -la .git/hooks/pre-commit
# -rwxr-xr-x  pre-commit  ← 需要 x 权限

# 5. Windows 上的 shebang
# 第一行必须指定解释器
#!/bin/bash        # Bash(推荐)
#!/usr/bin/env pwsh  # PowerShell
#!/usr/bin/env python3  # Python
#!/usr/bin/env node     # Node.js

Hook 执行时间优化

bash
# 问题:pre-commit hook 运行太慢(lint 整个项目)
# 解决:只检查暂存的文件

# ❌ 慢:检查所有文件
npx eslint src/

# ✅ 快:只检查暂存的文件
npx eslint $(git diff --cached --name-only --diff-filter=d -- '*.ts' '*.tsx')

# ✅✅ 更快:使用 lint-staged(自动处理)
npx lint-staged

# 并行运行多个检查
(npx eslint --cache . &)
(npx tsc --noEmit &)
wait

自定义 hooks 目录

bash
# 将 hooks 放在版本控制的目录中
git config core.hooksPath .githooks

# 项目结构
# .githooks/
#   pre-commit
#   commit-msg
#   pre-push

# 这样 hooks 就跟随代码提交,团队共享
# 无需 Husky 等工具

常见陷阱

陷阱 1:Hooks 不受版本控制
bash
# .git/hooks/ 中的文件不会被 git add
# 新成员克隆后没有任何 hooks!

# ✅ 解决方案(选其一):
# 1. 使用 Husky(.husky/ 目录受版本控制)
# 2. 使用 core.hooksPath 指向项目中的目录
# 3. 使用 pre-commit(Python)
# 4. 提供安装脚本
陷阱 2:--no-verify 被滥用
bash
# ❌ 养成跳过 hooks 的习惯
git commit --no-verify -m "快速提交"

# ✅ 只在真正紧急时使用
# ✅ 服务端 hooks 无法跳过(最后一道防线)
陷阱 3:Hook 脚本没有可执行权限
bash
# Linux/macOS 上忘记 chmod
git commit -m "test"
# hook 被忽略,没有任何提示!

# ✅ 确保权限
chmod +x .git/hooks/pre-commit
chmod +x .githooks/*
陷阱 4:Hook 中路径问题
bash
# Hook 的工作目录是仓库根目录
# ❌ 使用相对路径可能出错
cd src && npx eslint .

# ✅ 始终使用仓库根目录的路径
npx eslint src/
陷阱 5:Hook 执行时间过长
bash
# ❌ pre-commit 中运行完整测试套件(几分钟)
npm test   # 200 个测试,3 分钟

# ✅ pre-commit 只做快速检查(< 10 秒)
npx lint-staged   # 只检查暂存文件

# ✅ 完整测试放在 pre-push 或 CI 中

常见问题

客户端 hooks 可以被绕过吗?

可以--no-verify 可以跳过 pre-commitcommit-msgpre-push 等客户端 hooks。因此,关键规则应该在服务端 hooks(如 GitHub Branch Protection)中强制。

Husky vs pre-commit vs lefthook,选哪个?
工具语言生态优势
HuskyNode.js社区最大,配合 lint-staged
pre-commitPython丰富的预置 hooks
lefthookGo(跨语言)速度快,配置简单

根据项目的主要技术栈选择即可。

hooks 中能访问暂存区的内容吗?
bash
# git diff --cached 查看暂存区的变更
git diff --cached --name-only     # 暂存的文件列表
git diff --cached                  # 暂存的具体变更
git diff --cached --diff-filter=A  # 新增的文件
git diff --cached --diff-filter=M  # 修改的文件
git diff --cached --diff-filter=D  # 删除的文件
如何用 Node.js 写 hooks?
javascript
#!/usr/bin/env node
// .git/hooks/commit-msg

const fs = require('fs');
const msg = fs.readFileSync(process.argv[2], 'utf8').trim();
const pattern = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,100}$/;

if (!pattern.test(msg.split('\n')[0])) {
  console.error('❌ 提交消息格式不符合规范');
  process.exit(1);
}

速查表

目标Hook可阻止?常见用途
提交前检查pre-commitlint、格式化、安全检查
修改提交消息prepare-commit-msg自动添加 Issue 编号
验证提交消息commit-msg强制 Conventional Commits
提交后通知post-commit通知、日志
推送前检查pre-push测试、分支保护
分支切换后post-checkout自动安装依赖
合并后post-merge自动安装依赖、迁移提醒
服务端接收前pre-receive强制策略、权限控制
服务端接收后post-receive自动部署、通知

相关命令

参考资料