前言

前段时间,部门的前端项目迁移到 monorepo 架构,笔者在其中负责跟 git 工作流相关的事情,其中就包括 git hooks 相关的工程化的实践。用到了一些常用的相关工具如 husky、lint-staged、commitizen、commit-lint 等,以此文记录一下整个的实践过程和踩过的坑。

注意:下文中的例子以及命令都是基于 Mac OS,如果你是 windows 用户,也不用担心,文中也会阐述大致原理和运行逻辑,对应的 windows 命令可以推理得知。

Git Hooks

Git Hooks 是什么

大多数同学应该都对 git hooks 相当了解,但是笔者还是想在这里详细解释一下。
首先是 hook,这其实是计算机领域中一个很常见的概念,hook 翻译过来的意思是钩子或者勾住,而在计算机领域中则要分为两种解释:

  1. 拦截消息,在消息到达目标前,提前对消息进行处理
  2. 对特定的事件进行监听,当某个事件或动作被触发时也会同时触发对应的 hook
    也就是说 hook 本身也是一段程序,只是它会在特定的时机被触发。

理解了 hook 这一概念,那么 git hooks 也就不难理解了。git hooks 就是在运行某些 git 命令时,被触发的对应的程序。

在前端领域,钩子的概念也并不少见,比如 Vue 声明周期钩子、React Hooks、webpack 钩子等,说到底它们都是在特定的时机触发的方法或者函数

常见的 Git Hooks 有哪些

git hooks 分为两类

客户端 hook

服务端 hook

这里只列举了一部分,更多的 git hooks 详细信息见官方文档

在本地 git 仓库中的 .git/hooks 文件夹中也可以看到常用的 git hooks 示例

前端 Git-Hooks 工程化实践

从图中可以看到,默认的 git hooks 都是 shell 脚本,只需要将 git hooks 的示例文件的 .sample 扩展名去掉,那么示例文件即可生效。
一般来说,在前端工程中应用 git hooks 都是运行 javaScript 脚本,就像这样

#!/bin/sh
node your/path/to/script/xxx.js

或者是这样

#!/usr/bin/env node
// javascript code ...

原生的 Git Hooks 的缺陷

原生的 git hooks 有一个比较大的问题是 .git 文件夹下的内容不会被 Git 追踪。这就表示,无法保证让一个仓库中所有的成员都使用同样的 git hooks,除非仓库的所有成员都手动同步同一份 git hooks,但这显然不是个好办法。

Husky

Husky 的使用

  1. 安装 husky
pnpm install husky --save-dev
  1. husky 初始化
npx husky install
  1. 设置 package.json 的 prepare。来保证 husky 可以正常运行
npm set-script prepare "husky install"
  1. 添加 git hooks
npx husky add .husky/${hook_name} ${command}

husky install 命令做了什么

事实上,husky install 命令是解决 git hooks 问题的关键

更多关于 husky 的配置、命令相关文档,看这这里

值得注意的是 core.hooksPath 是 Git v2.9 推出的新特性,而 Husky 也是在 v6 版本开始使用 core.hooksPath 这个特性。在这之前的版本,Husky 会直接覆盖 .git/hooks 文件夹下所有的 hook,来使通过 Husky 配置的 hooks 生效。另外,在配置了 core.hooksPath 后 Git 会忽略 .git/hooks 文件夹下的 git hooks

husky add 命令做了什么

当运行如下命令

npx husky add .husky/pre-commit npx eslint

.husky 目录下会新增一个 pre-commit 文件,文件内容为

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx eslint

此时已经成功添加了一个 pre-commit git hook,这个脚本会在运行 git commit 命令时执行。
在脚本的第二行,引用了上面所说的 .husky.sh 文件,也就是说通过 husky 创建的 git hook 在被触发时,都会执行这个脚本。

梳理一下,husky 是如何解决原生的 git hooks 的问题的,首先前面已经提到了原生 git hooks 主要的问题是 git 无法跟踪 .git/hooks 下的文件,但是这个问题已经被 git core.hooksPath 解决了,那么新的问题就是,开发者仍然需要手动设置 git core.hooksPath。 husky 在 install 命令中帮助我们设置了 git core.hooksPath,然后在 package.json 的 scripts 中添加 "prepare": "husky install",这样每次安装依赖的时候就会执行 husky install,因此就可以保证设置的 git hooks 可以被触发了。

常用的 git 相关工具库

lint-staged

在 pre-commit hook 中,一般来说都是对当前要 commit 的文件进行校验、格式化等,因此在脚本中我们需要知道当前在 Git 暂存区的文件有哪些,而 Git 本身也没有向 pre-commit 脚本传递相关参数,lint-staged 这个包为我们解决了这个问题,lint-staged 的文档中第一句这样说道:

Run linters against staged git files and don't let ? slip into your code base!

lint-staged 的使用

  1. 安装 lint-staged
pnpm install lint-staged --save-dev
  1. 配置 lint-staged
    一般情况下,建议 lint-staged 搭配着 Husky 一起使用,当然这不是必须的,只需要保证 lint-staged 会在 pre-commit hook 中被运行就可以了。在搭配 Husky 使用的情况下,可以运行下面的命令,在 pre-commit hook 中运行 lint-staged
npx husky add .husky/pre-commit "npx lint-staged"

关于 lint-staged 的配置,在形式上与常见的工具包的配置方式大同小异,可以通过在 package.json 中添加一个 lint-staged 项、也可以在根目录添加一个 .lintstagedrc.json 文件等,下面以在 package.json 中配置为例:
配置项中的 key 为 glob 模式匹配语句,值为要运行的命令(可以配置多个),例如想要为暂存区中 src 文件夹下所有的 .ts 和 .tsx 文件运行 eslint 检查以及 ts 类型检查,那么配置如下:
详细的配置文档看这这里
如果 git hooks 脚本运行失败(进程结束时返回的状态码不为 0),那么会终止后续操作。比如上例中 eslint 检查报错,那么会直接终止 commit,git commit 命令失败。

lint-staged 是如何知道当前暂存区有哪些文件的

事实上,lint-staged 内部也没有什么黑魔法,它在内部运行了 git diff --staged --diff-filter=ACMR --name-only -z 命令,这个命令会返回暂存区的文件信息,类似如下所示的代码:

const { execSync } = require('child_process');
const lines = execSync('git diff --staged --diff-filter=ACMR --name-only -z')
    .toString()
const stagedFiles = lines
    .replace(/\u0000$/, '')
    .split('\u0000')

commitizen

在使用 Git 过程中,不可避免的需要填写 commit message,这其实是一件相当令人头疼的事情。如果没有良好的 commit message 规范,那么在查看历史 commit 的时候只会一脸懵*。
commitizen 可以协助开发者填写 commit 信息

commitizen 的使用

  1. 安装 commitizen
pnpm install commitizen -D
  1. 初始化 commitizen
npx commitizen init cz-conventional-changelog --save-dev --save-exact --pnpm

commitizen init 做了什么

  1. 安装 cz-conventional-changelog 适配器 npm 模块
  2. 将其保存到 package.json 的 devDependencies 中
  3. config.commitizen 配置添加到 package.json 中 如下所示:
"config": {
  "commitizen": {
    "path": "./node_modules/cz-conventional-changelog"
  }
}

commitizen 本身只提供命令行交互框架以及一些 git 命令的执行,实际的规则则需要通过适配器来定义,commitizen 留有对应的适配器接口。而 cz-conventional-changelog 就是一个 commitizen 适配器。

此时运行 npx cz 命令 就会出现以下命令行交互页面:

前端 Git-Hooks 工程化实践

这个适配器生成的 commit message 模板如下

<type>(<scope>): <subject>
<空行>
<body>
<空行>
<footer>

这也是最常见的提交约定,当然也可以安装其他适配器,或者自定义适配器来定制自己想要的 commit message 模板。
当运行 npx cz, commitizen 在通过适配器模板以及用户的输入拿到最终的 commit message 后,会在内部运行 git commit -m "XXX" 命令,到此为止,就完成了一次 git commit 操作

更多关于 commitizen 的详细等信息可以看 github 和 cz-git

自定义 commitizen 适配器

如果你想自定义适配器,那么可以选择使用 cz-customizable 这个工具包。
在没有这个工具包的情况下,如果想要自定义一个 commitizen 适配器,那么你还需要掌握 inquirer 的 API,commitizen 只会为适配器传递一个 inquirer 对象,适配器的规则需要通过这个 inquirer 对象来创建规则,这是在不太易用,而 cz-customizable 可以让我我们只专注于规则而不用去考虑 inquirer 的 API。

cz-customizable 的使用

  1. commitizen 配置
"config": {
  "commitizen": {
    "path": "./node_modules/cz-customizable"
  }
}
  1. cz-customizable 配置,在根目录新增一个 .cz-config.js 文件,配置示例如下
module.exports = {
  types: [
    { value: 'feat', name: 'feat: A new feature' },
    { value: 'fix', name: 'fix: A bug fix' },
  ],
  scopes: [{ name: 'accounts' }, { name: 'admin' }],
  allowTicketNumber: false,
  messages: {
    type: "Select the type of change that you're committing:",
    scope: '\nDenote the SCOPE of this change (optional):',
    customScope: 'Denote the SCOPE of this change:',
  },
  subjectLimit: 100,
};

这里是关于cz-customizable更详细的 示例 和 配置

使用 git cz 命令运行 commitizen

在全局 PATH 配置正确的情况下,也可以直接使用 git cz 命令去运行 commitizen。如果你在项目中安装了 commitizen, 那么在你的项目下的 node_modules/.bin 目录下将会看到两个脚本: czgit-cz , 如下图所示:

前端 Git-Hooks 工程化实践

这两个脚本的内容是一模一样的,官方的文档中会推荐在 package.json 的 scripts 中添加如下内容:

commit: "cz"

这样就可以使用 npm run commit 来运行 commitizen 了。但是如果想要使用 git cz 命令运行 commitizen,那么则要求 git-cz 文件所在的目录在全局的 PATH 下,运行以下命令来查看 PATH

echo $PATH

PATH 以冒号分隔,检查一下所有的 PATH 中是否有一条能匹配到你的 cz 脚本,一般来说都是有的,如果没有,那么你可以在你的 ~/.zshrc 或者 ~/.bash_profile 中加上一条:

PATH=$PATH:./node_modules/.bin

然后重新加载一下配置文件,运行 source ~/.zshrc 或者 source ~/.bash_profile,这样在你项目根目录下 就可以直接使用 git cz 命令了。
如果你是用 npm 全局安装的 commitizen,那么你大概率不需要担心 PATH 的问题,因为 npm 的依赖安装路径下的 bin 文件夹会被 node 或者 NVM 自动加入到 PATH 中。

回到刚刚所说的 node_modules/.bin 文件夹 下的 git-cz 脚本,实际上它是 git cz 命令可以运行的关键。不知道你是否疑惑,为什么使用 Git 可以去运行一个 npm 库,实际上,这是 git 自定义命令。想要添加一个 git 自定义命令有如下几个要求:

  1. 是一个可执行文件
  2. 文件名必须是 git-XXX
  3. 这个文件所在路径必须在你的 PATH 下

所以前文中,提到想要运行 git cz 命令,需要全局 PATH 配置正确。

_你也可以根据上述要求尝试添加别的自定义 git 命令。需要注意的是,要检查一下你添加的 shell 脚本是否具有可执行权限,若没有可执行权限会导致如下报错 _git: 'your command' is not a git command_, 此时可以运行 __chmod a+x <path to your file>_来修改文件的权限使其可运行即可。

commitlint

commitlint 这个工具库,可以通过配置一些规则来校验 commit message 是否规范。
那么我们已经有了 commitizen 为什么还需要 commitlint 呢?上文中说到,commitizen 的作用是协助开发者填写 commit message,虽然可以通过选择不同的适配器或者自定义适配器来制定对应的 commit 信息规范以及模板,但是缺少了对 commit message 的校验功能,开发者仍然可能在无意中使用原生的 git commit 命令来提交,而 commitlint 在 commit-msg 这个 git hook 中对 commit message 进行校验,正好解决了这个问题。

commitlint 的使用

  1. 安装
pnpm install --save-dev @commitlint/config-conventional @commitlint/cli
  1. 使用 husky 添加 commit-msg hook
npx husky add .husky/commit-msg "npx --no -- commitlint --edit $1
  1. commitlint 配置
    在项目根目录增加一个 commitlint.config.js 文件,文件内容如下:
module.exports = {
    extends: ['@commitlint/config-conventional'],
    // 自定义部分规则
    rules: {
        'scope-case': [0, 'always', 'camel-case'],
        'scope-empty': [2, 'never'],
        'scope-enum': [2, 'always', [...]],
    },
};

commitlint 与 commitizen 一样,分为两部分,一部分是执行的主程序,另一部分是规则或者说是适配器。 @commitlint/cli 是执行的主程序,@commitlint/config-conventional 则是规则。commitlint 和 commitizen 分别采用了策略模式和适配器模式,因此都拥有非常高的可用性和良好的扩展性。
在 commitlint 的配置文件中,可以先引用一个 commitlint 规则包,然后在定义部分自己想要的规则,就像 eslint 的配置一样。
需要注意的是,在将 commitlint 添加到 commit-msg hooks 中时,执行 commitlint 的 shell 命令中 --edit $1 参数是必须的,这个参数的意思是:存储 commit message 的临时文件路径是 $1, 而$1 则是 Git 传给 commit-msg hook 的参数,它的值是 commit message 的临时存储文件的路径,默认情况下是 .git/COMMIT_EDITMSG。如果不传这个参数,那么 commitlint 将无法得知当前的 commit message 是什么。

更多 commitlint 的相关详情看这里

commitlint 与 commitizen 的配置共用

前文中说到 commitlint 解决了 commitizen 没有对 commit message 做校验的问题,但是使用了 commitlint 后,新的问题出现了,如果 commitlint 的规则集与 commitizen 的适配器中的规则不一致,那么可能会导致使用 commitizen 生成的 commit message 被 commitlint 校验时不通过从而 git commit 失败。
解决这个问题的办法有两种:

  1. 将 commitizen 的适配器规则翻译为 commitlint 规则集,已有的对应工具包为 commitlint-config-cz,这个包需要你所使用的 commitizen 适配器为 cz-customizable,也就是自定义适配器。
  2. 将 commitlint 规则集转化为 commitizen 的适配器,已有对应的工具包为 @commitlint/cz-commitlint

这里以第二种选用 @commitlint/cz-commitlint 为例:

  1. 安装 @commitlint/cz-commitlint
pnpm install --save-dev @commitlint/cz-commitlint
  1. 修改 packages.json 中 commitizen 的配置
  "config": {
    "commitizen": {
      "path": "./node_modules/@commitlint/cz-commitlint"
    }
  }

conventional-changelog 生态

打开 commitlint 的 github 仓库,就会发现它在 conventional-changelog 这个 Organization 下,而 commitizen/cz-cli 这个仓库的 README.md 文件中也提到了 conventional-changelog 生态:

For this example, we'll be setting up our repo to use AngularJS's commit message convention, also known as conventional-changelog.

这也难怪为什么 commitlint 还专门提供了一个 @commitlint/cz-commitlint 包来配合 commitizen。

那么 conventional-changelog 生态还包含什么呢?

支持 Conventional Changelog 的插件

Conventional Changelog 生态中的重要模块

由于本文主要是讲 git hooks,这里关于 conventional-changelog 生态就不展开讲了,有兴趣的话可以自行去看一下他们的 github 仓库 和 这篇文章