
开场
先看一个常见场景。团队使用 GitLab CE 社区版,仓库里随处可见 update、fix bug、init、123、修改 之类的 commit,分支名也很随意,比如 test、wjw-dev、新分支2。想启用 Push Rules,才发现这个功能只支持 Premium 及以上版本;改用 CI 校验,通常又要等流水线运行后才能发现问题。此时代码已经推到远程仓库,再修改 commit history 会很麻烦。
这类问题的难点不在正则怎么写,而在于校验应该放在哪一层。本文会介绍三种适用于 GitLab CE 的代码规范落地方案,并给出一套已在 Docker 部署的 GitLab 18.x 上验证通过的全局服务端 Hook 配置,同时说明路径和 Gitaly 配置中容易出错的地方。
结论
- 使用 GitLab 企业版:直接启用 Push Rules,在 Web 界面填写正则表达式,无需额外配置。
- 使用 GitLab CE 社区版且有宿主机权限:在服务端配置 pre-receive Hook,在 push 时拦截不合规内容,避免其进入仓库。
- 只能操作项目仓库、没有服务器权限:使用
.gitlab-ci.yml配合 MR 校验。
配置时最容易出错的是路径。GitLab 15 及更高版本的钩子目录由 Gitaly 管理,旧教程提到的 /var/opt/gitlab/git-data/repositories/@hashed/... 或 gitlab-shell/custom_hooks 已不再生效。现在应使用 Gitaly 的 custom_hooks_dir,并在 gitlab.rb 中显式启用。下文说明具体配置方法。

规范由来
团队规范不能凭空制定,业界已有两套较成熟的标准可供参考。
Commit Message 采用 Angular 规范,基本格式为 <type>(<scope>): <subject>。type 通常限定为七种:feat、fix、docs、style、refactor、test、chore。scope 可选,一般填写模块名,例如 feat(auth): add sso login。统一格式后,可以借助 standard-version 或 semantic-release 自动生成 CHANGELOG 和版本号。
分支命名参考 Git Flow,前缀统一为五种:feature/、bugfix/、hotfix/、release/、chore/。通过分支名,可以大致判断分支的生命周期和合并策略。hotfix/ 从 main 拉出,修复后合回 main 和 develop;feature/ 从 develop 拉出,完成后合回 develop。命名规则固定后,CI 可以根据前缀执行不同的流水线,例如只允许 release/* 触发预发部署。
对应的正则如下:
# commit
^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+# branch
^(main|master|develop|feature\/.*|feat\/.*|bugfix\/.*|fix\/.*|hotfix\/.*|release\/.*|chore\/.*)$
后续方案都围绕这两条正则展开。
三种方案
把三种拦截方案放在一起对比,差别会更清楚,也不容易选错。
| 方案 | 拦截时机 | CE 是否可用 | 是否消耗 CI | 代码是否进入远端 | 运维成本 |
|---|---|---|---|---|---|
| Push Rules | push 时 | 否,仅企业版可用 | 不消耗 | 未进入 | 极低,通过 Web 配置 |
| .gitlab-ci.yml | push 后运行流水线 | 可用 | 消耗 | 已进入 | 低 |
| pre-receive Hook | push 时 | 可用 | 不消耗 | 未进入 | 中,需要宿主机权限 |
CI 校验的问题在于,代码推到远端后才会报错。比如向 test-branch push 了一个 init,随后流水线失败。这时只能通过 rebase 修改 commit history 后强推,或者删除分支重来。团队成员如果不熟悉 Git,处理过程中很容易把仓库弄乱。
服务端 Hook 在 git push 的握手阶段运行。脚本执行 exit 1 后,push 会直接失败,远端仓库不会写入代码。它使用原生 Git 机制,也不占用 GitLab Runner 资源,但需要有权限修改宿主机的挂载目录。团队制定相关规范时,可以用这份对比作为选型依据。

脚本核心
服务端 Hook 按以下方式执行:GitLab 收到 push 后,会通过标准输入逐行传入 <oldrev> <newrev> <refname>。脚本以 exit 0 正常退出时放行,返回非零值时拒绝推送。脚本通过 echo 写入 stdout 的内容,会原样显示在推送者的终端中,可以用来说明拒绝原因。
下面是生产环境实际运行的版本,主要逻辑已添加注释:
#!/usr/bin/env bashCOMMIT_REGEX="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+"
BRANCH_REGEX="^(main|master|develop|feature\/.*|feat\/.*|bugfix\/.*|fix\/.*|hotfix\/.*|release\/.*|chore\/.*)$"# 全 0 的 SHA 代表分支删除(newrev)或新建分支(oldrev),要单独处理
zero_commit="0000000000000000000000000000000000000000"while read -r oldrev newrev refname; do# 分支删除操作不拦截,只校验新建和更新if [ "$newrev" != "$zero_commit" ] && [[ "$refname" == refs/heads/* ]]; thenbranch_name="${refname#refs/heads/}"if [[ ! "$branch_name" =~ $BRANCH_REGEX ]]; thenecho "❌ [GitLab 拦截] 分支名 '$branch_name' 不符合规范"exit 1fifi# 新分支(oldrev 全 0)要用 --not --branches 找出该分支独有的 commit# 已有分支只需要检查增量部分,避免把历史遗留的脏 commit 也拦下来if [ "$oldrev" = "$zero_commit" ]; thencommit_list=$(git rev-list "$newrev" --not --branches --not --tags)elsecommit_list=$(git rev-list "$oldrev..$newrev")fifor commit in $commit_list; do# 只校验第一行,去掉首尾空白,避免复制粘贴带空格误判commit_title=$(git log --format=%B -n 1 "$commit" | head -n 1 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')if [[ ! "$commit_title" =~ $COMMIT_REGEX ]]; thenecho "❌ [GitLab 拦截] Commit 格式错误: '$commit_title'"echo "正确示例: feat: add login 或 fix(order): fix crash"exit 1fidone
doneexit 0
这里有两个容易出错的地方。第一个是新分支的 commit 边界。如果不加 --not --branches --not --tags,git rev-list <newrev> 会列出从该分支向前追溯到的全部 commit,连仓库早期的提交也会被重新校验,最终出现大量报错。
第二个是 subject 的清洗。有些 IDE 会在 commit message 开头加入空格或 BOM,导致正则匹配失败,因此需要用 sed 去除首尾空白。

踩坑一:路径变了
脚本不难写,麻烦的是让 GitLab 真正调用它。第一次部署后,init 仍然正常 push,脚本没有任何反应。
先查路径。网上搜到的基本都是 custom_hooks/pre-receive.d/,但完整路径有三种写法:
- 老版本:
/var/opt/gitlab/git-data/repositories/<项目>.git/custom_hooks/ - 中间版本:
/opt/gitlab/embedded/service/gitlab-shell/custom_hooks/pre-receive.d/ - 新版本(15+ 到 18.x):Hook 由 Gitaly 接管,需要使用
gitaly的custom_hooks_dir
一开始按第二种方式,把脚本放在 gitlab-shell 下,结果没有生效。GitLab 15 之后,gitlab-shell 只处理 SSH 层,push 时实际执行 Hook 的是 Gitaly,老路径也不会再被读取。
项目级 Hook 可以临时解决问题,但项目在磁盘中使用哈希路径,例如 @hashed/d4/73/d4735e3a...git。每个新项目的哈希都不同,靠手工挂载很难维护,所以最后还是要使用全局路径。
踩坑二:空文件与静默通过
路径改对后,脚本还是没有生效。继续排查时,下面这条命令暴露了问题:
docker exec -it gitlab ls -la /var/opt/gitlab/gitaly/custom_hooks/pre-receive.d/
# -rwxr-xr-x 1 root root 0 Jul 17 16:03 check_rules
文件大小竟然是 0 字节。之前用 IDE 远程编辑时操作失败,文件被覆盖成了空文件。空脚本执行后,退出码依然是 0,GitLab 便把它当成校验通过,直接放行。整个过程看起来都没问题:文件存在,权限正常,路径也正确,很难第一时间发现异常。
如果线上遇到「配置已经生效,功能却没反应」的情况,可以按这个顺序检查:先看文件是否为空,再查权限和归属,最后检查脚本逻辑。顺序错了,很容易在无关的问题上浪费时间。
还有一个常被忽略的问题:文件所有者。GitLab 容器内使用 git 用户运行,UID 通常是 998。如果宿主机上的文件由 root 写入,容器中的 git 用户可能无法执行。写入文件后,最好再执行:
docker exec -it -u 0 gitlab chown -R git:git /var/opt/gitlab/gitaly/custom_hooks
docker exec -it -u 0 gitlab chmod -R 755 /var/opt/gitlab/gitaly/custom_hooks

踩坑三:Gitaly 默认不认这个路径
问题就出在这里。路径没错,文件不为空,权限也正常,但 init 仍然可以提交。查看日志:
docker exec -it gitlab tail -f /var/log/gitlab/gitaly/current
日志里没有任何 custom_hooks 或 pre-receive 的调用记录,说明 Gitaly 根本没有从这个目录读取脚本。
查阅官方文档后发现,新版 GitLab 需要在 gitlab.rb 中显式配置 gitaly 的 custom_hooks_dir。它的默认值可能为空,也可能指向其他位置。只把文件放进 /var/opt/gitlab/gitaly/custom_hooks 不会生效,因为 Gitaly 默认不读取这个路径。
在挂载出来的 ./config/gitlab.rb 中加入:
gitaly['custom_hooks_dir'] = "/var/opt/gitlab/gitaly/custom_hooks"
然后执行:
docker exec -it gitlab gitlab-ctl reconfigure
这条命令会重新渲染 Gitaly 的 config.toml,写入 [hooks] custom_hooks_dir 配置。reconfigure 完成后不用重启容器,Gitaly 会自动重载。
此时再次 push 一个 init,终端会直接显示红色的拦截提示。整个链路终于跑通了。
全局配置方案
把前面遇到的问题整理后,可以按下面的步骤统一配置。设置完成后,现有项目和之后创建的新项目都会生效。
第一步:修改 gitlab.rb,让 Gitaly 识别路径
在挂载的 ./config/gitlab.rb 文件末尾加入:
gitaly['custom_hooks_dir'] = "/var/opt/gitlab/gitaly/custom_hooks"
第二步:在宿主机上准备脚本
mkdir -p ./data/gitaly/custom_hooks/pre-receive.dsudo tee ./data/gitaly/custom_hooks/pre-receive.d/check_rules >/dev/null <<'EOF'
# 这里贴入前面那份完整脚本
EOF
<<'EOF' 外的单引号不能省略,否则 shell 会提前展开 $oldrev 等变量。
第三步:修正权限
docker exec -it -u 0 gitlab chown -R git:git /var/opt/gitlab/gitaly/custom_hooks
docker exec -it -u 0 gitlab chmod -R 755 /var/opt/gitlab/gitaly/custom_hooks
这一步必须执行。
第四步:执行 reconfigure,使配置生效
docker exec -it gitlab gitlab-ctl reconfigure
第五步:验证配置
在本地任选一个项目执行:
git commit -m "init" --allow-empty
git push origin main
# 应该看到 remote: ❌ [GitLab 拦截] ...
如果看到红色的拒绝提示,说明配置已经生效。

边界与取舍
这套方案并非适用于所有情况,有几个边界需要提前说明。
不适合使用服务端 Hook 的场景:
- GitLab.com 或 SaaS 版:无法获取宿主机权限,只能使用 Push Rules 或 CI。
- 团队有大量遗留仓库,或历史 commit 不符合规范:Hook 主要校验新增内容。rebase 老分支时,旧 commit 可能因不合规而被拦截。遇到这种情况,可以为老分支设置白名单,或临时禁用 Hook,集中完成迁移。
- 不同项目需要使用不同规则:全局 Hook 会统一执行同一套规则。如果 A 项目要求中文 commit,B 项目要求英文,更适合使用项目级
custom_hooks。
推给团队之前,可以先做好两件事:
- 配置 husky 和 commitlint,先在本地拦截。开发者执行
git commit时就能发现问题,不必等到 push 后再被服务端拒绝。本地和服务端各设一道检查,使用起来更顺手。 - 写清楚豁免机制。谁有权临时关闭 Hook,出现问题该联系谁,都要提前约定,避免规范影响正常开发。
最后可以检查一下项目的 commit 历史。很多团队嘴上说有规范,打开 log 却满是 update、修改一下、test。这样的规范其实只留在 wiki 里。
上线检查清单
部署前,按下面的项目逐项检查:
遇到问题时,按以下顺序排查:
- 查看 Gitaly 日志,确认是否触发了 Hook
- 检查脚本文件大小和权限
- 进入容器,手动向脚本传入 stdin:
echo "oldsha newsha refs/heads/test" | ./check_rules - 对比
gitlab.rb配置和 Gitaly 实际加载的config.toml,确认两者一致
技术文章最怕只讲方案不谈代价,只写成功经验不提踩过的坑。本文会尽量讲清 GitLab CE 落地规范过程中可能遇到的问题,方便需要时查阅。团队里负责 DevOps 或代码规范的人也可以直接参考,能少走不少弯路。如果你在其他 GitLab 版本中遇到过更棘手的问题,尤其是“配置全对却不生效”这类情况,可以补充具体场景。