简介:针对 GitLab 仓库提交规范检查场景,作者以 Go 语言编写了一个轻量级 pre-receive 钩子,面向需要强化代码提交管理的开发者、运维或 GitLab 管理员。资源通过服务端预接收机制,在用户推送时自动校验最新提交消息是否包含指定关键词(如 fix),不满足即拒绝推送,适合希望在不改造客户端的前提下统一提交信息格式、拦截异常提交的团队使用。压缩包体积仅 3KB,共 4 个文件,核心是 main.go 钩子源码,辅以 LICENSE、README 与 .gitignore,文件精炼、便于阅读和二次扩展。资源目前已吸引 1992 人学习下载。除基础校验逻辑外,代码提供了清晰的 stdin/refs 处理思路,可直接编译部署到 GitLab 服务器的 hooks 目录,并根据实际需求扩展关键词校验、作者身份验证或分支推送限制等策略,是一份兼具实用性与学习价值的服务端钩子参考实现。
1. 一次 push 被拦下的背后:这个 pre-receive 钩子到底在查什么
当你把一个 commit 推送到远程分支时,GitLab 会先把这次推送交给 pre-receive 钩子过一遍。这个项目做的事情很直接:在服务器端检查每个新提交的 commit message,不符合正则规则就直接把 push 拒掉,任何不合规的提交都到不了远程分支。举个例子:某团队长期被 “update” 和 “fix bug” 这类消息困扰,回滚时找不到对应提交,review 时看不出改动意图。口头约定解决不了这个问题,因为约定只能约束自觉的人。把规则写进 pre-receive 钩子,等于在服务器上装了一道闸门,人人平等。这个项目适合所有用 GitLab、想从工具层面落实提交规范、又不想在客户端配钩子的团队。它用 Go 实现,编译成单个二进制文件,部署到 GitLab 服务器即可,不需要改任何客户端配置。
2. 技术选型与整体设计:为什么用 Go 写 GitLab 钩子
2.1 为什么是 Go 而不是 Shell 或 Ruby
pre-receive 钩子本质上是服务器上的一个可执行文件。GitLab 收到 push 请求后调用它,往标准输入里写入 ref 更新信息,然后看它的退出码:0 接受推送,非 0 拒绝。实现这个可执行文件的语言选型,直接决定后期维护成本。
| 方案 | 优点 | 痛点 |
|---|---|---|
| Shell 脚本 | 零编译,改完即生效 | 正则写起来啰嗦;多分支多 commit 时逻辑混乱;git 命令的输出解析全是字符串切片 |
| Ruby 脚本 | GitLab 内置 Ruby,可直接调用 GitLab API | 服务器 Ruby 版本和 Gem 依赖容易出问题;部署时还得 bundle install |
| Go 二进制 | 静态编译、交叉编译、无运行时依赖;标准库自带 regexp 和 encoding/json | 改规则需要重新编译,但这在 CI 里一条命令就能解决 |
我一般会选 Go。原因有三个:一是单文件部署,GOOS=linux go build出一个二进制扔到服务器就能跑,没有 gem 依赖、没有 python 环境问题;二是 Go 标准库的regexp处理这种一次性校验性能足够,解析几百个 commit message 毫秒级完成;三是后续如果要调 GitLab API 或者对接企业内部系统,标准库的net/http直接就能干活,不用引第三方包。
2.2 项目目录结构与运行链路
这个项目拆开就几个文件,结构很清晰:
pre-receive/ ├── go.mod # 模块定义,无第三方依赖 ├── main.go # 入口:解析 stdin、串联整个检查流程 ├── rule.go # 规则引擎:正则定义、commit message 校验 └── deploy/ └── install.sh # 构建并安装到 GitLab custom_hooks 目录整个运行链路是这样的:开发者执行git push,GitLab 的 Git 接收进程触发 pre-receive 钩子,钩子从标准输入读取三行内容(旧 rev、新 rev、ref name),对每个 ref 调用git rev-list拿到新增提交列表,再对每个提交执行git log -1 --format=%s取 subject 做正则匹配。任何一个 commit 不合规,就把错误信息写到标准错误输出并返回非 0 退出码,GitLab 会将 stderr 内容直接展示给 push 的开发者,同时拒绝本次推送。
2.3 pre-receive 的输入输出协议:stdin 三行和多 ref 循环
GitLab 的 pre-receive 钩子协议很简单:标准输入每一行是一次 ref 更新请求,格式为旧rev 新rev ref名,中间用空格分隔。一次 push 可能同时更新多个分支或标签,所以钩子必须循环处理。
输出方面有个坑:GitLab 只会把钩子的标准错误输出回显给客户端,标准输出会被忽略。所以项目里所有错误信息都写os.Stderr,包括提醒用户“第几条提交不合规”这些可读信息。退出码方面,0 表示接受全部更新;任何一个 ref 校验失败,整个 push 都会被拒绝,GitLab 不会部分接受部分拒绝。
3. 核心代码拆解:从 stdin 解析到 commit message 校验
3.1 入口:解析 ref 更新请求
main.go的入口部分负责读取 stdin 并把每行拆成结构体:
type refUpdate struct { oldRev string // 旧的 commit hash,新建分支时为全 0 newRev string // 新的 commit hash,删除分支时为全 0 ref string // 分支名或标签名,如 refs/heads/main } func main() { scanner := bufio.NewScanner(os.Stdin) var updates []refUpdate for scanner.Scan() { line := scanner.Text() if line == "" { continue } parts := strings.Fields(line) if len(parts) != 3 { continue } updates = append(updates, refUpdate{ oldRev: parts[0], newRev: parts[1], ref: parts[2], }) } exitCode := 0 for _, u := range updates { bad, err := checkUpdate(u) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) exitCode = 1 continue } if len(bad) > 0 { for _, b := range bad { fmt.Fprintln(os.Stderr, b) } exitCode = 1 } } os.Exit(exitCode) }这段代码里有两个关键点。第一是用bufio.Scanner按行读 stdin,不是一次性ReadAll,因为一次大推送可能有几百个 ref 更新,逐行处理更稳。第二是strings.Fields按空白切分,兼容 tab 和多个空格。checkUpdate返回的字符串数组保存所有错误信息,最后统一输出——这样开发者一次能看到所有不合规的提交,而不是改一个推一次。
3.2 commit 扫描:git rev-list 的边界处理
拿到 ref 更新后,下一步是找出本次推送新增的 commit。这里有两个边界:新建分支时oldRev是全零,删除分支时newRev是全零。删除分支直接跳过不用检查,新建分支则不能用old..new的区间的写法。
func collectCommits(u refUpdate) ([]string, error) { zeroRev := strings.Repeat("0", 40) // 删除分支:newRev 为全零,无需检查任何提交 if u.newRev == zeroRev { return nil, nil } args := []string{"rev-list", "--no-merges"} if u.oldRev == zeroRev { // 新建分支:没有旧 commit 可对比,限制扫描数量防止仓库太大卡死 args = append(args, "--max-count=100", u.newRev) } else { args = append(args, u.oldRev+".."+u.newRev) } out, err := exec.Command("git", args...).Output() if err != nil { return nil, fmt.Errorf("git rev-list failed: %w", err) } var hashes []string for _, line := range strings.Split(string(out), "\n") { if hash := strings.TrimSpace(line); hash != "" { hashes = append(hashes, hash) } } return hashes, nil }这里比较容易被忽略的是--max-count=100。新建分支时如果仓库历史很长(比如从主干拉了个分支推回来),没有这个限制,钩子会把整个分支的所有提交全部检查一遍,轻则耗时长,重则直接超时。加一个上限,只检查最近 100 个提交,对绝大多数场景足够。另外这里用的是exec.Command("git", args...)而不是拼接命令字符串,从根本上避免了 commit hash 注入 shell 的问题。
3.3 规则引擎:正则表达式与错误收集
拿到 commit hash 列表后,对每个 hash 执行git log -1 --format=%s取提交标题,然后交给规则引擎判断。规则引擎的核心在rule.go:
var commitPattern = regexp.MustCompile( `^(feat|fix|docs|style|refactor|perf|test|chore)(\([a-z0-9-]+\))?: .+`, ) func checkCommitMessage(msg string) []string { var errs []string if commitPattern.MatchString(msg) { return errs } errs = append(errs, fmt.Sprintf( "commit message 不符合规范,请使用 {type}({scope}): {subject} 格式,例如 fix(user): 修复登录超时", )) return errs } func checkUpdate(u refUpdate) ([]string, error) { hashes, err := collectCommits(u) if err != nil { return nil, err } var errs []string for _, hash := range hashes { out, err := exec.Command("git", "log", "-1", "--format=%s", hash).Output() if err != nil { return nil, fmt.Errorf("git log failed for %s: %w", hash, err) } msg := strings.TrimSpace(string(out)) bad := checkCommitMessage(msg) if len(bad) > 0 { errs = append(errs, fmt.Sprintf("%s: %s", hash[:8], bad[0])) } } return errs, nil }正则表达式拆开看:feat|fix|docs|style|refactor|perf|test|chore是 type 枚举;(\([a-z0-9-]+\))?是可选的 scope,用括号包住模块名;:是分隔符,注意后面有个空格;.+要求 subject 非空。这个模式是服务端校验的底线:没有 type 的提交(比如 “update”)在第一轮就被拦截,scope 不合法或者空 subject 同样过不去。
4. 部署运行与避坑清单:从 custom_hooks 到真正生效
4.1 GitLab 端配置与部署步骤
GitLab 的 custom hooks 分全局和项目级两种。全局钩子放在GITLAB_CUSTOM_HOOKS_DIR环境变量指向的目录下,对所有仓库生效;项目级钩子放在具体仓库目录下的custom_hooks目录里。最常见的做法是项目级,路径一般在 GitLab 数据目录下,格式为repositories/<group>/<project>.git/custom_hooks/pre-receive。
部署脚本deploy/install.sh做的事很简单:
#!/usr/bin/env bash set -e # 交叉编译 Linux 二进制,防止本机是 macOS/Windows GOOS=linux GOARCH=amd64 go build -o pre-receive ./main.go HOOK_DIR="/path/to/your-project.git/custom_hooks" mkdir -p "$HOOK_DIR" # install 命令直接设置 0755 权限,避免 chmod 遗漏 install -m 0755 pre-receive "$HOOK_DIR/pre-receive" echo "installed to $HOOK_DIR/pre-receive"install -m 0755比cp加chmod更保险,一步到位把文件权限设为可执行。部署完不用重启 GitLab,但部分版本对 hooks 有缓存,如果发现没生效,等一两分钟或查一下 GitLab 的日志确认加载路径。也可以用软链方式,把编译产物放在统一目录,软链到各仓库的 custom_hooks 下,这样升级时只替换一个文件。
4.2 本地测试:模拟 pre-receive 的输入
GitLab 的钩子环境很难远程调试,所以我习惯先在本地完全模拟一遍输入。做法是建一个临时 Git 仓库,制造一个不合规提交和一个合规提交,然后把它们的 hash 拼接成 stdin 喂给编译好的二进制:
cd /tmp/test-repo git init -b main . git commit --allow-empty -m "bad message" BAD_REV=$(git rev-parse HEAD) git commit --allow-empty -m "fix(user): 修复登录超时" GOOD_REV=$(git rev-parse HEAD) ZERO_REV=$(printf '0%.0s' {1..40}) # 模拟新建分支推送,检查 GOOD_REV 的所有祖先提交 printf "$ZERO_REV $GOOD_REV refs/heads/test\n" | /tmp/pre-receive echo "exit code: $?"执行后如果看到bad message对应的提交被列出,并且退出码非 0,说明校验逻辑生效。再把ZERO_REV换成BAD_REV,模拟增量推送,确认只有新的提交被检查、旧的提交不会误伤。这套本地测试脚本我会保留在项目里,每次改完规则先跑一遍再部署。
4.3 避坑记录:五个真实的坑
现象一:钩子文件放好了,权限也对了,但是推送完全不触发任何检查,服务器日志里也没有执行痕迹。原因:GitLab 的 custom_hooks 目录路径配错了,管理员版本上配置的是全局GITLAB_CUSTOM_HOOKS_DIR,而项目有自己的custom_hooks,两边不统一。解决:先确认项目仓库的绝对路径,用git config --get core.hooksPath看有没有被单独指定;没有的话把钩子放到<project.git>/custom_hooks/pre-receive,同时确认 GitLab 的gitlab.yml里没有覆盖全局配置。
现象二:钩子报了错误,但开发者客户端只看到remote: hook declined一行,具体原因全被吞了。原因:错误信息写到了标准输出而不是标准错误输出,GitLab 只回显 stderr。解决:排查代码里所有fmt.Println,统一改成fmt.Fprintln(os.Stderr, ...)。这个坑在本地测试时完全看不出来,因为终端会把两个流混在一起显示。
现象三:新建分支时大量历史提交被误报,开发者推一个新分支被拦了一堆旧 commit。原因:oldRev为全零时用了"0000...0000..newRev"这种区间,等于从仓库第一个提交开始全部检查。解决:判断oldRev全零时改用git rev-list --max-count=100 <newRev>,限制扫描范围,正如 3.2 节代码所示。
现象四:钩子里执行git命令报command not found。原因:GitLab 服务器上 Git 的安装路径不在 hook 执行环境的 PATH 里,尤其是用源码包部署或容器化部署时。解决:部署前先which git确认绝对路径,然后在代码里显式使用绝对路径调用,或者用os.Setenv("PATH", "/usr/bin:/usr/local/bin:"+os.Getenv("PATH"))兜底。
现象五:merge commit 被误伤。原因:GitLab 自动生成的 merge commit message 是Merge branch 'xxx' into 'yyy',不符合常规规则。解决:在collectCommits里加--no-merges跳过合并提交,或者单独定义一条 merge 规则允许这种格式。前者更省事,团队没有 review merge commit message 的需求时推荐直接跳过。
5. 二次开发:把这个钩子改造成团队的提交规范引擎
基础的 type/scope 校验只是第一层。我用过这个结构之后,最常做的扩展是把它改造成团队专属的规范引擎。
第一个改造点是把正则规则抽成配置文件,用 JSON 或 YAML 存规则集合,这样不懂 Go 的同事也能改规范。比如定义rules.json,里面放多条规则,每条规则包含名称、正则表达式、错误提示。启动时加载文件,校验时逐条匹配,新增规则不用重新编译。这里要注意正则的 OR 优先级,多条规则之间用逻辑与还是逻辑或要在文档里写清楚,否则会出现“改了一条规则,另一条规则被绕过去”的尴尬。
第二个改造点是增加 body 校验。现在很多团队用 Conventional Commits 全面体,不仅要检查 subject,还要检查 message body 里是否有BREAKING CHANGE标记或RevId。做法是对每个 commit 执行git log -1 --format=%b取 body 文本,再按规则检查必填字段。这个改造对 SaaS 化团队尤其有用,因为外部客户提交代码时经常漏掉变更说明。
第三个改造点是接入外部系统。运行时通过http.Post调用内部的审计接口,把不合规的提交信息、开发者、分支名推送到团队 IM 群,让规范执行有反馈闭环。这个环节要注意超时设置——钩子执行时间是 GitLab 的硬限制,默认不能拖太久,HTTP 调用必须设 3 秒超时并且失败后不阻断主流程,否则钩子本身会成为推送的瓶颈。
从那以后,我每次改钩子规则都会先在测试仓库上推一版故意写错的提交,确认拦截生效、再确认历史提交不被误伤,然后才让同事重新 push。校验类钩子最怕的不是规则的严格程度,而是规则上线时误伤存量分支。希望帮到你。
本文还有配套的精品资源,点击获取