目录

GO语言开发实践-pre-receive钩子校验提交信息

我们有时候会遇到开发提交的千奇百怪的 Commit 信息,这样给代码更新追踪溯源增加了麻烦,所以有必要为 GitLab 增加自定义 Commit 提交格式检测.

pre-receive 钩子

当我们需要对 Git 提交的相关内容做校验时,可以使用服务端的 pre-receive 钩子。

  • 在进行 push 操作时,GitLab 会调用这个钩子文件,并且从 stdin 输入三个参数(同时还内置了很多环境变量),分别为:之前的版本 commit IDpush 的版本 commit IDpush 的分支。

  • 根据 commit ID 我们就可以很轻松的获取到提交信息,从而实现进一步检测动作。

  • 根据 GitLab 的文档说明,当这个 hook 执行后以非 0 状态退出则认为执行失败,从而拒绝 push;同时会将 stderr 信息返回给 client 端。

  • 范围:可以针对单个项目,也可以作用于全局。

具体详细参数可以参阅官网:这里

实施步骤

开始前,我们必须跟业务侧约定好 commit 信息的规范,同时设计好钩子的作用范围,本文针对全局起作用,同时使用白名单功能,只有在名单内的项目组才启用校验功能。

前提条件

commit message 要规范:

格式:<类型>[可选的作用域]:# <描述>

实现原理

Gitlab server hooks 钩子会收到3个 stdin 参数,分别为:之前的版本 commit IDpush 的版本 commit IDpush 的分支。

程序拿到2个 commit ID 后,通过命令 "git log odlCommitID commitID --pretty=format:%s" 获取当前提交的 commit message,再进行正则匹配,正则通过则放行提交。所以上面的前提条件是必须要符合规范正则才能生效。

信息
  • 要创建适用于实例中所有存储库的 Git 挂钩,请设置全局服务器挂钩。

    全局服务器钩子目录:

    通常是 /opt/gitlab/embedded/service/gitlab-shell/hooks

  • hooks 目录若不存在则新建。

  • GitLab 服务器上,进入到的全局服务器钩子目录。

  • 在此位置创建一个新目录。取决于钩的类型,它可以是一个 pre-receive.dpost-receive.dupdate.d 目录。

  • 在这个新目录中,添加您的钩子文件,如上面的 pre-receive 文件。

  • 确保钩子文件可执行并且用户为 Git 启动用户的权限。

设置名单

在代码里,有白名单逻辑,在名单内的组才做验证。

名单文件:includePath = "/opt/gitlab/embedded/service/gitlab-shell/hooks/includes/includes.txt"

Gitlab 官方只给出了有限的环境变量去获取仓库的相关信息,详情查看下方的相关资料链接。

我的仓库地址是:‘http://gitlab.mytest.com:8080/ops/simplehttp.git',具体逻辑是,在执行钩子的时候,获取变量 GL_PROJECT_PATH ,为仓库名,格式 ops/simplehttp,获取组前2个元素,/ops/woven/mytest ,获取 /ops/wovenwoven 组下面的所有项目都生效。

编码

新建 pre-receive 目录,新建 main.go 文件,复制以下代码。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
package main

import (
    "bufio"
    "fmt"
    "io/ioutil"
    "log"
    "os"
    "os/exec"
    "regexp"
    "strings"
)

type CommitType string

const (
    // 是否开启严格模式,严格模式下将校验所有的提交信息格式(多 commit 下)
    strictMode = true
    CommitMessagePattern = `((feat|fix|to|docs|style|refactor|perf|test|chore|revert|merge|sync|build|ci)(\()(\s|\S){0,}(\)):(\s)*#(\s|\S)+(\s)(\s|\S)+)|^Merge\ (.*)`
    BranchPattern = `refs/heads/(master|release|develop|uat|qa|stress).*`
    // excludePath = "/opt/gitlab/embedded/service/gitlab-shell/hooks/excludes/excludes.txt"
    includePath = "/opt/gitlab/embedded/service/gitlab-shell/hooks/includes/includes.txt"
    checkFailedMeassge = `GL-HOOK-ERR:##############################################################################
GL-HOOK-ERR:##                                                                          
GL-HOOK-ERR:## Commit message style check failed!                                       
GL-HOOK-ERR:##                                                                          
GL-HOOK-ERR:## Commit message style must satisfy this regular:                          
GL-HOOK-ERR:##   类型(可选的作用域):#Jira_ID 描述                               
GL-HOOK-ERR:##                                                                          
GL-HOOK-ERR:## Example:                                                                 
GL-HOOK-ERR:##   fix(DAO):#JIRA-001 用户查询缺少username属性                            
GL-HOOK-ERR:##                                                                          
GL-HOOK-ERR:##############################################################################`)

var commitMsgReg = regexp.MustCompile(CommitMessagePattern)
var branchMsgReg = regexp.MustCompile(BranchPattern)

func main() {

    input, _ := ioutil.ReadAll(os.Stdin)
    param := strings.Fields(string(input))
    projectGroup := getProjectGroupPath()

    // 项目在名单内,返回true,!true 表示,在名单内的不放行,继续下面的验证
    ok := handleText(includePath, projectGroup)
    if !ok {
        os.Exit(0)
    }

    // 删除分支 commit ID
    if param[1] == "0000000000000000000000000000000000000000" {
        os.Exit(0)
    }

    // 校验分支
    {
        branchMsg := branchMsgReg.FindAllStringSubmatch(param[2], -1)
        if len(branchMsg) == 1 {
            os.Exit(0)
        } else {
           fmt.Println(" ")
        }
    }
    
    // 新分支第一次推送只校验最近一条commit message
    if param[0] == "0000000000000000000000000000000000000000" {
        commitMsg := firstCommitMsg(param[1])

        // firstCommitMsg拼接后,数组第二个值为空,会引起正则混乱
        tmpStr := commitMsg[0]
        
        commitTypes := commitMsgReg.FindAllStringSubmatch(tmpStr, -1)
        if len(commitTypes) != 1 {
            // 打印异常commit message
            commitMessagePrint(tmpStr)
            // 打印提示信息,提示哪些commitID有规范问题
            firstCommitIdPrint(param[1])
            // 退出并打印规范提示
            checkFailed()
        } else {
            fmt.Println(" ")
        }
        
    } else {
        commitMsg := getCommitMsg(param[0], param[1])
        for _, tmpStr := range commitMsg {
            commitTypes := commitMsgReg.FindAllStringSubmatch(tmpStr, -1)
            if len(commitTypes) != 1 {
                // 打印异常commit message
                commitMessagePrint(tmpStr)
                // 打印提示信息,提示哪些commitID有规范问题
                commitIdPrint(param[0], param[1])
                // 退出并打印规范提示
                checkFailed()
            } else {
                fmt.Println(" ")
            }
            if !strictMode {
                os.Exit(0)
            }
        }
    }
}

// 获取git commit 信息,去掉换行符并存入到slice切片
func getCommitMsg(odlCommitID, commitID string) []string {
    getCommitMsgCmd := exec.Command("git", "log", odlCommitID+".."+commitID, "--pretty=format:%s")
    getCommitMsgCmd.Stdin = os.Stdin
    getCommitMsgCmd.Stderr = os.Stderr
    b, err := getCommitMsgCmd.Output()
    if err != nil {
        fmt.Print(err)
        os.Exit(1)
    }

    commitMsg := strings.Split(string(b), "\n")
    return commitMsg
}

// 只获取最后一条commit 信息,跟上面的getCommitMsg的输出保持一致,存到slice切片
func firstCommitMsg(odlCommitID string) []string {
    getCommitMsgCmd := exec.Command("git", "show", odlCommitID, "--format=%s", "-s")
    getCommitMsgCmd.Stdin = os.Stdin
    getCommitMsgCmd.Stderr = os.Stderr
    b, err := getCommitMsgCmd.Output()
    if err != nil {
        fmt.Print(err)
        os.Exit(1)
    }

    commitMsg := strings.Split(string(b), "\n")
    return commitMsg
}

func checkFailed() {
    fmt.Fprintln(os.Stderr, checkFailedMeassge)
    os.Exit(1)
}

// 获取项目组名,暂时不用,先用下面的方式获取更小范围,后面再放开次函数
// func getProjectGroupPath() (projectGroup string) {
//     projectname := os.Getenv("GL_PROJECT_PATH")
//     // 获取第一个元素
//     projectGroup = strings.Split(projectname, "/")[0]
//     return
// }

// 获取组前2个元素,/ops/woven/mytest ,获取 /ops/woven
func getProjectGroupPath() (projectGroup string) {
    projectname := os.Getenv("GL_PROJECT_PATH")
    projectGroupList := strings.Split(projectname, "/")[0:2]
    projectGroup = strings.Join(projectGroupList, "/")
    return
}

// 名单处理
func handleText(textfile, projectGroup string) bool {
    file, err := os.Open(textfile)
    if err != nil {
        _, err := fmt.Printf("Cannot open text file: %s, err: [%v]", textfile, err)
        panic(err)
    }
    defer file.Close()

    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := scanner.Text()
        line = fmt.Sprintf("%s\n", line)
        // 去除空格,换行
        line = Strip(line)
        projectGroup = Strip(projectGroup)

        if line == projectGroup {
            return true
        }
    }

    if err := scanner.Err(); err != nil {
        log.Printf("Cannot scanner text file: %s, err: [%v]", textfile, err)
        return false
    }

    return false
}

// 打印ID信息
func firstCommitIdPrint(odlCommitID string) {
    fmt.Fprintln(os.Stderr, 
        "GL-HOOK-ERR:##" + " " + "详情查看 git show", odlCommitID + " " + "--pretty=format:%s")
}

// 打印ID信息
func commitIdPrint(odlCommitID, commitID string) {
    fmt.Fprintln(os.Stderr, 
        "GL-HOOK-ERR:##" + " " + "详情查看 git log", odlCommitID + ".." + commitID + " " + "--pretty=format:%s")
}

// 打印message信息
func commitMessagePrint(message string) {
    fmt.Fprintln(os.Stderr, "GL-HOOK-ERR:##" + " " + "错误的 commit message: " + message)
}

// 去除换行空格
func Strip(s string) (r string) {
	if s != " " {
		s = strings.Trim(s, " ")
		s = strings.Trim(s, "\t")
		s = strings.Trim(s, "\n")
		r = strings.Trim(s, "\r")
	}
	return
}

使用方法

假设你本地已经装好了 golang,然后设置 go module 模式

如果你是 windows 系统,需要交叉编译得先设置 go env -w GOOS=linux

1
2
3
4
5
6
7
8
cd pre-receive
go env -w CGO_ENABLED=0
go env -w GO111MODULE=on
go env -w GOARCH=amd64
go env -w GOOS=linux
go mod init pre-receive
go mod tidy
go build -ldflags="-w"

把编译后的文件丢进 /opt/gitlab/embedded/service/gitlab-shell/hooks/pre-receive.d 并给与可执行权限,即可。无需重启 Gitlab 服务。

注意
如果你的 Gitlab 是容器部署,那把上面的目录挂载出来,把二进制文件丢进去即可。