Git Hooks 修改提交信息

988 阅读4分钟

「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

前言

monorepo 仓库的提交记录一般很混乱,难以追踪,所以一般会在提交信息中加上scope,即修改的项目。但是肯定会有人不遵守规范,这时候就可以对提交信息进行检验。但是既然要检验了,为何不直接自动加上呢?

修改提交信息,就分为以下几步:

  1. 拦截 Git 提交,获取其提交信息
  2. 获取修改的文件,判断其处于哪个项目
  3. 修改提交信息,继续提交

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)

到这里就结束了,欢迎各位大佬提出建议及指出问题。