强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Git 服务器搭建完全指南 / 第 8 章 - 服务端钩子

第 8 章 - 服务端钩子

Git 钩子(Hooks)是在特定事件发生时自动执行的脚本。服务端钩子运行在 Git 服务器上,可用于代码质量检查、自动部署、通知推送等自动化工作流。

8.1 钩子类型总览

8.1.1 服务端钩子

钩子触发时机用途可阻止推送
pre-receive接收推送前,最先执行全局策略检查
update每个分支更新前分支级检查
post-receive推送完成后通知、部署、日志

8.1.2 执行顺序

客户端 git push
    │
    ▼
pre-receive    ← 所有引用的总体检查(一次执行)
    │
    ▼
update         ← 每个被更新的分支各执行一次
    │
    ▼
post-receive   ← 推送完成后的操作(一次执行)

8.1.3 客户端钩子(参考)

钩子触发时机
pre-commitgit commit
commit-msg编辑完提交信息后
pre-pushgit push

8.2 pre-receive 钩子

pre-receive 在服务器接收推送数据前执行,用于全局策略检查。任何非零退出码都会拒绝整个推送。

8.2.1 输入格式

通过标准输入接收三元组(每行一个):

<旧引用SHA> <新引用SHA> <引用名>

示例:

0000000000000000000000000000000000000000 abc1234567890 refs/heads/feature/new-login
abc1234567890 def9876543210 refs/heads/main
def9876543210 0000000000000000000000000000000000000000 refs/heads/old-branch

8.2.2 禁止强制推送

#!/bin/bash
# pre-receive: 禁止对 main 分支强制推送

protected_branch="refs/heads/main"

while read oldrev newrev refname; do
    if [ "$refname" = "$protected_branch" ]; then
        # 检查是否为非快进推送
        if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then
            # 检查 oldrev 是否是 newrev 的祖先
            if ! git merge-base --is-ancestor "$oldrev" "$newrev" 2>/dev/null; then
                echo "ERROR: 非快进推送到 $protected_branch 被禁止"
                echo "请使用 'git pull --rebase' 合并最新代码后再推送"
                exit 1
            fi
        fi
    fi
done

exit 0

8.2.3 提交信息规范检查

#!/bin/bash
# pre-receive: 检查提交信息格式

while read oldrev newrev refname; do
    # 跳过删除操作
    if [ "$newrev" = "0000000000000000000000000000000000000000" ]; then
        continue
    fi
    
    # 跳过新分支(从零开始)
    if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
        oldrev=$(git rev-parse "$newrev^" 2>/dev/null || echo "4b825dc642cb6eb9a060e54bf899d15f4f20c54f")
    fi
    
    # 遍历所有新的提交
    git rev-list "$oldrev".."$newrev" 2>/dev/null | while read commit; do
        msg=$(git log --format=%B -n 1 "$commit")
        subject=$(echo "$msg" | head -1)
        
        # 检查格式: type(scope): description
        if ! echo "$subject" | grep -qE '^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?: .{1,72}$'; then
            echo "ERROR: 提交信息格式错误"
            echo "  Commit: $commit"
            echo "  Subject: $subject"
            echo ""
            echo "  期望格式: type(scope): description"
            echo "  类型: feat|fix|docs|style|refactor|test|chore|perf|ci|build"
            echo "  示例: feat(auth): add OAuth2 login support"
            exit 1
        fi
    done
    
    [ $? -ne 0 ] && exit 1
done

exit 0

8.2.4 文件大小和类型检查

#!/bin/bash
# pre-receive: 检查大文件和敏感文件

MAX_FILE_SIZE=$((50 * 1024 * 1024))  # 50MB
BLOCKED_EXTENSIONS="exe,dll,so,dylib,jar,war,ear,zip,tar,gz,rar,7z"
SENSITIVE_PATTERNS="(\.env|\.pem|\.key|\.p12|\.pfx|id_rsa|id_ed25519)"

while read oldrev newrev refname; do
    if [ "$newrev" = "0000000000000000000000000000000000000000" ]; then
        continue
    fi
    
    if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
        oldrev="4b825dc642cb6eb9a060e54bf899d15f4f20c54f"
    fi
    
    # 获取变更的文件
    git diff --name-status "$oldrev".."$newrev" | while read status filepath; do
        # 检查敏感文件
        if echo "$filepath" | grep -qEi "$SENSITIVE_PATTERNS"; then
            echo "ERROR: 检测到敏感文件: $filepath"
            echo "请将敏感文件添加到 .gitignore"
            exit 1
        fi
        
        # 检查文件扩展名
        ext="${filepath##*.}"
        if echo "$BLOCKED_EXTENSIONS" | grep -qw "$ext"; then
            echo "ERROR: 禁止推送二进制文件: $filepath (.$ext)"
            exit 1
        fi
        
        # 检查文件大小(仅新增和修改的文件)
        if [ "$status" = "A" ] || [ "$status" = "M" ]; then
            size=$(git cat-file -s "$newrev":"$filepath" 2>/dev/null || echo 0)
            if [ "$size" -gt "$MAX_FILE_SIZE" ]; then
                echo "ERROR: 文件过大: $filepath ($((size / 1024 / 1024))MB > 50MB)"
                echo "请使用 Git LFS 管理大文件"
                exit 1
            fi
        fi
    done
    
    [ $? -ne 0 ] && exit 1
done

exit 0

8.3 update 钩子

update 钩子在每个被更新的分支上分别执行,参数通过命令行传入。

8.3.1 参数说明

#!/bin/bash
# update <refname> <oldrev> <newrev>

refname="$1"
oldrev="$2"
newrev="$3"

echo "Branch: $refname"
echo "Old: $oldrev"
echo "New: $newrev"

8.3.2 分支命名规范检查

#!/bin/bash
# update: 检查分支命名规范

refname="$1"
oldrev="$2"
newrev="$3"

# 跳过标签
[[ "$refname" == refs/tags/* ]] && exit 0

# 分支命名规范: feature/*, bugfix/*, hotfix/*, release/*, dev, main
valid_patterns=(
    "^refs/heads/main$"
    "^refs/heads/dev$"
    "^refs/heads/develop$"
    "^refs/heads/feature/[a-z0-9._-]+$"
    "^refs/heads/bugfix/[a-z0-9._-]+$"
    "^refs/heads/hotfix/[a-z0-9._-]+$"
    "^refs/heads/release/[0-9]+\.[0-9]+$"
)

valid=false
for pattern in "${valid_patterns[@]}"; do
    if [[ "$refname" =~ $pattern ]]; then
        valid=true
        break
    fi
done

if [ "$valid" = false ]; then
    echo "ERROR: 分支名不符合规范: $refname"
    echo ""
    echo "允许的分支命名模式:"
    echo "  main"
    echo "  dev / develop"
    echo "  feature/<name>"
    echo "  bugfix/<name>"
    echo "  hotfix/<name>"
    echo "  release/<version>"
    echo ""
    echo "示例: feature/user-login, bugfix/fix-crash, release/2.1"
    exit 1
fi

exit 0

8.4 post-receive 钩子

post-receive 在推送完成后执行,用于通知、自动部署、日志记录等。由于推送已完成,退出码不影响推送结果。

8.4.1 自动部署

#!/bin/bash
# post-receive: 推送到 main 分支时自动部署

DEPLOY_DIR="/var/www/app"
DEPLOY_BRANCH="refs/heads/main"
LOG_FILE="/var/log/git-deploy.log"

while read oldrev newrev refname; do
    if [ "$refname" = "$DEPLOY_BRANCH" ]; then
        echo "$(date '+%Y-%m-%d %H:%M:%S') - Deploy triggered by push to main" >> "$LOG_FILE"
        
        # 导出代码到部署目录
        GIT_WORK_TREE="$DEPLOY_DIR" git checkout -f main >> "$LOG_FILE" 2>&1
        
        # 执行部署脚本
        if [ -x "$DEPLOY_DIR/scripts/deploy.sh" ]; then
            cd "$DEPLOY_DIR"
            ./scripts/deploy.sh >> "$LOG_FILE" 2>&1
        fi
        
        echo "$(date '+%Y-%m-%d %H:%M:%S') - Deploy completed" >> "$LOG_FILE"
        
        # 通知(可选)
        echo "✅ 已自动部署 main 分支到 $DEPLOY_DIR"
    fi
done

8.4.2 Webhook 通知

#!/bin/bash
# post-receive: 发送 Webhook 通知

WEBHOOK_URL="https://hooks.example.com/git-push"
BOT_NAME="Git Bot"

while read oldrev newrev refname; do
    branch="${refname#refs/heads/}"
    
    # 获取提交信息
    if [ "$newrev" != "0000000000000000000000000000000000000000" ]; then
        author=$(git log -1 --format='%an' "$newrev")
        message=$(git log -1 --format='%s' "$newrev")
        commit_count=$(git rev-list "$oldrev".."$newrev" --count 2>/dev/null || echo "?")
    else
        author="system"
        message="Branch deleted"
        commit_count=0
    fi
    
    # 发送通知到企业微信/钉钉/Slack
    curl -s -X POST "$WEBHOOK_URL" \
      -H "Content-Type: application/json" \
      -d "{
        \"msgtype\": \"markdown\",
        \"markdown\": {
          \"content\": \"**Git 推送通知**\n> 仓库: $(basename $(pwd) .git)\n> 分支: $branch\n> 作者: $author\n> 提交数: $commit_count\n> 信息: $message\"
        }
      }" > /dev/null 2>&1 &
done

8.4.3 邮件通知

#!/bin/bash
# post-receive: 发送邮件通知

REPO_NAME=$(basename $(pwd) .git)
NOTIFY_EMAIL="[email protected]"

while read oldrev newrev refname; do
    branch="${refname#refs/heads/}"
    
    if [ "$newrev" = "0000000000000000000000000000000000000000" ]; then
        continue
    fi
    
    # 生成变更摘要
    summary=$(git log --format="- %s (%an)" "$oldrev".."$newrev" 2>/dev/null | head -20)
    
    # 发送邮件
    cat << EMAIL | mail -s "[Git] ${REPO_NAME}: ${branch} updated" "$NOTIFY_EMAIL"
仓库: ${REPO_NAME}
分支: ${branch}
时间: $(date '+%Y-%m-%d %H:%M:%S')

提交记录:
${summary}
EMAIL
done

8.4.4 综合 post-receive 钩子

#!/bin/bash
# post-receive: 综合钩子模板

REPO_NAME=$(basename $(pwd) .git)
LOG_FILE="/var/log/git-${REPO_NAME}.log"

log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') [$REPO_NAME] $1" >> "$LOG_FILE"
}

while read oldrev newrev refname; do
    branch="${refname#refs/heads/}"
    
    # === 日志记录 ===
    if [ "$newrev" != "0000000000000000000000000000000000000000" ]; then
        author=$(git log -1 --format='%an <%ae>' "$newrev")
        message=$(git log -1 --format='%s' "$newrev")
        log "PUSH: $branch by $author - $message"
    else
        log "DELETE: branch $branch"
    fi
    
    # === 自动部署 (仅 main 分支) ===
    if [ "$branch" = "main" ] && [ "$newrev" != "0000000000000000000000000000000000000000" ]; then
        log "DEPLOY: Starting auto-deploy for main"
        DEPLOY_DIR="/var/www/${REPO_NAME}"
        mkdir -p "$DEPLOY_DIR"
        GIT_WORK_TREE="$DEPLOY_DIR" git checkout -f main 2>> "$LOG_FILE"
        log "DEPLOY: Completed"
    fi
    
    # === Webhook 通知 ===
    if command -v curl &> /dev/null && [ -n "${WEBHOOK_URL:-}" ]; then
        curl -s -X POST "$WEBHOOK_URL" \
          -H "Content-Type: application/json" \
          -d "{\"repo\":\"$REPO_NAME\",\"branch\":\"$branch\",\"author\":\"${author:-system}\"}" \
          > /dev/null 2>&1 &
    fi
done

exit 0

8.5 Gitea/Forgejo/GitLab Webhooks

除了原生 Git 钩子,现代 Git 平台还提供 Web 界面配置的 Webhooks。

8.5.1 Gitea Webhook 配置

# 通过 API 创建 Webhook
curl -s -X POST -H "Authorization: token $TOKEN" \
  -H "Content-Type: application/json" \
  "$GITEA_URL/api/v1/repos/owner/repo/hooks" \
  -d '{
    "type": "slack",
    "active": true,
    "config": {
      "url": "https://hooks.slack.com/services/xxx/yyy/zzz",
      "content_type": "json"
    },
    "events": ["push", "pull_request", "issues"]
  }'

8.5.2 通用 Webhook 接收器

#!/usr/bin/env python3
# webhook-receiver.py - 通用 Git Webhook 接收器

from flask import Flask, request, jsonify
import subprocess
import json
import hmac
import hashlib

app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret"

@app.route('/webhook', methods=['POST'])
def handle_webhook():
    # 验证签名(如设置了 secret)
    signature = request.headers.get('X-Gitea-Signature', '')
    if WEBHOOK_SECRET:
        expected = hmac.new(
            WEBHOOK_SECRET.encode(),
            request.data,
            hashlib.sha256
        ).hexdigest()
        if not hmac.compare_digest(signature, expected):
            return jsonify({"error": "Invalid signature"}), 403

    payload = request.json
    event = request.headers.get('X-Gitea-Event', 'unknown')
    
    print(f"Event: {event}")
    print(f"Repo: {payload.get('repository', {}).get('full_name', 'N/A')}")
    
    if event == 'push':
        branch = payload.get('ref', '').replace('refs/heads/', '')
        pusher = payload.get('pusher', {}).get('login', 'unknown')
        commits = len(payload.get('commits', []))
        print(f"Push: {branch} by {pusher} ({commits} commits)")
        
        # 触发 CI/CD 或部署
        if branch == 'main':
            subprocess.Popen(['./deploy.sh', 
                payload['repository']['full_name']])
    
    return jsonify({"status": "ok"}), 200

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=9000)

8.6 钩子部署指南

8.6.1 裸仓库钩子

# 将钩子脚本放在仓库的 hooks/ 目录
cd /opt/git/project.git/hooks/

# 创建 pre-receive 钩子
cat > pre-receive << 'SCRIPT'
#!/bin/bash
# your pre-receive logic here
SCRIPT

chmod +x pre-receive

# 创建 post-receive 钩子
cat > post-receive << 'SCRIPT'
#!/bin/bash
# your post-receive logic here
SCRIPT

chmod +x post-receive

8.6.2 批量部署钩子

#!/bin/bash
# deploy-hooks.sh - 批量部署钩子到所有仓库

GIT_ROOT="/opt/git"
HOOK_SOURCE="/opt/git-hooks"

for repo in $(find "$GIT_ROOT" -name "*.git" -type d); do
    echo "Deploying hooks to: $repo"
    
    for hook in pre-receive update post-receive; do
        if [ -f "$HOOK_SOURCE/$hook" ]; then
            cp "$HOOK_SOURCE/$hook" "$repo/hooks/$hook"
            chmod +x "$repo/hooks/$hook"
        fi
    done
done

echo "Hooks deployed to all repositories"

8.6.3 GitLab 服务端钩子

# /etc/gitlab/gitlab.rb
gitaly['custom_hooks_dir'] = "/opt/gitlab-custom-hooks"
# 创建钩子目录结构
sudo mkdir -p /opt/gitlab-custom-hooks/pre-receive.d
sudo mkdir -p /opt/gitlab-custom-hooks/post-receive.d

# 放置钩子脚本
sudo cp pre-receive.sh /opt/gitlab-custom-hooks/pre-receive.d/
sudo chmod +x /opt/gitlab-custom-hooks/pre-receive.d/*.sh

# 应用配置
sudo gitlab-ctl reconfigure

8.7 钩子调试

#!/bin/bash
# 调试 pre-receive 钩子

# 在钩子开头添加调试输出
exec 2>/tmp/hook-debug.log
set -x

# 测试推送并查看日志
# git push origin main
# cat /tmp/hook-debug.log
# 手动测试钩子
echo "abc123 def456 refs/heads/main" | /opt/git/project.git/hooks/pre-receive

8.8 扩展阅读


本章小结

学到了什么关键要点
钩子类型pre-receive(全局检查)、update(分支检查)、post-receive(后续操作)
pre-receive禁止强制推送、提交规范检查、文件大小/类型检查
update分支命名规范检查、分支级权限控制
post-receive自动部署、Webhook 通知、邮件通知
WebhooksGitea/GitLab Web 界面配置的 HTTP 回调
部署方式裸仓库 hooks/ 目录、GitLab custom_hooks_dir

下一章:第 9 章 - 镜像与备份 — 配置仓库镜像同步和完善的备份策略。