简介:面向 GitLab 管理员及有仓库规范化需求的开发者,这是一份使用 Go 语言实现的 pre-receive 钩子示例资源,演示在服务端拦截推送并校验 commit message 是否包含指定关键词,适合需要强化提交规范、防止不合规变更进入仓库的团队参考。资源包为 zip 压缩格式,整体仅 3KB,共 4 个文件,包含主要 Go 源码 main.go、说明文档 README.md、开源许可证 LICENSE 以及 .gitignore 配置文件,结构简单清晰,便于快速阅读与部署。目前已有 1982 人学习。示例代码展示了 pre-receive 钩子的核心流程:读取推送引用、获取最新提交消息、检查关键词并给出非零退出码,可在此基础上扩展作者身份验证、分支限制、日志记录等功能,也可为理解 GitLab 服务端钩子运行机制提供可运行的参考。 团队里最早尝试过很多种规范 commit 消息的办法,客户端钩子、Code Review 约定、公告模板都试过,最终全是靠一个服务端钩子真正兜住底的。这个方案就是在 GitLab 上部署一个pre-receive钩子,用它检查每次git push进来的 commit 消息,不合规的直接拒绝入库。这篇文章把钩子的原理、完整脚本、部署步骤,以及我在生产环境里踩过的坑一次性讲清楚,希望能帮你少走弯路。
1. 为什么非要在GitLab服务端做commit消息检查
1.1 客户端钩子根本管不住人
很多人第一反应是写一个commit-msg客户端钩子,在本地提交时就检查消息格式。理论上没问题,但现实中它撑不过一周。原因很简单:客户端钩子存在于每个开发者自己机器上的.git/hooks目录里,而这个目录根本不会随git clone分发。新成员克隆项目后,钩子不存在;老成员换电脑、换 IDE、重新克隆,钩子也可能丢。更不用说git commit --no-verify可以一次性跳过所有本地钩子,用 IDE 自带提交功能时,很多图形化工具也压根不执行自定义钩子。
我当时的项目组就有这样的情况:规定写了、钩子发了、公告也喊了,结果当晚就有人连续推了七八个消息为fix、update、wip的 commit 上去。代码仓库乱了,后面查问题、回溯需求、生成 changelog 全部受影响。所以团队要落实 commit 规范,唯一可靠的位置只能是服务端——因为 push 这个动作绕不过去。
1.2 pre-receive在整个提交链路里的位置
GitLab 服务端的 Git 钩子有三种:pre-receive、update、post-receive。它们的触发时机不一样:post-receive是引用更新成功后才运行,主要用来发通知、触发 CI;update是每个引用更新时分别运行一次;pre-receive则是在所有引用更新之前只运行一次,对一次 push 的全局情况做把关。
pre-receive担当的是"最后的闸门"角色。当开发者执行git push origin feature/xxx,本地 Git 把对象传输到 GitLab 服务端后,服务端并不会立刻更新分支指针,而是先调用钩子。钩子读取标准输入里的引用更新请求,运行检查脚本;脚本退出码为 0,引用才真正写入;如果退出码非 0,整个 push 会被拒绝、所有引用都不会更新。用户本地的 Git 客户端会直接收到报错信息,反馈是同步的、即时的,非常直观。
检查 commit 消息用pre-receive最合适,因为它能一次性拿到这次 push 涉及的所有分支和 tag 范围,效率高、覆盖面全。如果用一个团队里已经跑了一年的结论来概括:想让规范从"建议"变成"强制",服务端pre-receive是成本最低、可靠性最高的位置。
2. pre-receive钩子是被怎样调用的
2.1 标准输入的三字段格式
初写钩子的人最困惑的就是"脚本到底怎么拿到那些 commit"。pre-receive不是通过命令行参数传数据的,而是通过标准输入传入一组行。每一行代表一个要被更新的引用,格式是:
<旧commit SHA> <新commit SHA> <引用名称>三个字段以空格分隔。举个实际例子,如果开发者往master分支推送了一个新 commit6b7a0ca,当时服务端上 master 指向c4d8a1b,那么脚本读到的内容可能长这样:
c4d8a1b3f2e1c9d4a8b1c2de6b7a0ca4d3a7b4c6 refs/heads/master还有两种特殊情况:创建新分支或新 tag 时,旧 commit SHA 是一串全零0000000000000000000000000000000000000000;删除分支或删除 tag 时,新 commit SHA 是全零。脚本解析时必须把这两种情况单独拎出来处理。
2.2 找出本次push实际新增的commit
拿到新旧 SHA 之后,最常用的命令是git rev-list $oldrev..$newrev,它能列出从旧提交到新提交之间新增的 commit 列表。但这招有个前提:旧 SHA 是本次 push 前服务端真实存在的提交。
当旧 SHA 是全零、也就是创建新分支时,不能直接跑去检查0000000000000000000000000000000000000000..$newrev。我见过不少脚本用git log扫全量历史,这在仓库小的时候没什么感觉,等仓库涨到几个 GB、历史几万次提交时,push 一次能卡好几十秒。
正确思路是借助--not --all参数。在执行pre-receive的阶段,目标引用还没更新,--all代表的依然是服务端当前所有已有分支,不会包含这次新推上来的 commit。用这个命令:
git rev-list $newrev --not --all它列出的就是"只有新引用有、服务端其他地方都不存在的 commit",恰好是这次 push 真正新增的内容。这个方法我在多台 GitLab 环境里都验证过,稳定可靠。
2.3 退出码和各种输出约定
钩子脚本最有意思的一点是:你没有显式exit 0,就相当于默认放行。因为脚本执行到最后,shell 的返回码是最后一条命令的返回码。比如脚本最后一行是git rev-list ...,这条命令正常结束时返回 0,钩子就放行了;如果脚本中间某个判断想拒绝而使用了exit 1,那整个 push 就被拒绝。所以脚本必须对所有违规分支都做显式exit 1,并把所有检查通过的路径收敛到最后的exit 0,绝不能依赖隐式返回。
另外,脚本往标准输出或标准错误打印的内容,会原样显示在开发者的 push 终端里。GitLab 有一个约定:输出行以GL-HOOK-ERR:开头,GitLab 会把它渲染成醒目的错误信息;否则只是普通remote:日志,很容易被用户忽略。这个细节后面单独展开。
3. 一个能直接用的commit消息检查脚本
3.1 先定义规则
写脚本前先想清楚检查什么、不检查什么。我当时和团队定的规则相当朴素:
- commit 消息不能为空。
- 消息开头必须包含需求单号,格式类似
[PROJ-123] 描述,正则表达式为^\[[A-Z]+-[0-9]+\]。 - 合并提交(merge commit)不检查需求单号,因为 GitLab 默认的合并提交消息是
Merge branch 'xxx' into 'yyy',格式已经足够描述语义。 - 删除分支、删除 tag 不需要检查。
- tag 引用的消息不检查,让它保持简单。
规则越简单,脚本越好维护,团队成员也越容易理解。上线后真正需要的规则,通常比想象中少得多。
3.2 脚本完整实现
这是一个纯 bash 版本,我在生产环境跑过几个月,稳定可靠:
#!/bin/bash # pre-receive hook: enforce commit message convention # Rules: # 1. commit message must not be empty # 2. normal commit message must match ^\[[A-Z]+-[0-9]+\] # 3. merge commit is skipped # 逐行读取标准输入,处理每个待更新的引用 while read oldrev newrev refname; do # 删除分支/tag:不检查 if [ "$newrev" = "0000000000000000000000000000000000000000" ]; then continue fi # 只检查分支,tag 一律放行 case "$refname" in refs/heads/*) ;; *) continue ;; esac # 获取本次 push 新增的 commit 列表 if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then # 创建新分支:排除服务端已有引用,只查新提交 revs=$(git rev-list "$newrev" --not --all 2>/dev/null) else # 普通更新:检查两个引用之间的范围 revs=$(git rev-list "$oldrev".."$newrev" 2>/dev/null) fi # 遍历每个 commit,逐条检查消息 for rev in $revs; do msg=$(git log -1 --format='%s' "$rev") parent_count=$(git rev-list --parents -n 1 "$rev" | awk '{print NF-1}') # merge commit 跳过消息格式检查 if [ "$parent_count" -gt 1 ]; then continue fi if [ -z "$msg" ]; then echo "GL-HOOK-ERR: Commit $rev has empty message." >&2 echo "GL-HOOK-ERR: Please amend your commit message before pushing." >&2 exit 1 fi if ! echo "$msg" | grep -qE '^\[[A-Z]+-[0-9]+\]'; then echo "GL-HOOK-ERR: Commit $rev message is invalid:" >&2 echo "GL-HOOK-ERR: $msg" >&2 echo "GL-HOOK-ERR: Message must start with [PROJ-123], e.g. '[PROJ-123] fix user login issue'." >&2 exit 1 fi done done exit 03.3 脚本里几个关键命令的意思
git rev-list "$newrev" --not --all这段代码是脚本的核心,它解决的是创建新分支时历史范围过大的问题。原理我在第 2 节说过:pre-receive执行时目标引用还没更新,--all不包含新引用的 commit,所以能精确列出"只属于本次 push 的新提交"。实测下来,哪怕仓库有几万次提交,创建新分支时的检查时间也能控制在几十毫秒。
git rev-list --parents -n 1 "$rev" | awk '{print NF-1}'这一行用于判断当前 commit 是不是 merge commit。原理是让 Git 输出这个 commit 的所有父提交,然后数一下字段数量减一就是父提交个数。父提交大于 1 就是 merge commit,直接跳过消息格式检查。这个写法比git cat-file -p再解析要简单得多,也避免了对 submodule 等特殊情况的无谓担心。
还有个小细节:错误信息里把完整 commit 输出给用户,能让开发者立刻定位是哪个提交出了问题,而不是在几十个 commit 里猜。多打印一行具体的 commit 消息原文也很关键,开发者从本地git log搜索这段文字就能找到位置。
3.4 为什么我选bash而不是Ruby或Python
GitLab 本身就是 Ruby 写的,官方文档里很多钩子示例也直接用 Ruby。但我的选择是纯 bash 加 Git 原生命令,原因有三个:一是 bash 和 git 在任何 GitLab 服务器上都天然存在,不需要额外维护运行时环境;二是部署就是放一个脚本文件、加个执行权限,团队里任何一个人都能看懂改得动;三是排查问题时可以直接在服务器上手动执行,不用进入任何特定语言环境。
如果你未来要做的校验明显复杂——比如调 Jira API 验证工单存在、从消息里提取任务号写进数据库、对接内部流程系统——那用 Python 或 Ruby 更合适。大多数团队的 commit 消息规范其实停留在"格式是否正确"层面,bash 的轻量优势就非常明显。
4. 在GitLab里部署这个钩子的完整过程
4.1 找到GitLab的custom_hooks目录在哪
GitLab 没有直接把钩子放在传统的hooks目录里,而是为每个项目仓库预留了一个custom_hooks目录,专门用来放自定义钩子。路径跟 GitLab 的安装方式和版本有关,最常见的 Omnibus 安装路径长这样:
/var/opt/gitlab/git-data/repositories/<group>/<project>.git/custom_hooks/源码安装则一般在:
/home/git/repositories/<group>/<project>.git/custom_hooks/GitLab 的 group 目录可能带.git后缀,多层级 group 也会映射成多级子目录。最靠谱的定位方式是登录 GitLab 管理后台,在项目页面的 Gitaly 信息里查看到仓库路径;也可以用gitlab-rails console执行:
p = Project.find_by_full_path('group/project') p.repository.relative_path拿到相对路径后,拼上 GitLab 数据目录的根路径就是完整位置。我建议直接按照这个方式确认,不要在服务器上find瞎碰,既浪费时间又容易找错存储 shard。
4.2 创建目录、安装脚本、设置权限
确认路径之后,创建custom_hooks目录,把脚本放进去并命名为pre-receive。这里有一个关键点:目录和脚本的属主必须是 GitLab 的运行用户(通常是git),否则钩子不会被执行,或者 GitLab 干脆拒绝服务。
PROJ=/var/opt/gitlab/git-data/repositories/mygroup/myproj.git sudo mkdir -p $PROJ/custom_hooks sudo cp /tmp/pre-receive $PROJ/custom_hooks/pre-receive sudo chown -R git:git $PROJ/custom_hooks sudo chmod 755 $PROJ/custom_hooks/pre-receive如果同时对多个项目生效,可以把脚本放到 GitLab Shell 的全局 custom hooks 目录。Omnibus 安装通常是/opt/gitlab/embedded/service/gitlab-shell/hooks目录下的pre-receive.d系列机制,不同版本差异较大,我的建议是先从单项目验证,确认脚本逻辑无误后再推广到全局,避免一次改太多影响所有人的推送。
4.3 在服务器上本地模拟钩子测试
脚本部署完,先不要急着从本地 push。直接在服务器上模拟钩子调用,能把问题隔离在脚本本身:
cd $PROJ echo "0000000000000000000000000000000000000000 6b7a0ca4d3a7b4c6a1d5a8b3f2e1c9d4a8b1c2de refs/heads/master" | sudo -u git $PROJ/custom_hooks/pre-receive echo $?这里需要注意,钩子脚本内部执行 git 命令时依赖"当前工作目录是仓库目录"这一前提,所以要么在脚本里先cd进仓库,要么在测试时先cd到仓库目录。如果echo $?返回 1,说明脚本按预期拒绝了这次 push;返回 0 则说明放行。
构造测试输入时,最好用一个真实存在的 commit SHA 和一条违反规则的 commit,这样才能验证核心逻辑。零 SHA 加任意 SHA 测试新建分支路径,两个真实 SHA 测试普通更新路径,替换成删除分支的组合测试放行路径。
4.4 远程push验证完整链路
服务器侧脚本没问题之后,从另一台机器做端到端验证。我的做法是拉一个新分支,提交一个不合规的消息,push 一次,然后改成合规的消息再 push 一次:
git checkout -b test-commit-check git commit --allow-empty -m "no ticket id here" git push origin test-commit-check预期输出类似:
remote: GL-HOOK-ERR: Commit 1a2b3c4... message is invalid: remote: GL-HOOK-ERR: no ticket id here remote: GL-HOOK-ERR: Message must start with [PROJ-123], e.g. '[PROJ-123] fix user login issue'. To gitlab.example.com:mygroup/myproj.git ! [remote rejected] test-commit-check -> test-commit-check (pre-receive hook declined) error: failed to push some refs看到pre-receive hook declined就说明服务端拦截成功了。接着用git commit --amend -m "[TEST-001] valid message"修改消息,再 push,这次应该顺利通过。如果这两步都正常,整个钩子链路就算跑通了。
5. 生产环境最容易踩的坑和我现在的处理方式
5.1 错误输出格式对体验的影响远比想象中大
刚开始我直接在脚本里用echo "error: invalid message",效果很不理想。GitLab 会把普通输出显示成remote:开头,而很多开发者在 IDE 里推送时,这种信息会被折叠或当成普通日志一带而过,根本注意不到。
后来改成输出GL-HOOK-ERR:前缀之后,用户体验明显不一样。GitLab 会把这部分内容单独识别为钩子错误,IDE 里通常会以醒目的错误样式展示。我的习惯是每条错误输出两行:第一行定位是哪条 commit 出了问题,第二行告诉用户怎么改。不要让开发者面对一堆日志去猜。
5.2 大仓库场景下的性能优化思路
脚本上线初期,有一次同事 push 一个包含 800 多个新建 commit 的大分支,卡了差不多半分钟。排查下来是因为git rev-list $newrev --not --all在大型仓库里要遍历对象,本身不算快;再加上脚本对每个 commit 都调用了git log -1,几百个 commit 就额外产生了大量子进程,累积延迟非常明显。
优化方案有三个,我实际结合使用了:一是发现违规 commit 后立即exit 1,不要把所有 commit 都查完再汇总,用户反悔前本地消息改写成本更低;二是给git rev-list加上--max-count=200之类的上限,把检查范围限定在最近 200 个 commit,既保证常见场景的检查力度,又避免极端长分支把 push 拖死;三是把git log -1 --format='%s'和父提交判断合并成一次git show -s --format=%s,减少不必要的开销。这套组合下来,即使大分支的检查时间也能控制在几秒以内。
5.3 多节点Gitaly的钩子分发不能忘
如果公司的 GitLab 是高可用部署,后面挂着多个 Gitaly 存储节点,custom_hooks 并不会自动在多节点间同步。最开始我只在其中一个节点放了脚本,结果同一个项目今天 push 被拦截、明天换个节点就通过了,规则形同虚设。
解决办法是在配置管理工具(Puppet、Ansible、SaltStack 或者简单的 rsync 定时同步任务)里把钩子脚本统一分发到所有 Gitaly 节点。分发后要记得检查所有节点的属主和权限,任何一台机器上权限不对,结果就是部分节点有规范、部分节点没规范。
另外,升级 GitLab 版本和运行gitlab-ctl reconfigure一般不会动custom_hooks目录里的内容,因为它在仓库数据目录而非程序目录。但仓库迁移、存储目录变更、磁盘重新挂载这类运维操作要格外小心,迁移完必须重新确认钩子文件是否还在、有没有执行权限。
5.4 什么时候不用pre-receive而用其他方案
GitLab 的高阶版(Premium/Ultimate)内置了 Push Rules 功能,可以直接在项目或群组层面配置正则规则、拒绝包含特定关键词的提交、限制提交作者邮箱等,不用写脚本就能实现基本的消息规范。如果你的公司买的是高阶版授权,直接用 Push Rules 更省心,管理界面也比脚本友好得多。
但如果你是免费版或自托管社区版用户,Push Rules 是用不了的。GitLab 的 Project Webhooks 只能做推送后的通知,根本无法在 push 过程中拦截。这种场景下,pre-receive自定义钩子是唯一能在服务端强制执行 commit 规范的方式,也是开源生态里最通用的做法。如果你的团队已经买了高阶版,还是想用脚本实现更复杂的逻辑,比如校验提交作者必须匹配 GitLab 账号或调用内部 API,那pre-receive依然值得保留。
5.5 上线前想好怎么让开发者改消息
钩子上线第一天,一定会有人 push 被拒。比较尴尬的是被拒之后怎么改本地消息:已经提交、还没推送的 commit,直接用git commit --amend改最近一条,用git rebase -i改中间的多条;改完之后因为历史被改写,push 时要用git push --force-with-lease覆盖远程引用。
如果团队开启了分支保护规则,force push 可能也会被拒绝,这就要求开发者把"消息规范"这件事放在提交阶段,而不是等到 push 才想起来。我的经验是上线钩子时同步在团队公告里写清楚被拒后的处理流程,并给出常见命令模板。规范能不能落地,一半靠技术强制,另一半靠团队的操作习惯能不能顺滑跟上。
本文还有配套的精品资源,点击获取