
简介面向 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是引用更新成功后才运行主要用来发通知、触发 CIupdate是每个引用更新时分别运行一次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或PythonGitLab 本身就是 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-count200之类的上限把检查范围限定在最近 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 才想起来。我的经验是上线钩子时同步在团队公告里写清楚被拒后的处理流程并给出常见命令模板。规范能不能落地一半靠技术强制另一半靠团队的操作习惯能不能顺滑跟上。本文还有配套的精品资源点击获取