
简介一份基于Go语言实现的GitLab pre-receive钩子示例资源面向需要规范仓库提交规范的GitLab管理员或Go开发者。该钩子可在用户执行git push时于服务端拦截推送通过检查最新提交消息是否包含指定关键词如“fix”实现对提交信息的强制性校验适用于团队协作与CI流程前置管控。压缩包仅3KB共4个文件以Go源码为主体附有README说明、LICENSE许可及.gitignore配置结构精简适合快速阅读与二次开发。已有1990人学习。资源中完整给出了钩子的核心实现逻辑包括通过git log获取提交内容并进行关键词匹配并对非零退出码拒绝推送做了清晰演示在此基础上稍作调整即可扩展为多关键词、作者校验或分支限制策略是理解GitLab服务端钩子机制的高效参考。1. pre-receive为什么非要在服务端拦截一次不规范的 commit组里小半年都在靠git log猜谜有人提交消息是一串空格有人把键盘乱敲的字符直接粘上去出问题要回滚根本不知道哪个提交对应哪个需求。这个问题放到 CI 阶段才解决已经太晚分支早就推上去了指望客户端的commit-msg钩子更不现实因为别人完全可以不装、不改。pre-receive 是 Git 服务端自带的钩子在 push 收包阶段、引用真正更新之前执行通过 stdin 读入“旧 SHA、新 SHA、引用名”三样数据脚本返回非零就拒绝整批推送。把检查脚本放进 GitLab 仓库的custom_hooks目录社区版就能用不需要改任何仓库配置。适合想让提交消息强制带工单号、屏蔽脏词和敏感信息的团队这是投入最小、执行最硬的一条前置防线。2. pre-receive 钩子的工作机制一次推送、三段输入、在引用更新前执行2.1 钩子在收包阶段做了什么pre-receive 运行在服务端的git receive-pack进程里。你用git push把提交推过去服务端先接收对象数据然后在真正移动分支或标签指针之前调用这个钩子。钩子拿到的是一份“本次推送的引用更新清单”不是文件 diff也不是某个目录的内容。它必须自己用git命令去查提交历史和消息内容。钩子通过 stdin 逐行读取数据每行三个字段顺序固定旧引用 SHA、新引用 SHA、引用名。旧 SHA 全零表示新建分支或标签新 SHA 全零表示删除引用。这个判断几乎是每个脚本都要写的第一个分支漏掉谁就会出问题。#!/usr/bin/env bash # 这是 pre-receive 的标准骨架 # stdin 每一行代表一个将要更新的引用 # 旧引用SHA 新引用SHA 引用名 # 例如 # 0000... a1b2... refs/heads/main # a1b2... c3d4... refs/heads/feature/payment while read oldrev newrev refname; do echo 收到更新: ${oldrev:0:8} - ${newrev:0:8} ${refname} 2 done exit 0这段代码的意义是先把骨架跑通确认钩子确实被调用再往上加规则。echo ... 2是把信息写到标准错误Git 客户端只会把服务端 stderr 的内容显示给推送者如果不用2这段日志会被 Git 吞掉推的人什么都看不到。脚本退出码是成败的唯一信号。返回 0 放行返回非 0 拒绝。注意“拒绝”是整批的一次 push 里带了三个分支的更新只要其中一个检查不通过三个分支全部不更新。这不是 Bug是设计避免服务端出现引用之间的不一致状态。pre-receive 执行时的工作目录是这个仓库的.git目录裸仓库的.git就在仓库根目录。因此脚本里可以直接跑git rev-list、git log这类只读命令不需要额外指定--git-dir。这一点让钩子写起来比很多人想象中简单。2.2 为什么不用 commit-msg、不依赖 CI 流水线commit-msg钩子长在客户端git clone下来的人根本不会带你的钩子想绕过的人用git commit --no-verify就能关掉。它适合做“本地提醒”做不了“团队强制”。GitLab Runner 上的 CI 检查则太晚代码已经推上去了坏消息已经进了远程仓库后续要么 force push 回滚要么在历史里留一个不合规的提交。更重要的是CI 只在 pipeline 触发时跑不跑 pipeline 的推送就完全漏掉。pre-receive 的位置刚好在两者之间代码到达服务端、但没有落进任何分支历史时执行是最后一道闸门。GitLab 里常说的 Server Hooks 是企业版功能但custom_hooks目录的 pre-receive 是 Git 原生能力社区版完全可用。很多人以为要花钱其实只需要在正确的位置放一个可执行脚本。持续集成流水线做的事应该是编译、测试、部署而不是替 Git 检查消息格式。把消息校验放到 pre-receive流水线能少跑很多无谓的构建GitLab 的日志里也少一些因为“提交消息太短”而失败的 pipeline。3. 在 GitLab 上落地最小可用钩子路径、权限与第一个示例3.1 钩子目录的两种版本差异与权限确认GitLab 的钩子目录随版本演进有两种写法。老部署习惯把脚本直接命名为pre-receive放在仓库的custom_hooks下近几个大版本推荐在custom_hooks下再建一个pre-receive.d目录里面放任意可执行文件名方便一个仓库挂多个钩子。部署习惯仓库内钩子路径特点老版本 / 习惯单脚本repo.git/custom_hooks/pre-receive一个文件就是一个钩子简单直接新版本 / 推荐多脚本repo.git/custom_hooks/pre-receive.d/pre-receive目录下放多个可执行文件逐个执行要找到这个仓库在服务端磁盘上的真实路径用 GitLab Rails Runner 查disk_path是最快的办法。不管是 Omnibus 包安装还是 Docker 安装 GitLab这条命令都适用区别只是容器内要先进去执行。# 在运行 GitLab 的机器或容器内执行 gitlab-rails runner p Project.find_by_full_path(group/project).disk_path # 输出类似 # group/project.git # 物理目录在 /var/opt/gitlab/git-data/repositories/hashed/xx/xx/xxx.githashed是 GitLab 按哈希组织的存储目录路径里的xx/xx是根据项目 ID 算出来的。用disk_path拿到的是逻辑路径实际文件可能落在hashed下面。用find /var/opt/gitlab/git-data/repositories -maxdepth 2 -name xxx.git也能定位但没有 Rails Runner 直接。找到仓库目录后创建钩子文件并设置权限这一步很多人栽跟头cd /var/opt/gitlab/git-data/repositories/hashed/xx/xx/xxx.git mkdir -p custom_hooks/pre-receive.d chown git:git custom_hooks custom_hooks/pre-receive.d chmod 755 custom_hooks/pre-receive.d/pre-receive钩子文件属主必须是git用户且必须带可执行权限。.git 目录的访问权限很严如果脚本属主是 rootGitLab 的 git 进程没有读/执行权限钩子会静默失效。Git 对不可执行的钩子不会报红色错误只会忽略它这是最隐蔽的坑。Docker 部署时如果宿主机把/var/opt/gitlab挂载了出来钩子文件可以放在宿主机对应路径如果没挂载这个目录只能在容器内操作。容器重建前记得备份否则重新部署后钩子文件会随容器一起消失。3.2 最小检查脚本空消息与长度上限第一步不要写复杂规则先做一个能拒绝“空消息”和“超短消息”的脚本验证整条链路通不通。下面这个脚本可以直接抄它处理了新建分支、删除分支、多引用推送几个基本边界。#!/usr/bin/env bash # pre-receive: 最小 commit message 检查 # 规则: subject 不能为空, 长度 3~100 字符, 且不能只有空格和标点 error_msgs() while read oldrev newrev refname; do # 删除引用时不校验 [ $newrev 0000000000000000000000000000000000000000 ] continue if [ $oldrev 0000000000000000000000000000000000000000 ]; then revs$(git rev-list ${newrev} --not --all) else revs$(git rev-list ${oldrev}..${newrev} --not --all) fi for rev in $revs; do subject$(git log -1 --pretty%s $rev) short${rev:0:8} # 去掉首尾空白后再判断防止 这种纯空格消息 trimmed$(sed s/^[[:space:]]*//; s/[[:space:]]*$// $subject) if [ -z $trimmed ]; then error_msgs(${short} 的提交消息为空禁止空提交) continue fi len$(printf %s $trimmed | wc -m) if [ $len -lt 3 ] || [ $len -gt 100 ]; then error_msgs(${short} 的提交消息长度违反 3~100 限制当前 ${len} 字符) fi done done if [ ${#error_msgs[]} -gt 0 ]; then printf %s\n ${error_msgs[]} 2 exit 1 fi exit 0关键点在git rev-list ${oldrev}..${newrev} --not --all它列出本次推送真正新增的提交--not --all把服务端其他引用已经能到达的提交排除掉。这样新建分支时不会把仓库里已有的老提交拉出来重复检查正常推送时也只会检查新提交。git log -1 --pretty%s取的是提交消息的第一行也就是标题行。多行消息里的正文不在检查范围这个设计是刻意的多数团队值得规范的只有标题正文管太多容易误伤。wc -m统计字符数而非字节数中文消息不会因为 UTF-8 三字节编码被误判超长。如果服务端系统 locale 比较特殊建议在脚本开头加一行export LC_ALLen_US.UTF-8否则wc -m在某些环境下会退化成按字节统计。错误消息先收集进数组、最后统一输出比在循环里遇到一个就exit 1好。推送者一次能看到全部问题不用反复推三次才知道自己错了几处。3.3 验证脚本确实在工作脚本写好不是结束必须真实推一次验证。先在本地准备一条不合法提交git commit --allow-empty -m -m 空的提交消息 git push origin main预期看到类似remote: rejected的结果并且 stderr 里出现脚本输出的错误信息。如果 push 显示成功说明钩子没生效优先排查路径和权限。服务端日志能确认钩子是否被执行。Omnibus 部署的日志在/var/log/gitlab/gitlab-rails/gitlab-shell.log推送时的钩子调用会记录在githost.log。sudo tail -n 100 /var/log/gitlab/gitlab-rails/gitlab-shell.log sudo grep -i pre-receive /var/log/gitlab/gitlab-rails/githost.log如果日志里完全没有钩子相关记录基本可以断定是文件位置不对。另外要记住GitLab 的“导入项目”功能不会把custom_hooks目录一起打包迁移换机器或导入项目后钩子需要重新部署这一点最容易在项目迁移后造成“校验突然消失”的假象。4. 把规则做成可维护的规范库消息格式、必填字段与依赖判断4.1 从一条规则到一组规则规则分层与函数化最小脚本只有两个检查实际团队里通常要管更多是否带工单号、是否包含脏词、是否出现敏感信息、是否用类型前缀。把所有if堆在一个循环里会变成面条代码后续每加一条规则都要动主循环。我一般把每条规则写成独立函数函数接收 subject 和 commit 短 SHA发现问题就追加到全局error_msgs数组。主循环只负责遍历提交和调用规则维护时加一个函数、在规则数组里加一行就够。# 每个检查函数只负责一件事发现问题就往 error_msgs 里追加 check_required_ticket() { local subject$1 rev$2 if ! grep -qE [A-Z]-[0-9]|#[0-9] $subject; then error_msgs(${rev} 缺少工单号期望 JIRA-123 或 #456 格式) fi } check_forbidden_words() { local subject$1 rev$2 if grep -qiE fixme|later|临时|随便|去你 $subject; then error_msgs(${rev} 包含无意义/不文明关键词: fixme/随便 等) fi } check_secret_leak() { local subject$1 rev$2 if grep -qE (password|passwd|secret|token|api[_-]?key) $subject; then error_msgs(${rev} 疑似把敏感字段写在 message 中请清理后重推) fi } # 规则注册表以后新增检查加函数 加数组元素即可 rules(check_required_ticket check_forbidden_words check_secret_leak) for rev in $revs; do subject$(git log -1 --pretty%s $rev) short${rev:0:8} for rule in ${rules[]}; do $rule $subject $short done done规则要有分层意识。我会把规则分成两类硬性规则必须拒绝软性规则先警告。硬性包括“subject 非空、长度合理、不含敏感信息”这类错误没有辩解空间软性包括“必须带工单号、必须带 feat/fix 类型前缀”这类规则有业务上下文上线前要和团队对齐。下表是常用的规则分层参考具体字段按自家研发流程改规则类型检查内容执行强度硬性标题非空、长度 3~200 字符直接拒绝硬性禁止密码、token、API Key 等敏感字段直接拒绝硬性禁止纯标点/纯空格/乱码直接拒绝团队必须包含工单号JIRA-123或#456建议先警告后强制团队类型前缀feat(scope): 描述建议先警告后强制软性消息正文每行不超过 72 字符不做检查靠社区规范grep -qE用的是扩展正则是 bash 的 here-string把变量内容喂给 grep 的标准输入。这个写法比echo $subject | grep -qE ...少一个子进程也避免了主题内容被当成参数解析的意外。敏感信息检查只能是“防误操作”防不了恶意。真正想防密钥泄露要靠 GitLab Secret Detection 这类工具pre-receive 里的正则只是给手滑的人一个后悔药。4.2 边界条件临时绕过、amend、merge commit、多引用规则越严边界情况越多这几个是我实际维护中遇到最多的。紧急绕过通道必须有。线上故障时修复者可能真的来不及按规范写工单号硬拦会变成事故放大器。我一般允许标题里带[skip-check]的提交直接放行但必须写审计日志if [[ $subject *[skip-check]* ]]; then echo $(date %F %T) ${short} ${refname} ${subject} /var/log/gitlab-pre-receive-skip.log continue fi绕过开关必须让所有人都知道有这个口子同时日志要留全。谁在什么时候跳过了检查事后可以在日志里审。日志目录注意权限git用户需要能写。git commit --amend怎么使用在这个场景下是修复消息的唯一后悔药提交已经推上去但消息不对本地改完再强制推送pre-receive 校验的是 amend 之后的新提交消息。git commit --amend -m feat(user): 新的合规标题 git push --force-with-lease origin feature/payment--force-with-lease比--force可靠推送前会确认远程引用和你上次拉取时一致避免把同事刚推上去的提交冲掉。注意 amend 之后 SHA 变了如果分支上还有其他人基于旧提交开发协调成本要提前说清楚。merge commit 是 pre-receive 规则最容易误伤的地方。GitLab 合并请求的自动 merge commit 消息长这样Merge branch feature/payment into master它天然不满足“必须带工单号”这类规则。如果脚本不识别整个 MR 会卡在合并按钮上而合并不是开发者能自行绕过的。处理方式是在检查循环开头放行[[ $subject ~ ^Merge\ branch ]] continue多个引用一起推送时一个坏引用会让整批被拒。这是 design by contract新人在第一次遇到时会很困惑“我只改了 feature/a为什么 main 也没推上去”。解决方案是错误消息里把引用名也带上${short} (${refname}) 的提交消息为空这样推送者能立刻定位是哪个分支的哪个提交出问题。另一个常见误用是试图在 pre-receive 里检查“提交人邮箱是否符合公司域名”。这个信息在git log --pretty%ae里可以拿到但改了作者信息通常需要 rebase 或 push 别人开发的提交代价远超消息格式问题。我一般把这个规则留给准入控制平台去管pre-receive 只碰 commit message。5. pre-receive 常见问题与避坑清单为什么我推不上去、为什么钩子没生效5.1 五个真实踩坑记录1. 钩子没生效不合规提交照样被收下现象脚本写完放到服务器本地推一条空消息居然推成功了。原因最常见的是文件没有执行权限或者放到了custom_hooks/pre-receive而 GitLab 新版本实际路径应该放在custom_hooks/pre-receive.d/pre-receive。Git 对不可执行的钩子静默跳过不报错不警告。解决逐项检查路径、属主、权限。chmod 755、chown git:git做完后再推一次。确认路径用ls -l custom_hooks/pre-receive.d/不要把钩子误放到本地仓库的.git/hooks下——那是给自己看的不是给服务端看的。2. 新建分支的所有提交全部被误报现象新建一个分支推上去脚本报了一堆本来没问题的老提交或者直接报fatal: bad revision。原因新建分支时 oldrev 是 40 个 0我没做全零判断直接拿git rev-list ${oldrev}..${newrev}旧对象不存在git 必然报错。解决前面骨架代码里的if [ $oldrev 0000000000000000000000000000000000000000 ]分支不能省全零状态下直接用newrev作为 rev range 起点。3. 客户端只看到“remote: rejected”不知道因为什么现象脚本里明明写了很详细的错误信息推送者那边屏幕上只有一句remote: rejected。原因错误信息写到了 stdout。Git 对 pre-receive 的 stdout 不会原样透传给客户端透传的是 stderr。钩子返回非零时客户端只显示 rejected。解决所有给用户看的提示统一追加2。我见过不少人用echo不重定向排查半天以为是规则没命中。写成printf %s\n ${error_msgs[]} 2推送者就能看到每条违反的具体规则。4. 合并请求的自动 merge commit 被规则卡死现象功能分支一切正常点 GitLab 的 Merge 按钮却一直失败错误信息指向Merge branch ... into ...。原因GitLab 自动生成的合并提交消息不满足“必须带工单号”或“必须带类型前缀”它被当成普通提交检查了。解决脚本开头放行^Merge branch开头的 subject。需要说明的是这样才能保证 MR 合并过程不被自己的规范阻断否则团队会恨死这个钩子。5. 从 Windows 编辑的脚本导致 bash 崩溃现象推送到远程时报/usr/bin/env: bash\r: No such file or directory。原因脚本在 Windows 上保存成了 CRLF 换行shebang 行的#!/usr/bin/env bash后面多了个\r内核把bash\r当成可执行文件名。解决脚本放到服务端后执行sed -i s/\r$// 文件名或者用dos2unix转换。之后每次都从 Linux 环境编辑脚本避免再引入这问题。还有一个经常出现但不属于拒收的问题是“username and email must be set before commit”。这是客户端本地提交时报的错和 pre-receive 无关。出现它说明推送者的 Git 全局配置里没有 user.name 和 user.email提交根本没有生成。在 GitLab 上建议引导新人配置好提交身份否则 pre-receive 连消息都读不到。5.2 调试钩子的常用命令与观察点钩子出问题先确认三层脚本有没有被执行、参数读得对不对、规则结果是什么。# 1. 确认钩子文件和权限 ls -l /var/opt/gitlab/git-data/repositories/hashed/xx/xx/xxx.git/custom_hooks/pre-receive.d/ # 2. 跟踪一次推送的日志 sudo tail -f /var/log/gitlab/gitlab-rails/gitlab-shell.log # 3. 临时开启钩子调试输出 # 在脚本开头加一行: # echo pre-receive called with: $oldrev $newrev $refname 2加调试输出后推送一次客户端能看到每行引用的旧新 SHA 和引用名基本就能判断是遍历逻辑的问题还是规则本身的问题。排查阶段最忌讳在脚本里乱加exit。我习惯把所有规则错误都收集到数组、最后统一输出这样调试时一次能看到全部违反项而不是被第一条拦死。6. 一次白天误拒绝的复盘先放行再收紧6.1 那天我拦掉了一位同事的紧急修复有一次我把“必须带工单号”设成了硬性规则上线当天下午线上故障运维的同学写了一条fix: 紧急修复支付超时没有工单号被 pre-receive 硬生生拦下。他急着推修复又不知道去哪找测试环境里的工单号来回沟通花了近二十分钟。那次的教训不是“规则不该设”而是“规则上线方式错了”。我已经留了[skip-check]绕过通道但对方不知道有这个通道也没有在任何文档里看到过。事后我做了两件事把绕过标记写进团队 GitLab 使用教程的提交规范页把新规则的默认策略改成先记录、不拒绝。6.2 上线前必须跑一遍的验证清单新钩子我建议放在灰度环境跑至少一周用下面这张表逐项验证全都符合预期再拿到生产仓库检查项预期行为空消息推送拒绝提示“提交消息为空”普通合规消息推送接受新建分支推送只检查新分支上的新增提交删除远程分支放行不检查MR 自动合并提交放行Merge branch开头消息amend 后 force-with-lease 推送按新消息检查带[skip-check]的消息放行并写入审计日志一次推送两个分支其中一条违规整批拒绝并指明违规分支6.3 决定钩子策略的顺序我现在做钩子规则只有三个顺序先黑名单拒绝再白名单收紧最后加灰度观察。黑名单拒绝面向的是“空消息、敏感信息、脏词”这类没有任何争议的错误白名单收紧面向的是“必须带工单号、必须带类型前缀”这类团队约定俗成的事项。白名单规则上线前先跑一周只记录不拒绝。把所有不合规消息写进日志人工看一遍确认这些都是该拦的再把记录改成拒绝。第一天就直接上白名单容易把自己和同事都逼疯。日志文件我会留着至少一个季度。出争议时翻日志能说清楚一条提交到底是被哪条规则拦的、当时有没有走紧急通道、绕过次数有没有异常。这套做法看着保守但比“写个脚本一把梭”可靠得多。希望帮到你。本文还有配套的精品资源点击获取