
简介一份用 Go 语言实现的 GitLab pre-receive 钩子源码面向需要规范提交信息、加强仓库管理的中级 Git/GitLab 管理员或运维开发者。该钩子在用户推送前检查最新提交消息是否包含指定关键词如“fix”不满足即拒绝推送从而为团队提交质量提供第一道防线。资源共 4 个文件包含 Go 源码main.go、项目说明README.md、许可证LICENSE与 .gitignore压缩包仅 3KB结构紧凑适合直接阅读、修改并部署到服务器 hooks 目录。已有 1991 人学习下载可作为理解 GitLab 服务端钩子工作原理、Go 调用 git 命令及标准库处理流程的入门示例。从代码中可学到如何遍历推送引用、解析提交 SHA、调用 git log 获取提交信息以及如何通过非零退出码控制推送是否被接受。进一步扩展时可加入分支限制、作者校验或多关键词匹配灵活适配团队规范。1. 为什么我劝你别再靠人工盯 Git 提交信息直接把结论放在前面只要你的团队还允许git commit -m fix bug这种提交信息进主干代码评审就永远慢半拍。某次凌晨线上事故回滚我们靠git log --oneline找对应提交看到整整六条fix bug和三条update只能逐条 checkout 出来看 diff那个晚上我就在想这种毫无信息量的提交信息本质上是在给每个后来查日志的人上刑。pre-receive 钩子就是解决这个问题的第一道闸门。它是 Git 服务端的钩子运行在远端仓库收到git push时能拿到这次推送的所有提交对象在引用更新之前执行检查。我这个方案里它做的事很简单逐一读取提交信息按团队约定的正则规则去匹配不达标就直接拒绝这整个推送并且把错误原因原样怼回给推送者。适合的场景很明确小团队不想上重型 CI 流水线或者仓库管理员只想守住提交规范底线不想管代码内容。下面这套方案不依赖额外的服务组件不加数据库不引入消息队列就一个脚本、一个正则黑名单文件、一行官方自带的配置命令十分钟能跑通生产环境已经替我顶了一年多。这篇会把脚本怎么拼、参数怎么调、推送被拒时客户端会看到什么、以及那些日志里看不到但你会真实撞上的坑全摊开来讲。2. 钩子背后的机制与官方模板先搞懂 Git 给了你什么2.1 服务端钩子与客户端钩子的本质区别以及 pre-receive 的触发时机很多新手第一次接触 Git 钩子会直接翻.git/hooks目录把commit-msg钩子写得飞起。那个是客户端钩子只在你本地git commit时触发改的是本机行为换个电脑就没约束力了。pre-receive 是服务端钩子跑在托管 Git 仓库的机器上比如自建的 GitLab 服务器任何开发者往这个远端仓库执行git push服务端校验通过才会真正更新引用。两者的差异一句话概括客户端钩子靠自觉服务端钩子靠强制。触发时机值得多讲一句。Git 收到 push 请求后先接收对象再对每一个要更新的引用运行 pre-receive 钩子脚本拿到的是标准输入stdin上传来的三列信息旧引用old ref、新引用new ref、完整引用名。只要这个脚本退出码非零整个 push 操作就会被拒绝所有引用都不会更新。这个“一刀切”的特性很有用也很坑后面避坑章节会细说。2.2 官方 sample 怎么读那些注释里藏着关键参数GitLab 的仓库初始化之后hooks目录里会有一堆.sample文件其中pre-receive.sample是官方给的参考骨架。我那时候第一次打开它发现脚本几乎全是注释真正执行的代码行很少但里面指明了一件至关重要的配置自定义钩子要放进 GitLab 的gitlab-shell目录并做软链接。这个事不做对脚本写再好都白搭。下面是我当时从官方 sample 里提炼出的最小执行框架请盯着退出码和 stdin 的解析方式看#!/bin/sh # 这是一个极简的 pre-receive 骨架核心就三件事 # 1. 读 stdin 拿推送的 ref 信息2. 跑校验逻辑3. 用退出码决定放行或拒绝。 while read oldrev newrev refname do # 当前只处理分支推送。如果你不想让代码往 tag 上推可以在这里加过滤。 if [ $refname refs/heads/master ]; then # 这里是校验逻辑的占位符。真实场景下你会在这里做提交信息检查。 echo master 分支暂不支持直接推送请提合并请求 2 exit 1 fi done exit 0我把这段骨架的逻辑拆开讲。脚本主体是一个while read循环每次迭代读取一行 stdin一行对应一个引用更新oldrev是推送前的提交哈希或空值新建分支时为空newrev是推送后的提交哈希或空值删除分支时为空refname是完整引用名。判断分支用refname refs/heads/master判断 tag 用前缀refs/tags/判断删除操作看newrev是否为空这些都是 pre-receive 钩子里最常用的套路。参数调整的要点如果你只想约束部分分支就把这个 while 循环里的 if 条件换成case语法匹配refs/heads/main、refs/heads/dev或者干脆用通配符匹配refs/heads/*。如果要给操作者更明确的提示用echo ... 2把信息写到标准错误Git 会把标准错误内容原样显示给执行 push 的人这是我当时排错时最依赖的线索源具体怎么用后面排查章节展开。2.3 GitLab 钩子目录的加载机制为什么不直接改原目录GitLab 官方文档对自定义钩子的说明精简后就是两个步骤在 GitLab 服务器上找到 Git 仓库的实际目录通常形如/var/opt/gitlab/git-data/repositories/某个组/某个项目.git然后在它下面新建custom_hooks目录再把你的钩子脚本放进去脚本名必须是pre-receive且带可执行权限。为什么是custom_hooks而不是直接覆盖仓库原有的hooks/pre-receive原因很现实GitLab 自身管理仓库生命周期和权限校验靠的就是这些自有钩子你直接改写原生pre-receive一次 GitLab 升级就可能把你的改动冲掉。custom_hooks是 GitLab 替你在运行时加载用户自定义钩子的目录本质上它内部会再调用一次你的脚本我一般建议后续用软链接方式维护它。当脚本有任何改动时不需要重启 GitLab 服务也不需要动仓库配置文件保存后下一次 push 自动生效。但有一个隐藏的坑custom_hooks目录如果不存在钩子就静默失效GitLab 不会在日志里给你任何明确报错。我见过有人把脚本放错目录后发现规则没生效查了整整一天才发现问题出在custom_hooks这个目录名拼错成了custom_hook。3. 在 GitLab 上落地 commit 消息检查钩子最小可用脚本到团队规范3.1 给团队定规矩之前先想清楚要检查什么commit 消息检查钩子的核心不是正则怎么写而是你想让团队养成什么习惯。我当时和团队定的规范分成三档缺一不可一是基础格式所有提交信息必须包含类型前缀类似feat、fix、docs、refactor、test中的一种二是辅助信息括号里写模块或影响范围像fix(login)表示修了登录模块的问题三是关联需求提交信息末尾要带需求或缺陷编号方便从提交直接跳转到项目管理后台。这三档规则对应的检查逻辑逐步收紧。前两档用正则能直接拆解第三档涉及编号的合法性校验比如编号长度、是否只是数字加连字符。坦率说最开始的版本只做第一档因为第二档开始容易误伤比如有人写fix(typo)你没法判断typo是不是一个真实存在的模块。团队跑顺畅了再把第二档和第三档作为强制项加上去。3.2 写一个同时支持中英文场景的 pre-receive 检查脚本我直接给你看一个我在生产环境跑了大半年的脚本版本它同时做了格式正则校验和错误提示打印每一处不合规的提交信息并用一个文件白名单做前置检查#!/bin/bash # 一个生产可用的 pre-receive 脚本检查提交信息是否合规 # 依赖git 命令、正则文件 $GIT_DIR/commit-msg-rules.txt # 用法将此脚本放入 custom_hooks 目录并命名为 pre-receivechmod x 即可 GIT_DIR$(pwd) RULES_FILE$GIT_DIR/commit-msg-rules.txt # 如果规则文件不存在给一个默认宽松规则不直接拒绝所有推送 if [ ! -f $RULES_FILE ]; then echo 警告:未找到规则文件, 本次推送将跳过检查 2 exit 0 fi # 严格匹配 pattern: 类型(模块): 描述 #编号 # 例: feat(login): add remember me #123 STRICT_PATTERN^[a-z]\([a-z0-9-]\): . #[0-9]$ # 宽松匹配 pattern: 类型: 描述 LOOSE_PATTERN^[a-z]: .$ # 从 stdin 逐行读取推送的 ref 信息 while read oldrev newrev refname; do # 跳过 tag 推送和删除分支操作(删除分支时 newrev 为空) if [[ $refname refs/tags/* ]] || [[ -z $newrev ]]; then continue fi # 使用 git rev-list 提取本次推送的所有提交哈希 # 注意: $oldrev 可能为空(新建分支), 用 git rev-list 的边界处理 if [[ -z $oldrev ]]; then # 新建分支场景: 从新分支的根提交开始检查 rev_range$newrev else # 更新分支场景: 检查 oldrev..newrev 之间的新增提交 rev_range$oldrev..$newrev fi # 逐条检查提交信息, 不匹配则记录并标记失败 failed0 for commit in $(git rev-list $rev_range --not --all); do commit_msg$(git log -1 --prettyformat:%s%n%b $commit) if ! echo $commit_msg | grep -qE $STRICT_PATTERN; then # 如果不满足严格模式, 再尝试宽松模式 if ! echo $commit_msg | grep -qE $LOOSE_PATTERN; then echo 错误: 提交 $commit 的说明不符合规范 2 echo 提交信息: $commit_msg 2 echo 规范示例: feat(login): add remember me #123 2 failed1 fi fi done if [ $failed -ne 0 ]; then echo 推送被拒绝: 请修正上述提交信息后重试 2 exit 1 fi done exit 0这段脚本的核心逻辑是从 stdin 拿到推送的引用范围后用git rev-list把这段范围内的提交哈希全列出来逐个取提交信息做正则匹配有任何一条不合规就立刻把错误信息写到标准错误并置失败标记退出码返 1。逻辑说明里我强调几个关键点。git rev-list是一个专门用于列出提交对象的高效命令它的--not --all参数用来排除所有已被其他分支引用的提交这样在新建分支或强制推送场景下范围会比较干净不会把历史提交重复检查一遍。git log -1 --prettyformat:%s%n%b负责输出该提交的主题行和正文%s是主题%n是换行%b是正文组合起来保证多行提交信息也能被完整读入变量。参数调整的要点集中在两处。第一处是正则表达式STRICT_PATTERN里的第一段[a-z]\([a-z0-9-]\)限制类型和模块只能是小写字母、数字、连字符避免中文括号或空格混入如果你要支持merge或revert这类自动化提交需要把[a-z]替换成(feat|fix|docs|refactor|test|merge|revert)否则合并提交会被误杀。第二处是if [ $failed -ne 0 ]的判断我把它放在仓库内最后一个 while 循环外面是为了实现一个关键行为一次推送里如果多个分支都出现违规提交脚本会全部检查完并一次性返回失败而不是检查到第一个违规就退出这样推送者能一次性看到所有问题。实际操作时的坑之一这个脚本依赖pwd来定位规则文件位置。GitLab 在执行钩子时当前工作目录就是仓库的.git目录所以GIT_DIR$(pwd)才能正确拼接出规则文件的绝对路径。如果你把规则文件放在别的绝对路径直接改RULES_FILE对应的位置。另一个常见坑是bash和sh的语法差异GitLab 服务器默认的sh可能是 dash不支持正则表达式操作符~所以我脚本里用的是grep -qE而不是[[ $msg ~ $pattern ]]这是兼容性最好的做法。3.3 GitLab 配置自定义钩子的标准操作步骤脚本写完只是第一步部署到 GitLab 并让钩子生效需要按以下步骤走一次。我尽量用最不易出错的路径来描述顺便标出每个步骤的意义。先找到目标仓库在 GitLab 服务器上的真实存储路径。用 GitLab 的管理员账号在后台进入仓库详情页找到「Repository」区域的路径信息。或者直接在服务器上执行gitlab-rails console这会稍微麻烦更快的办法是用find按仓库名找# 在 GitLab 服务器上查找某仓库的实际存储路径 # 提示仓库名通常是项目名加 .git 后缀 find /var/opt/gitlab/git-data/repositories -maxdepth 4 -name *.git -type d | grep 仓库名拿到仓库路径后进入该目录创建custom_hooks并放入脚本。如果项目很多别一个个手敲直接写一个小循环批量创建目录并做软链接软链接的好处是脚本本体可以放在一个统一的管理目录里比如/opt/company/git-hooks方便版本管理。cd /var/opt/gitlab/git-data/repositories/具体组/具体仓库.git mkdir -p custom_hooks # 将脚本复制过去并重命名为 pre-receive cp /opt/company/git-hooks/pre-receive custom_hooks/pre-receive chmod x custom_hooks/pre-receive # 验证目录结构和权限 ls -l custom_hooks/这里我要额外解释为什么用“复制”而不是直接编辑脚本。GitLab 的 custom_hooks 目录是在仓库的物理目录下面这个目录会被 GitLab 自己管理如果你在多个仓库之间复制脚本每次升级脚本版本就要重新同步非常容易漏。我自己的维护习惯是脚本本体放在/opt/company/git-hooks然后在每个仓库的custom_hooks下用软链接指向本体这样升级脚本只改一个文件。软链接的命令是ln -s前提是 GitLab 运行用户对源文件和目标目录都有读权限和执行权限。最后验证钩子是否真的生效。最直接的办法是本地随便改一个文件写一条明显违规的提交信息再执行git push# 本地新建一个测试提交, 故意用违规信息 git commit --allow-empty -m test hook # 推送, 预期看到 pre-receive 脚本的报错信息 git push origin master这一步输出的效果因人而异但我预期你会看到类似 “错误: 提交 xxx 的说明不符合规范” 的字样并且 push 操作被拒绝。如果没看到任何拒绝优先检查三个位置脚本是否在custom_hooks目录内且名为pre-receive、是否有x权限、脚本第一行的解释器路径是否正确。4. 提交规范的粒度选择与正则参数调整贴合团队节奏而不是卡死所有人4.1 三种规范化级从宽松到严格分别适用于什么阶段的团队我的经验是规范越严脚本看起来越有面子但团队成员的抵触情绪也越重。从来没有人喜欢自己在终端里敲了半天的提交信息被服务器一脚踢回来重写所以规范要跟着团队成熟度走。第一级只校验“非空且包含基本描述”。这种级别的正则我只要求提交信息不是空字符串且至少有一个空格分隔的主题和描述。适用于项目刚起步、还在追功能进度的阶段这时候频繁拒绝提交只会打断心流。第二级强制类型前缀。要求每个提交信息必须匹配(feat|fix|docs|test|refactor): 描述这一类结构。这是绝大多数团队停留的位置因为类型前缀能极低成本建立变更识别度git log --oneline一眼扫过去哪些是功能哪些是修 bug 清清楚楚。第三级完整约束包括模块和需求编号。形式像feat(login): add remember me #123。这种适合研发流程已经成熟、每个提交都关联到需求或缺陷系统的团队。好处是每个提交都有据可查坏处是成本变高有时候开发者在功能代码写代码时根本不知道需求编号填什么强制就会倒退成瞎编编号。我建议的路径是先定第二级跑三个月养成习惯后再升级到第三级。升级时给团队缓冲期比如第一周只记录警告不拒绝第二周再开始拒绝而不是第二天就翻脸执行。4.2 正则写法详解类型、模块、编号三段的兼容性设计现在回到正则本身。以严格模式^[a-z]\([a-z0-9-]\): . #[0-9]$为例逐段解释设计意图。第一段^[a-z]强制开头是纯小写字母。很多开发者提交信息习惯写大写例如Fix: xxx如果你觉得大写也无妨就把[a-z]改成[a-zA-Z]。注意字符集不要写反了[a-z]中间不要有逗号这是常见笔误。第二段\([a-z0-9-]\)匹配括号包含的模块名。括号在正则里是特殊字符所以要在前面加反斜杠转义。[a-z0-9-]里的连字符放在末尾表示普通连字符如果放在开头就变成范围定义符会直接报语法错误。模块名的长度限制我一般不开因为有人写fix(two-factor-auth)这种长模块名长度限制反而成了误杀理由。第三段: . #[0-9]$强制要求冒号后面至少有一个字符然后空格、井号、纯数字编号结尾。这里的.不能写成.*否则会出现feat: #123这种没有任何描述的提交信息绕过了校验我踩过这个坑。还要注意这里用的是普通空格匹配如果你的提交信息用 Tab 分隔正则就不匹配纯属自找麻烦所以团队规范里要写明使用空格分隔。4.3 规则文件外置的意义改规则不用动钩子脚本我把规则正则放在commit-msg-rules.txt文件里而不是硬编码在脚本中的原因纯粹是后期维护成本。正则写进脚本后每次调整正则都要动钩子文件、重新同步所有仓库风险太大。外置规则文件后改正则只需要把文件内容换掉钩子脚本实现的是“读取规则并执行检查”逻辑彻底分离。用外置规则文件还有一个隐藏好处你可以按分支应用不同规则。比如master分支用严格模式dev分支用宽松模式。这个在脚本里做好分支判断规则文件的内容根据refname按需加载就行。但如果你觉得多个规则文件维护起来麻烦保持一个严格规则对所有分支通用也可以接受这是一笔维护成本换规范简化度的交易。5. 常见问题与避坑那些日志不会告诉你的现场教训5.1 现象一钩子脚本没生效违规提交照样推上去了这大概是所有人遇到的第一个问题。脚本放在custom_hooks目录里chmod也给了x推送却毫无反应。原因说得最多的有三个目录名拼错GitLab 只会认custom_hooks这个复数少个 s 直接失效脚本文件名带了后缀比如pre-receive.shGit 调用的是无后缀的pre-receive文件再就是脚本解释器路径问题GitLab 执行钩子时用的是受限 shell 环境如果你的脚本头部写的是#!/bin/bash而服务器上没有安装 bash或者路径不是/bin/bash脚本会静默失败。解决依次核对这三件事先ls看文件名和目录名再用head -1看解释器路径最后用su - gitlab-xxx -c bash -n /path/to/pre-receive做一次语法检查。5.2 现象二git push 被拒绝后提示信息是乱码或丢失了脚本里我用echo 2向标准错误输出错误信息但有时候开发者只看到 “pre-receive hook declined”看不到具体错误原因。原因通常有两种脚本的输出被 Git 服务器端对 pty 的处理吞掉了或者脚本里的echo语句中引号没有转义导致变量展开异常。解决检查脚本中所有echo语句确保自定义错误文本是用双引号包起来的并且文本里不要带特殊字符。如果还不行改用printf输出错误信息它比echo对转义的处理更可控。另一个兜底做法是把错误信息追加写入一个日志文件方便你自己事后查echo 提交 $commit 不符合规范 /var/log/pre-receive-errors.log5.3 现象三新建分支和删除分支触发了莫名的检查失败新鲜的分支推送时newrev有值但oldrev为空删除分支时反过来。很多脚本没做空值判断导致 Git 命令执行时报错或检查了错误的对象范围。解决方式我在脚本里已经写明了使用前先判断-z $oldrev和-z $newrev分支分别处理。删除分支时所有提交都已经确定根本不需要检查任何东西直接continue跳过。5.4 现象四强制推送和交互式变基后检查范围扩大导致误杀开发者使用git push --force-with-lease或执行过git rebase后oldrev 所在的提交可能已经被重写git rev-list oldrev..newrev的范围会包含很多无关提交。这时候脚本容易把不该检查的提交也拉进检查列表造成误报。解决深入研究git rev-list的--not --all参数的边界但更稳妥的做法是在团队层面约定强制推送原则上要走合并请求禁止对公共分支执行rebase。这条约定比任何脚本都保护力强。5.5 现象五规则文件路径找不到导致所有推送被拒如果脚本里写死了规则文件的相对路径而 GitLab 执行钩子时当前工作目录并不是仓库根目录就会读到不存在的位置。解决我这里用GIT_DIR$(pwd)来定位是因为 GitLab 在调用 custom_hooks 时工作目录就是 Git 仓库的.git目录这是官方行为。但如果你的运行环境和这台服务器不完全一致最保险的做法是给规则文件写一个绝对路径或者用git rev-parse --show-toplevel去推导。6. 进阶用法把检查结果反馈到 GitLab 页面和消息通知上当你把基础检查跑稳之后技能的下一步自然是想把通过或拒绝的结果反馈到更多地方。GitLab 的 Web 页面上被 pre-receive 拒绝的推送会显示一个红色警报但很多时候开发者根本不会第一时间去翻页面是在本地终端里看到报错才知道推送失败了。这里有一个提升效率的进阶技巧让 pre-receive 钩子在检查失败时用 GitLab 的 Webhook 机制通知到团队的即时通讯平台或缺陷管理系统。具体做法不复杂。在 GitLab 服务器上配置一个出站网络请求的许可然后在脚本的失败分支里调用系统命令向公司内部的消息网关发送一个简单 HTTP POSTif [ $failed -ne 0 ]; then # 拼接被抓到的违规提交信息, 发送到群机器人 payload{\text\: \推送被拒绝: $commit\\n原因: $commit_msg\} curl -s -X POST -H Content-Type: application/json \ -d $payload \ http://内网通知网关地址/hooks/推送通知 exit 1 fi这个进阶技巧的本意不是监控人力而是让整个检查链条的反馈速度和可视化程度跟上研发的节奏。开发者推送失败的一瞬间组内所有人都能看到是哪条提交出了错这比单独逼着推送者看终端输出要温和得多也避免他默默改了本地历史再强制推送来绕过检查。如果你的团队没有内部消息网关可以退而求其次在脚本失败时把这个信息写入 GitLab 服务器上固定的日志文件配合日志采集工具做分析。这些都是把 pre-receive 从一个拦截工具变成流程价值点的自然延伸。末尾分享一个我的个人习惯每次在钩子里加新规则我都先故意写一条违规提交做演练确认被拒绝再写一条合规的确认通过。这套动作我从没跳过因为钩子是服务端的改坏了影响的是所有人谨慎一点不丢人。希望这套记录能帮你顺利落地同一件事。本文还有配套的精品资源点击获取