利用GitLab pre-receive钩子强制规范Commit消息,从源头拦截垃圾提交
2026/9/7 3:08:41 网站建设 项目流程

简介:一份基于Go语言实现的GitLab pre-receive钩子资源,面向需要规范提交信息、加强仓库管理的GitLab管理员或后端开发者。资源围绕commit消息检查场景,提供可直接参考的钩子程序与配套说明,适合希望用代码自动化约束推送行为的团队。包内共4个文件,以Go源码为主,辅以许可证、忽略规则和说明文档,压缩包仅3KB,轻量易读。已有1982人学习下载。通过这份资源,读者可理解pre-receive钩子的执行流程,掌握用Go解析ref、获取最新提交信息并输出校验结果的方法,也能根据示例扩展为检查作者、限制分支、记录日志等更严格的仓库策略。 做团队 GitLab 代码托管已经两三年了,最让我头疼的其实不是权限配置,也不是仓库迁移,而是 commit 消息。大部分 developer 写提交信息时真的是放飞自我,什么 "fix"、"update"、"aaa"、甚至 "111" 都有,等到做版本回溯或者 Code Review 的时候,对着几百条含义不明的提交记录,整个人都是崩溃的。

所以我花了半天时间,给 GitLab 加了一个 pre-receive 钩子,专门用来检查 commit 消息。它做的事一句话就能讲清楚:在你执行 git push 的时候,GitLab 服务端自动检查这批 commit 的 message 格式,不合法就直接拒绝推送,并把错误原因原样回显给你。从源头上堵住垃圾提交信息,而不是等事后翻 log 再骂人。

这篇文章我把这个钩子的原理、完整脚本、部署方法、以及我在测试过程中踩过的坑都整理出来,用的是最简单直接的 Bash 实现,不需要装额外依赖,也不需要懂 Ruby 和 Go,只要你有 GitLab 服务器的文件权限,照着做就能跑起来。

1. 为什么要给 GitLab 加一道 commit 消息检查

先说一个现实问题:很多团队不是没有 commit 规范,而是规范停留在文档里。嘴上说着要按 Conventional Commits 写,实际上只要不强制,效率至上的开发者们就会用最短字符串完成任务,反正代码能跑,review 能过。

我见过最典型的一次事故:项目要出一个紧急修复版本,需要从 git log 里找出哪些提交包含了安全补丁,结果提交消息全是 "fix stuff"、"wocao"、空消息。运维同事把 200 多条提交一条条翻,翻了两个小时也没搞清楚哪个提交是哪个功能,最后只能整个分支合上去。

这种时候你就会发现,事后整理永远比事前拦截痛苦得多。在 push 环节加一道服务端检查,其实是用最小的成本换长期的账目清晰。

我选用的方案是 GitLab 的 pre-receive hook。它属于 Git 自带的服务端钩子,在服务端收到 push 请求、真正把引用(分支、标签)更新之前执行。如果钩子返回非 0 退出码,这次 push 就会被拒绝,客户端会看到钩子输出的所有内容。

对比一下其他检查方式:

检查方式执行位置优点缺点
客户端 pre-commit hook开发者本地提交时立即提示开发者可以绕过,比如 git commit --no-verify
CI 流水线检查推送后可做复杂检查代码已经上来了,发现问题需要二次修复
服务端 pre-receive hookGitLab 服务端不可绕过,强制生效管理员需要维护服务端脚本

服务端钩子最大的价值是不可绕过。无论开发者用的是 IDEA 自带的提交按钮、SourceTree、还是命令行的 git commit --no-verify,只要 push 到远程,都会被 pre-receive 拦下来。所以最终我选了这条路,一劳永逸。

2. 先搞懂 pre-receive 钩子怎么工作

有人可能对 pre-receive 比较陌生,我先拆一下原理,搞清楚之后写脚本不会走弯路。

2.1 Git 钩子家族里 pre-receive 的位置

Git 的钩子分布在三个层级:

  • 客户端钩子:比如 pre-commit、commit-msg、pre-push,运行在开发者自己电脑上。
  • 服务端钩子:包括 pre-receive、update、post-receive,运行在远程仓库所在的服务器上。

pre-receive 在服务端收到的 push 请求中,每批推送只执行一次。它的输入不是参数,而是从标准输入(stdin)读取多行数据,每一行格式是:

<旧引用值> <新引用值> <引用名称>

举个例子,你推送一个分支,把本地的 main 从 abc1234 更新到 def5678,那么 pre-receive 的 stdin 里就会有一行:

abc1234 def5678 refs/heads/main

其中 abc1234 是更新前的 commit SHA,def5678 是更新后的 commit SHA,refs/heads/main 是这次要更新的分支引用。

如果你一次推送了 3 个分支,stdin 里就有 3 行。如果推送的是新分支,旧引用值是 40 个 0;如果是删除分支,新引用值是 40 个 0。

注意:pre-receive 的 stdin 可能一次包含多行引用,所以写脚本时千万不要 read 一次就完事,要用 while 循环逐行处理。

2.2 GitLab 如何调用和解析钩子输出

GitLab 的仓库,在相关二进制目录下会自动识别自定义钩子。它找的路径是:

仓库存储路径/group/repo.git/custom_hooks/pre-receive

以 Omnibus 安装方式为例,仓库路径通常是:

/var/opt/gitlab/git-data/repositories/<group>/<repo>.git/custom_hooks/pre-receive

只要这个文件存在且有可执行权限,GitLab 就会在相应操作执行前运行它。钩子向 stdout 或 stderr 输出的内容会被 GitLab 原样转发给客户端,最终显示在开发者的终端里。向 stderr 输出提示是一种更稳妥的做法,因为某些 Git 客户端对 stdout 和 stderr 的处理方式不同,直接输出到 stderr 能保证在 IDEA、SourceTree 里都可见。

2.3 为什么选 pre-receive 而不是 update

Git 还有一个 update 钩子,它也是服务端钩子,和 pre-receive 唯一的区别是:update 针对每个引用执行一次,而 pre-receive 每批推送只执行一次。

按说检查 commit 消息用 update 也可以,但我更推荐 pre-receive,原因是它拿到的 stdin 包含所有引用的变更,你可以在一个脚本里统一判断哪些分支需要检查、哪些分支可以跳过,逻辑更集中。而且 update 钩子不带状态信息,你得自己通过 GitLab 的环境变量去猜当前正在处理哪个引用,写起来绕一些。

3. 设计一个可落地的 commit 检查规则

3.1 消息格式:Conventional Commits 轻量版

很多团队采用的规范来源是 Conventional Commits,核心思想就是 commit 消息要带 type。我设计的检查规则如下:

  • 消息首行必须匹配指定格式,且 type 属于白名单:
    type(scope): subject
    type 可选值:feat、fix、docs、style、refactor、perf、test、chore、build、ci、revert。 scope 可选,建议限制为小写字母和数字,比如feat(user)fix(login)。 subject 是描述内容,必须非空。
  • 消息整体长度(首行)建议不超过 100 字符,避免 IDEA 和 GitLab Web 界面显示时折行。
  • 允许空白 commit(GitLab 的 Merge Request 操作可能产生)跳过检查。

这个规则比完整版 Conventional Commits 简单,没有强制 body 和 footer,但已经能把 "aaa" 这类消息防掉了。

3.2 分支跳过策略

不是所有分支都需要同样严格的检查。我做了以下设计:

  • 分支名匹配 master、main、release、hotfix 的,必须严格检查。
  • 开发者自己的 feature 分支,也检查,但规则稍微宽松一点,允许以 git merge 自动生成的 "Merge branch" 开头。
  • 删除分支或空提交直接跳过。

如果你团队觉得 feature 分支不需要强制,可以把 branch 判断条件改一下,让 feature 分支跳过。但从可追溯性角度讲,我建议最终所有分支都查,省得某些开发者一直往 feature 分支堆垃圾提交,合并时一团乱麻。

3.3 完整脚本实现

脚本是用纯 Bash 写的,不依赖 Ruby 和 Python,GitLab 自带的 Git 环境可以直接运行。核心思路是:通过git rev-list获取本次 push 涉及的所有 commit,再逐个读取 commit message 做正则匹配。

#!/usr/bin/env bash # pre-receive: 检查 gitlab commit 消息格式 # 规则: type(scope): subject # type 取值为 feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert zero="0000000000000000000000000000000000000000" # 消息白名单正则 type_pattern="(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)" pattern="^${type_pattern}(\([a-z0-9]+\))?!?: .+$" # 分支跳过的正则,Merge 分支自动生成的提交不拦 merge_pattern="^Merge branch" max_subject_length=100 fail_flag=0 function check_commit_messages() { local oldrev="$1" local newrev="$2" local refname="$3" local rev_list # 如果是删除分支,不检查 if [ "$newrev" = "$zero" ]; then return 0 fi # 新分支的 oldrev 是全零,单独处理 if [ "$oldrev" = "$zero" ]; then rev_list=$(git rev-list "$newrev" --not --branches 2>/dev/null) else rev_list=$(git rev-list "$oldrev".."$newrev" 2>/dev/null) fi if [ -z "$rev_list" ]; then return 0 fi # 为防某些极端情况下提交数量过大,最多检查最近 100 条 # 普通 feature 分支几十个提交足够 local count=0 for commit in $rev_list; do count=$((count + 1)) if [ "$count" -gt 100 ]; then echo "[INFO] 提交数量超过 100,只检查前 100 条提交信息。" >&2 break fi local subject subject=$(git log -1 --format=%s "$commit" 2>/dev/null) if [ -z "$subject" ]; then continue fi # 合并自动生成的提交不拦 if echo "$subject" | grep -qE "$merge_pattern"; then continue fi local subject_length=${#subject} if ! echo "$subject" | grep -qE "$pattern"; then echo "[ERROR] commit $commit 的消息不符合规范: $subject" >&2 echo " 期望格式: type(scope): subject" >&2 echo " 示例: feat(user): 增加登录接口" >&2 fail_flag=1 elif [ "$subject_length" -gt "$max_subject_length" ]; then echo "[ERROR] commit $commit 的消息过长 ($subject_length 字符,最长 $max_subject_length): $subject" >&2 fail_flag=1 fi done } while read oldrev newrev refname; do check_commit_messages "$oldrev" "$newrev" "$refname" done if [ "$fail_flag" -ne 0 ]; then echo "======================================" >&2 echo "错误: 提交信息未通过仓库规范检查。" >&2 echo "请先使用以下命令修改历史提交信息:" >&2 echo " git rebase -i <commit-hash>^" >&2 echo " git commit --amend -m \"feat(xxx): 新消息\"" >&2 echo "修完后重新 git push 即可。" >&2 exit 1 fi exit 0

脚本里有几个细节说一下:

  • git rev-list "$newrev" --not --branches用于新分支场景,意思是“找到只属于这个新分支、不属于其他已有分支的提交”,效果等同于旧分支的oldrev..newrev,能避免新分支把仓库全部历史重新检查一遍,造成无意义的失败。
  • 正则里 type 后面的 scope 部分用(\([a-z0-9]+\))?匹配,也就是fix(user)这种写法可以,fix(User)这种大写 scope 会被判不合法。如果团队想放开大小写,把[a-z0-9]改成[a-zA-Z0-9]就行。
  • 所有提示都输出到>&2,确保 GitLab 在解析时不会把提示当作钩子执行的正式输出,也不会被 IDEA 等客户端吞掉。

4. 部署到 GitLab 的完整流程

4.1 手动部署:custom_hooks 目录

首先确认你的 GitLab 仓库路径。通过 Omnibus 安装的 GitLab,默认仓库存储路径在配置文件/etc/gitlab/gitlab.rb里可以找到:

git_data_dirs({"default" => { "path" => "/var/opt/gitlab/git-data" }})

对应的仓库最终路径就是/var/opt/gitlab/git-data/repositories/<group>/<repo>.git

给指定仓库创建 custom_hooks 目录,然后把脚本放进去:

cd /var/opt/gitlab/git-data/repositories/your-group/your-repo.git mkdir -p custom_hooks cat > custom_hooks/pre-receive <<'EOF' # 脚本内容粘贴到这里 EOF chown git:git custom_hooks/pre-receive chmod 755 custom_hooks/pre-receive

注意:文件名必须是pre-receive,不要加.sh后缀,否则 GitLab 不会识别。权限方面,GitLab 会以 git 用户身份执行该钩子,所以文件属主设为 git,权限至少 755。

放好之后在任意一台开发机本地随便做一次 push 测试,如果看到有输出或拒绝提示,说明钩子生效了。

4.2 维护多个仓库的批量部署

如果团队有几十上百个仓库,一个个手动复制显然不现实。我通常会写一个小脚本,把钩子分发到所有需要检查的仓库下:

#!/usr/bin/env bash HOOK_FILE="/tmp/pre-receive" REPO_ROOT="/var/opt/gitlab/git-data/repositories" find "$REPO_ROOT" -maxdepth 3 -type d -name "*.git" | while read -r repo; do # 跳过 GitLab 内部维护的仓库,避免误伤 case "$repo" in *"/@hashed/"*) continue ;; esac mkdir -p "$repo/custom_hooks" cp "$HOOK_FILE" "$repo/custom_hooks/pre-receive" chown git:git "$repo/custom_hooks/pre-receive" chmod 755 "$repo/custom_hooks/pre-receive" echo "已安装: $repo" done

需要注意的是,GitLab 底层仓库分两种存放方式:老版本是你刚才看到的 group/repo.git 层级目录结构;新版本如果启用了 hashed storage,仓库路径是/var/opt/gitlab/git-data/repositories/@hashed/xx/xx.git,这种路径跟项目名没对应关系,不好直接按名字找。所以一个更省力的方式是通过 GitLab 的 API 或者管理后台,批量获取仓库 ID 再定位路径,或者干脆用 Rails Runner 来调 GitLab 内部接口。

如果你只是给一两个核心仓库加检查,手动操作就够了。批量部署只是在团队规模大时才需要考虑。

4.3 本地模拟测试

部署完之后,最好先在服务器上做一些假的 git 操作来测试钩子的逻辑,避免直接拿生产分支做实验。

GitLab 的钩子脚本会在更新仓库引用的进程里执行,你没法直接在命令行手动喂 stdin 给 pre-receive 的进程,但可以在一个非 GitLab 管理的临时 Git 仓库里测试脚本逻辑:

mkdir /tmp/test-hook && cd /tmp/test-hook git init --bare test.git # 把上面的 pre-receive 脚本复制到 test.git/hooks/pre-receive chmod +x test.git/hooks/pre-receive # 再初始化一个工作仓库 git init work && cd work echo "hello" > readme.md git add readme.md git commit -m "bad message" git remote add origin /tmp/test-hook/test.git git push origin master

这时候你就能看到 pre-receive 的报错信息。用真实 GitLab 测试时,过程一模一样,只是远程地址换成 GitLab 的仓库。

我在真实 GitLab 仓库测试时,故意提交了一条fix bug消息,push 后被拦截,客户端反馈如下:

remote: [ERROR] commit 7f0a9c1 的消息不符合规范: fix bug remote: 期望格式: type(scope): subject remote: 示例: feat(user): 增加登录接口 remote: ====================================== remote: 错误: 提交信息未通过仓库规范检查。

能看到具体的 commit hash 和错误消息,开发者就知道该去改哪条提交了。

5. 踩坑记录与实际排查

这个钩子上线一周内,我收到最多的问题就那几种,整理成速查表方便直接查。

现象可能原因解决办法
push 被拒但没有任何钩子提示pre-receive 文件不可执行或文件名不对chmod +x,确认名字没有后缀
提示在 IDEA 里不显示IDEA 的 Git 控制台对 stderr 输出解析有限在命令行里 push 一次看完整输出
中文 commit 消息乱码脚本文件编码或服务器 LANG 环境不对脚本文件保存为 UTF-8 无 BOM,在脚本开头 export LANG=zh_CN.UTF-8 或 en_US.UTF-8
全删分支或新空仓库第一次 push 失败没处理 oldrev/newrev 为 40 个 0 的场景脚本里用 zero 变量先判断
push 大历史时很慢检查的 commit 数量太多脚本里加计数限制,最多检查 100 条
Merge Request 产生的合并提交被误拦没跳过 Merge branch 类型提交加 merge_pattern 判断
报送 "remote: hooks failed" 之类通用错误GitLab 在钩子脚本崩溃时无法解析具体信息在脚本最开头加set -x或写日志文件定位

实际运维中有几个点最容易被忽略。

5.1 文件行尾问题

如果你是在 Windows 上编辑脚本然后上传到服务器,文件可能带有 CRLF 行尾,bash 在执行时遇到\r会直接报错$'\r': command not found,钩子直接失效。用sed -i 's/\r$//' pre-receive或者 vim 里执行 :set ff=unix 处理一下。

还有一种情况是复制脚本时把单引号和双引号搞混,Bash 正则表达式里的引号一旦变成中文引号,整个脚本就是语法错误。所以强烈建议不要手抄脚本,直接复制原始文件通过 scp 或粘贴到服务器。

5.2 如何动态查看钩子日志

钩子报错不实时,排查问题费劲。建议在脚本最前面加一行:

exec >> /tmp/githook.log 2>&1

这会把脚本所有输出(包括标准输出)追加到日志文件。之后任何一次 push 的执行记录都能看到,调试完再删掉这一行。我在定位新分支判断逻辑时就是靠这个方法,否则只能靠开发者客户端回传的信息,速度太慢。

5.3 rebase 之后 push 的坑

有的开发者在本地已经把提交记录整理好了,rebase -i 压缩了一堆提交,但 commit message 还是旧的,push 时被回绝。这时候直接git push --force也一样会被拦,因为 pre-receive 检查的是最终的 commit,不只是新提交,是所有这次 push 带来的 commit。

处理方式是让开发者先git log origin/your-branch..HEAD看看这次推送到底带了哪些提交,然后对不符合的消息逐个 amend。

还有一种更隐蔽的情况:开发者本地有多个分支,用git push --all推送多个分支,其中一个分支的 commit 不合规,整体推送会被全部拒绝。开发者会奇怪为什么其他分支也推不上去了。文档里跟团队说清楚:一次 push 里只要有一个 commit 不合规,整个 push 就会失败,避免误操作。

5.4 关于 GitLab Web IDE 和 MR 提交

GitLab Web IDE 在线编辑文件提交、或者创建 Merge Request 时,GitLab 会自动生成 commit 消息。这类消息默认格式为Update fileMerge branch 'xxx' into 'yyy',很可能不符合我们的 type 白名单。

所以脚本里一定要把Merge branch作为合法前缀放过,否则团队用 Web 端操作就会被坑到。Web IDE 提交的消息是Update file这种,如果你们要求严格,可以在钩子里再加一个白名单,把^Update file^Delete file这类的系统消息也放过。

5.5 IDEA 提交格式配置

给团队普及 commit 规范后,IDEA 用户可以在 Settings -> Version Control -> Commit 里配置 Commit Message 模板,把type(scope):预设成模板的前缀,这样每次打开提交界面就是标准的格式。SourceTree 也一样,设置有全局提交模板。

不要指望所有人能记住正则规则,工具和钩子双管齐下,才能真正落地。

6. 这个钩子还能怎么扩展

pre-receive 检查 commit 消息只是最基础的用法,同一套思路可以扩展到不少团队管理场景。

6.1 关联任务单号

如果你们的开发流程依赖 Jira、禅道这类系统,可以在正则里强制要求 commit 消息带上任务单号:

task_pattern="^(feat|fix)(\([a-z0-9]+\))?!?: \[(JIRA-[0-9]+|BUG-[0-9]+)\] .+$"

这样后续从 git log 里追溯需求、排查线上问题时,可以直接跳到对应任务系统,效率翻倍。

6.2 检查提交作者信息

有种场景是团队要求提交作者邮箱必须是企业邮箱,不能是个人邮箱或随便写的假邮箱。这个钩子也能做,在检查 commit 时把 author 也拉出来:

author=$(git log -1 --format="%an <%ae>" "$commit") echo "$author" | grep -qE "@company\.com$" || echo "[ERROR] 提交者 $author 邮箱不在白名单内"

很多团队是因为没有这一步,导致最后统计工作量时发现大量提交挂在一个虚构的"dev"名字下,绩效和贡献完全对不上号。

6.3 大文件提交拦截

pre-receive 同样能拦截误提交的大文件。比如某个开发者把 anaconda 的安装包打进仓库里,仓库体积瞬间膨胀。你可以在钩子里用git diff --stat统计本次推送所有改动文件的大小,超过 50MB 的直接拒绝。

git diff --stat "$oldrev" "$newrev" | awk '{ if ($4 ~ /[0-9]+ files changed/) { print $0 } }'

这种检查不需要很精细,只要能把 99% 的误操作拦住就行,否则仓库的历史永远清不干净,处理起来代价非常大。

6.4 与 CI 流水线联动

pre-receive 是 push 阶段的第一道防线,CI 是第二道。建议不要把所有校验逻辑都堆到 pre-receive 里,pre-receive 只做轻量级拦截,重逻辑放在 CI 里跑。比如静态检查、单元测试、构建产物校验,这些耗时的操作放 CI 更合适,不会因为是 push 钩子而导致开发者每次推送都要干等十几秒。

这个思路我用了大半年,踩过一些坑,也基本理顺了。最后分享一个实际经验:上钩子之前,先花一天和团队对齐 commit 规范。不要在团队没统一标准时直接强制检查,那样只会引发大量抱怨。先拉一个文档,把 type 白名单定好,给出 3 个正面和 3 个反面的例子,让所有人知道什么是合格的 commit message,再把 pre-receive 加进去。上线后如果发现有人在群里喊"为什么我提交不上去",第一件事就是问他看没看客户端回显的错误提示——大多数情况下,提示里已经把改法写得很清楚了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询