「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战」
前言
monorepo 仓库的提交记录一般很混乱,难以追踪,所以一般会在提交信息中加上scope,即修改的项目。但是肯定会有人不遵守规范,这时候就可以对提交信息进行检验。但是既然要检验了,为何不直接自动加上呢?
修改提交信息,就分为以下几步:
- 拦截 Git 提交,获取其提交信息
- 获取修改的文件,判断其处于哪个项目
- 修改提交信息,继续提交
Git Hooks
Git 本身就提供了这样一个“拦截”的功能——Git Hooks。它能在执行特定的动作时触发自定义脚本。这些 hooks 一般放在 .git/hooks 里面。我们这次要修改提交信息,就需要用到 commit-msg 这样一个 hooks。
实际上,Git 会把提交信息放在一个名为 COMMIT_MSG 的文件里面,在 commit-msg ,我们能获取到这个文件地址。
hooks 文件一般是用 shell 编写的,当然也可以设置成其他语言。在 shell 中,可以用 $1 获取到上述的文件地址。
# .git/hooks/commit-msg
msg=$(cat $1)
echo $msg
exit 1
上面的操作就把提交信息打印出来了。exit 1 用于中断提交,只要返回一个非零值即可中断提交,在调试时非常方便。
如果要写入的话,可以这样echo $msg > $1。
接下来获取提交的文件。其实就是获取暂存区(git add)的文件。通过下面这个命令就可以获取其路径了:
git diff --name-only --cached
接下来只要判断文件所在的目录,修改一下提交信息就可以了。
做完这些你就会发现一个问题,这个 .git 是在我本地啊,又不提交,其他人要用怎么办?这个时候husky就登场了。
husky
这个玩意的原理很简单。既然 .git 里面的东西不提交,把这些 hooks 放到项目里不就行了,然后在 .git 里面调用项目里的 hooks 脚本就不行了。
但就算这样,别人要用的时候,还是得去修改 .git 才行。husky 的解决方案是在 npm script 中加上这一句:
{
"script": {
"prepare": "husky install"
}
}
prepare 会在 npm install 前执行,husky 会在这一步去修改 .git 里面的内容。
然后增加一个 hooks:
npx husky add .husky/commit-msg
打开 .git/hooks/commit-msg,你会发现里面变成了这样
#!/bin/sh
# husky
# Created by Husky v4.3.8 (https://github.com/typicode/husky#readme)
# ...
. "$(dirname "$0")/husky.sh"
这个 hooks 去调用了一个叫 husky.sh 的脚本,这个脚本就会去调用真正的 hook 了。所以把上一步搞得东西挪到 .husky/commit-msg 里面,看看能不能正常运行。
然后就可以开始校验了。但是 shell 我不会啊!最终还是得用前端的方式解决问题——用 node 写。
用 Node 写脚本
这里有两种方式,第一种就是直接把这个文件的运行环境改成 node。我猜测,这里需要你的电脑上安装了 node 并且设置了环境变量。设置成 python,perl 等其他的脚本环境也可以。
#!/bin/env node
写了几行,发现确实能运行,但 $1 怎么拿。。。
然后就用上了第二种方法,在 shell 里面调用 node 脚本。在 node 里面获取 git 提交文件有点麻烦,所以在这里直接传进去了。
#!/bin/sh
commitFiles=$(git diff --name-only --cached)
node ./hooks/commit-msg.js $1 $commitFiles
然后在 node 里面就可以通过 process.argv 拿到了。在 node 里面,可以用 process.exit(1) 代替 exit 1,中断提交。
先获取一下提交信息:
const commitMsgPath = process.argv[2]
const gitMsgPath = path.resolve(__dirname, '../', commitMsgPath)
const commitMsg = getFileContent(gitMsgPath)
function getFileContent(filePath) {
try {
var buffer = fs.readFileSync(filePath)
var hasToString = buffer && typeof buffer.toString === 'function'
return hasToString && buffer.toString()
} catch (err) {
if (err && err.code !== 'ENOENT' && err.code !== 'ENAMETOOLONG') {
throw err
}
}
}
然后从提交的文件路径中获取项目名。因为一般 monorepo 的项目都是放在 packages 下的,所以直接获取 packages 的下一层目录,顺手去个重:
const commitFiles = process.argv.slice(3)
const changeRange = commitFilesFilter(commitFiles)
function commitFilesFilter(commitFiles) {
const reg = new RegExp('^.*?packages[\\\/](.*?)[\\\/].*?$')
const changeRange = commitFiles.map(file => {
if (!reg.test(file)) return ''
return file.replace(reg, '$1')
})
return [...new Set(changeRange)]
}
然后把这些项目名弄到提交信息里面。但别人也有可能写了作用域,而且要插哪个位置也是讲究。
我的策略是,先去掉提交信息内提及的项目名,剩下的作为要插入的内容。接下来是插入的位置,按照提交规范,那一般是放在 feat():,chore(): 这些东西的括号里面的。所以,首先,有(): 这种格式的话,就插到括号里面;第二个条件是如果有 feat: 这种格式的话,就插到冒号前,顺手加个括号;如果都没有,就放到开头。
实际上,这个策略是有漏洞的,比如在提交信息中间整个():。再牛逼的规范也约束不了不遵守规范的人。但也大多数情况适用了。
const lackPackages = changeRange.filter(v => v && !~commitMsg.indexOf(v))
if (lackPackages.length) {
let newCmtMsg = commitMsg
const scopeReg = new RegExp('[\((](.*?)[\))][::]')
const typeReg = new RegExp('(\w{0,10})[::]')
if (scopeReg.test(newCmtMsg)) {
newCmtMsg = newCmtMsg.replace(scopeReg, `($1,${lackPackages}):`)
} else if (typeReg.test(newCmtMsg)) {
newCmtMsg = newCmtMsg.replace(typeReg, `$1(${lackPackages}):`)
} else {
newCmtMsg = `(${lackPackages}):${newCmtMsg}`
}
fs.writeFileSync(gitMsgPath, newCmtMsg)
}
process.exit(0)
到这里就结束了,欢迎各位大佬提出建议及指出问题。