前言
前段時(shí)間,部門的前端項(xiàng)目遷移到 monorepo 架構(gòu),筆者在其中負(fù)責(zé)跟 git 工作流相關(guān)的事情,其中就包括 git hooks 相關(guān)的工程化的實(shí)踐。用到了一些常用的相關(guān)工具如 husky、lint-staged、commitizen、commit-lint 等,以此文記錄一下整個(gè)的實(shí)踐過(guò)程和踩過(guò)的坑。
注意:下文中的例子以及命令都是基于 Mac OS,如果你是 windows 用戶,也不用擔(dān)心,文中也會(huì)闡述大致原理和運(yùn)行邏輯,對(duì)應(yīng)的 windows 命令可以推理得知。
Git Hooks
Git Hooks 是什么
大多數(shù)同學(xué)應(yīng)該都對(duì) git hooks 相當(dāng)了解,但是筆者還是想在這里詳細(xì)解釋一下。
首先是 hook,這其實(shí)是計(jì)算機(jī)領(lǐng)域中一個(gè)很常見(jiàn)的概念,hook 翻譯過(guò)來(lái)的意思是鉤子或者勾住,而在計(jì)算機(jī)領(lǐng)域中則要分為兩種解釋:
- 攔截消息,在消息到達(dá)目標(biāo)前,提前對(duì)消息進(jìn)行處理
- 對(duì)特定的事件進(jìn)行監(jiān)聽(tīng),當(dāng)某個(gè)事件或動(dòng)作被觸發(fā)時(shí)也會(huì)同時(shí)觸發(fā)對(duì)應(yīng)的
hook
也就是說(shuō)hook本身也是一段程序,只是它會(huì)在特定的時(shí)機(jī)被觸發(fā)。
理解了 hook 這一概念,那么 git hooks 也就不難理解了。git hooks 就是在運(yùn)行某些 git 命令時(shí),被觸發(fā)的對(duì)應(yīng)的程序。
在前端領(lǐng)域,鉤子的概念也并不少見(jiàn),比如 Vue 聲明周期鉤子、React Hooks、webpack 鉤子等,說(shuō)到底它們都是在特定的時(shí)機(jī)觸發(fā)的方法或者函數(shù)
常見(jiàn)的 Git Hooks 有哪些
git hooks 分為兩類
客戶端 hook
-
pre-commithook, 在運(yùn)行git commit命令時(shí)且在 commit 完成前被觸發(fā) -
commit-msghook, 在編輯完 commit-msg 時(shí)被觸發(fā),并且接受一個(gè)參數(shù),這個(gè)參數(shù)是存放當(dāng)前 commit-msg 的臨時(shí)文件的路徑 -
pre-pushhook, 在運(yùn)行git push命令時(shí)且在 push 命令完成前被觸發(fā)
服務(wù)端 hook
-
pre-receive在服務(wù)端接受到推送時(shí)且在推送過(guò)程完成前被觸發(fā) -
post-receive在服務(wù)端接收到推送且推送完成后被觸發(fā)
這里只列舉了一部分,更多的 git hooks 詳細(xì)信息見(jiàn)官方文檔
在本地 git 倉(cāng)庫(kù)中的 .git/hooks 文件夾中也可以看到常用的 git hooks 示例

從圖中可以看到,默認(rèn)的 git hooks 都是 shell 腳本,只需要將 git hooks 的示例文件的 .sample 擴(kuò)展名去掉,那么示例文件即可生效。
一般來(lái)說(shuō),在前端工程中應(yīng)用 git hooks 都是運(yùn)行 javaScript 腳本,就像這樣
#!/bin/sh
node your/path/to/script/xxx.js
或者是這樣
#!/usr/bin/env node
// javascript code ...
原生的 Git Hooks 的缺陷
原生的 git hooks 有一個(gè)比較大的問(wèn)題是 .git 文件夾下的內(nèi)容不會(huì)被 Git 追蹤。這就表示,無(wú)法保證讓一個(gè)倉(cāng)庫(kù)中所有的成員都使用同樣的 git hooks,除非倉(cāng)庫(kù)的所有成員都手動(dòng)同步同一份 git hooks,但這顯然不是個(gè)好辦法。
Husky
Husky 的使用
- 安裝 husky
pnpm install husky --save-dev
- husky 初始化
npx husky install
- 設(shè)置 package.json 的 prepare。來(lái)保證 husky 可以正常運(yùn)行
npm set-script prepare "husky install"
- 添加 git hooks
npx husky add .husky/${hook_name} ${command}
husky install 命令做了什么
事實(shí)上,husky install 命令是解決 git hooks 問(wèn)題的關(guān)鍵
- 第一步: husky install 會(huì)在項(xiàng)目根目錄下創(chuàng)建
.husky以及.husky/_文件夾(文件夾也可以自定義),然后在.husky/_文件夾下創(chuàng)建husky.sh腳本文件。 這個(gè)文件的作用就是保證通過(guò) husky 創(chuàng)建的腳本能夠正常運(yùn)行,它的實(shí)際應(yīng)用的地方后面會(huì)講到。更多關(guān)于這個(gè)腳本的討論可以看這里 github issue。 - 第二步: husky install 會(huì)運(yùn)行
git config core.hooksPath ${path/to/hooks_dir},這個(gè)命令用來(lái)指定 git hooks 的路徑,此時(shí)觀察項(xiàng)目下.git/config文件, [core] 下面會(huì)多出一條配置:hooksPath = xxx。當(dāng) git hooks 被某些命令觸發(fā)時(shí),Git 會(huì)運(yùn)行core.hooksPath指定的文件夾下的 git hook。
更多關(guān)于 husky 的配置、命令相關(guān)文檔,看這這里
值得注意的是 core.hooksPath 是 Git v2.9 推出的新特性,而 Husky 也是在 v6 版本開(kāi)始使用 core.hooksPath 這個(gè)特性。在這之前的版本,Husky 會(huì)直接覆蓋 .git/hooks 文件夾下所有的 hook,來(lái)使通過(guò) Husky 配置的 hooks 生效。另外,在配置了 core.hooksPath 后 Git 會(huì)忽略 .git/hooks 文件夾下的 git hooks
husky add 命令做了什么
當(dāng)運(yùn)行如下命令
npx husky add .husky/pre-commit npx eslint
.husky 目錄下會(huì)新增一個(gè) pre-commit 文件,文件內(nèi)容為
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx eslint
此時(shí)已經(jīng)成功添加了一個(gè) pre-commit git hook,這個(gè)腳本會(huì)在運(yùn)行 git commit 命令時(shí)執(zhí)行。
在腳本的第二行,引用了上面所說(shuō)的 .husky.sh 文件,也就是說(shuō)通過(guò) husky 創(chuàng)建的 git hook 在被觸發(fā)時(shí),都會(huì)執(zhí)行這個(gè)腳本。
梳理一下,husky 是如何解決原生的 git hooks 的問(wèn)題的,首先前面已經(jīng)提到了原生 git hooks 主要的問(wèn)題是 git 無(wú)法跟蹤 .git/hooks 下的文件,但是這個(gè)問(wèn)題已經(jīng)被 git core.hooksPath 解決了,那么新的問(wèn)題就是,開(kāi)發(fā)者仍然需要手動(dòng)設(shè)置 git core.hooksPath。 husky 在 install 命令中幫助我們?cè)O(shè)置了 git core.hooksPath,然后在 package.json 的 scripts 中添加 "prepare": "husky install",這樣每次安裝依賴的時(shí)候就會(huì)執(zhí)行 husky install,因此就可以保證設(shè)置的 git hooks 可以被觸發(fā)了。
常用的 git 相關(guān)工具庫(kù)
lint-staged
在 pre-commit hook 中,一般來(lái)說(shuō)都是對(duì)當(dāng)前要 commit 的文件進(jìn)行校驗(yàn)、格式化等,因此在腳本中我們需要知道當(dāng)前在 Git 暫存區(qū)的文件有哪些,而 Git 本身也沒(méi)有向 pre-commit 腳本傳遞相關(guān)參數(shù),lint-staged 這個(gè)包為我們解決了這個(gè)問(wèn)題,lint-staged 的文檔中第一句這樣說(shuō)道:
Run linters against staged git files and don't let ?? slip into your code base!
lint-staged 的使用
- 安裝 lint-staged
pnpm install lint-staged --save-dev
- 配置 lint-staged
一般情況下,建議lint-staged搭配著Husky一起使用,當(dāng)然這不是必須的,只需要保證lint-staged會(huì)在 pre-commit hook 中被運(yùn)行就可以了。在搭配 Husky 使用的情況下,可以運(yùn)行下面的命令,在 pre-commit hook 中運(yùn)行lint-staged
npx husky add .husky/pre-commit "npx lint-staged"
關(guān)于 lint-staged 的配置,在形式上與常見(jiàn)的工具包的配置方式大同小異,可以通過(guò)在 package.json 中添加一個(gè) lint-staged 項(xiàng)、也可以在根目錄添加一個(gè) .lintstagedrc.json 文件等,下面以在 package.json 中配置為例:
配置項(xiàng)中的 key 為 glob 模式匹配語(yǔ)句,值為要運(yùn)行的命令(可以配置多個(gè)),例如想要為暫存區(qū)中 src 文件夾下所有的 .ts 和 .tsx 文件運(yùn)行 eslint 檢查以及 ts 類型檢查,那么配置如下:
詳細(xì)的配置文檔看這這里
如果 git hooks 腳本運(yùn)行失?。ㄟM(jìn)程結(jié)束時(shí)返回的狀態(tài)碼不為 0),那么會(huì)終止后續(xù)操作。比如上例中 eslint 檢查報(bào)錯(cuò),那么會(huì)直接終止 commit,git commit 命令失敗。
lint-staged 是如何知道當(dāng)前暫存區(qū)有哪些文件的
事實(shí)上,lint-staged 內(nèi)部也沒(méi)有什么黑魔法,它在內(nèi)部運(yùn)行了 git diff --staged --diff-filter=ACMR --name-only -z 命令,這個(gè)命令會(huì)返回暫存區(qū)的文件信息,類似如下所示的代碼:
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 過(guò)程中,不可避免的需要填寫 commit message,這其實(shí)是一件相當(dāng)令人頭疼的事情。如果沒(méi)有良好的 commit message 規(guī)范,那么在查看歷史 commit 的時(shí)候只會(huì)一臉懵*。
而 commitizen 可以協(xié)助開(kāi)發(fā)者填寫 commit 信息
commitizen 的使用
- 安裝 commitizen
pnpm install commitizen -D
- 初始化 commitizen
npx commitizen init cz-conventional-changelog --save-dev --save-exact --pnpm
commitizen init 做了什么
- 安裝
cz-conventional-changelog適配器 npm 模塊 - 將其保存到 package.json 的 devDependencies 中
- config.commitizen 配置添加到 package.json 中 如下所示:
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
commitizen 本身只提供命令行交互框架以及一些 git 命令的執(zhí)行,實(shí)際的規(guī)則則需要通過(guò)適配器來(lái)定義,commitizen 留有對(duì)應(yīng)的適配器接口。而 cz-conventional-changelog 就是一個(gè) commitizen 適配器。
此時(shí)運(yùn)行 npx cz 命令 就會(huì)出現(xiàn)以下命令行交互頁(yè)面:

這個(gè)適配器生成的 commit message 模板如下
<type>(<scope>): <subject>
<空行>
<body>
<空行>
<footer>
這也是最常見(jiàn)的提交約定,當(dāng)然也可以安裝其他適配器,或者自定義適配器來(lái)定制自己想要的 commit message 模板。
當(dāng)運(yùn)行 npx cz, commitizen 在通過(guò)適配器模板以及用戶的輸入拿到最終的 commit message 后,會(huì)在內(nèi)部運(yùn)行 git commit -m "XXX" 命令,到此為止,就完成了一次 git commit 操作
更多關(guān)于 commitizen 的詳細(xì)等信息可以看 github 和 cz-git
自定義 commitizen 適配器
如果你想自定義適配器,那么可以選擇使用 cz-customizable 這個(gè)工具包。
在沒(méi)有這個(gè)工具包的情況下,如果想要自定義一個(gè) commitizen 適配器,那么你還需要掌握 inquirer 的 API,commitizen 只會(huì)為適配器傳遞一個(gè) inquirer 對(duì)象,適配器的規(guī)則需要通過(guò)這個(gè) inquirer 對(duì)象來(lái)創(chuàng)建規(guī)則,這是在不太易用,而 cz-customizable 可以讓我我們只專注于規(guī)則而不用去考慮 inquirer 的 API。
cz-customizable 的使用
- commitizen 配置
"config": {
"commitizen": {
"path": "./node_modules/cz-customizable"
}
}
- cz-customizable 配置,在根目錄新增一個(gè)
.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,
};
這里是關(guān)于cz-customizable更詳細(xì)的 示例 和 配置
使用 git cz 命令運(yùn)行 commitizen
在全局 PATH 配置正確的情況下,也可以直接使用 git cz 命令去運(yùn)行 commitizen。如果你在項(xiàng)目中安裝了 commitizen, 那么在你的項(xiàng)目下的 node_modules/.bin 目錄下將會(huì)看到兩個(gè)腳本: cz 和 git-cz , 如下圖所示:

這兩個(gè)腳本的內(nèi)容是一模一樣的,官方的文檔中會(huì)推薦在 package.json 的 scripts 中添加如下內(nèi)容:
commit: "cz"
這樣就可以使用 npm run commit 來(lái)運(yùn)行 commitizen 了。但是如果想要使用 git cz 命令運(yùn)行 commitizen,那么則要求 git-cz 文件所在的目錄在全局的 PATH 下,運(yùn)行以下命令來(lái)查看 PATH
echo $PATH
PATH 以冒號(hào)分隔,檢查一下所有的 PATH 中是否有一條能匹配到你的 cz 腳本,一般來(lái)說(shuō)都是有的,如果沒(méi)有,那么你可以在你的 ~/.zshrc 或者 ~/.bash_profile 中加上一條:
PATH=$PATH:./node_modules/.bin
然后重新加載一下配置文件,運(yùn)行 source ~/.zshrc 或者 source ~/.bash_profile,這樣在你項(xiàng)目根目錄下 就可以直接使用 git cz 命令了。
如果你是用 npm 全局安裝的 commitizen,那么你大概率不需要擔(dān)心 PATH 的問(wèn)題,因?yàn)?npm 的依賴安裝路徑下的 bin 文件夾會(huì)被 node 或者 NVM 自動(dòng)加入到 PATH 中。
回到剛剛所說(shuō)的 node_modules/.bin 文件夾 下的 git-cz 腳本,實(shí)際上它是 git cz 命令可以運(yùn)行的關(guān)鍵。不知道你是否疑惑,為什么使用 Git 可以去運(yùn)行一個(gè) npm 庫(kù),實(shí)際上,這是 git 自定義命令。想要添加一個(gè) git 自定義命令有如下幾個(gè)要求:
- 是一個(gè)可執(zhí)行文件
- 文件名必須是
git-XXX - 這個(gè)文件所在路徑必須在你的 PATH 下
所以前文中,提到想要運(yùn)行 git cz 命令,需要全局 PATH 配置正確。
你也可以根據(jù)上述要求嘗試添加別的自定義 git 命令。需要注意的是,要檢查一下你添加的 shell 腳本是否具有可執(zhí)行權(quán)限,若沒(méi)有可執(zhí)行權(quán)限會(huì)導(dǎo)致如下報(bào)錯(cuò) _git: 'your command' is not a git command_, 此時(shí)可以運(yùn)行 _chmod a+x <path to your file>_來(lái)修改文件的權(quán)限使其可運(yùn)行即可。
commitlint
commitlint 這個(gè)工具庫(kù),可以通過(guò)配置一些規(guī)則來(lái)校驗(yàn) commit message 是否規(guī)范。
那么我們已經(jīng)有了 commitizen 為什么還需要 commitlint 呢?上文中說(shuō)到,commitizen 的作用是協(xié)助開(kāi)發(fā)者填寫 commit message,雖然可以通過(guò)選擇不同的適配器或者自定義適配器來(lái)制定對(duì)應(yīng)的 commit 信息規(guī)范以及模板,但是缺少了對(duì) commit message 的校驗(yàn)功能,開(kāi)發(fā)者仍然可能在無(wú)意中使用原生的 git commit 命令來(lái)提交,而 commitlint 在 commit-msg 這個(gè) git hook 中對(duì) commit message 進(jìn)行校驗(yàn),正好解決了這個(gè)問(wèn)題。
commitlint 的使用
- 安裝
pnpm install --save-dev @commitlint/config-conventional @commitlint/cli
- 使用 husky 添加 commit-msg hook
npx husky add .husky/commit-msg "npx --no -- commitlint --edit $1
- commitlint 配置
在項(xiàng)目根目錄增加一個(gè)commitlint.config.js文件,文件內(nèi)容如下:
module.exports = {
extends: ['@commitlint/config-conventional'],
// 自定義部分規(guī)則
rules: {
'scope-case': [0, 'always', 'camel-case'],
'scope-empty': [2, 'never'],
'scope-enum': [2, 'always', [...]],
},
};
commitlint 與 commitizen 一樣,分為兩部分,一部分是執(zhí)行的主程序,另一部分是規(guī)則或者說(shuō)是適配器。 @commitlint/cli 是執(zhí)行的主程序,@commitlint/config-conventional 則是規(guī)則。commitlint 和 commitizen 分別采用了策略模式和適配器模式,因此都擁有非常高的可用性和良好的擴(kuò)展性。
在 commitlint 的配置文件中,可以先引用一個(gè) commitlint 規(guī)則包,然后在定義部分自己想要的規(guī)則,就像 eslint 的配置一樣。
需要注意的是,在將 commitlint 添加到 commit-msg hooks 中時(shí),執(zhí)行 commitlint 的 shell 命令中 --edit $1 參數(shù)是必須的,這個(gè)參數(shù)的意思是:存儲(chǔ) commit message 的臨時(shí)文件路徑是 $1, 而$1 則是 Git 傳給 commit-msg hook 的參數(shù),它的值是 commit message 的臨時(shí)存儲(chǔ)文件的路徑,默認(rèn)情況下是 .git/COMMIT_EDITMSG。如果不傳這個(gè)參數(shù),那么 commitlint 將無(wú)法得知當(dāng)前的 commit message 是什么。
更多 commitlint 的相關(guān)詳情看這里
commitlint 與 commitizen 的配置共用
前文中說(shuō)到 commitlint 解決了 commitizen 沒(méi)有對(duì) commit message 做校驗(yàn)的問(wèn)題,但是使用了 commitlint 后,新的問(wèn)題出現(xiàn)了,如果 commitlint 的規(guī)則集與 commitizen 的適配器中的規(guī)則不一致,那么可能會(huì)導(dǎo)致使用 commitizen 生成的 commit message 被 commitlint 校驗(yàn)時(shí)不通過(guò)從而 git commit 失敗。
解決這個(gè)問(wèn)題的辦法有兩種:
- 將 commitizen 的適配器規(guī)則翻譯為 commitlint 規(guī)則集,已有的對(duì)應(yīng)工具包為 commitlint-config-cz,這個(gè)包需要你所使用的 commitizen 適配器為
cz-customizable,也就是自定義適配器。 - 將 commitlint 規(guī)則集轉(zhuǎn)化為 commitizen 的適配器,已有對(duì)應(yīng)的工具包為 @commitlint/cz-commitlint
這里以第二種選用 @commitlint/cz-commitlint 為例:
- 安裝 @commitlint/cz-commitlint
pnpm install --save-dev @commitlint/cz-commitlint
- 修改 packages.json 中 commitizen 的配置
"config": {
"commitizen": {
"path": "./node_modules/@commitlint/cz-commitlint"
}
}
conventional-changelog 生態(tài)
打開(kāi) commitlint 的 github 倉(cāng)庫(kù),就會(huì)發(fā)現(xiàn)它在 conventional-changelog 這個(gè) Organization 下,而 commitizen/cz-cli 這個(gè)倉(cāng)庫(kù)的 README.md 文件中也提到了 conventional-changelog 生態(tài):
For this example, we'll be setting up our repo to use AngularJS's commit message convention, also known as conventional-changelog.
這也難怪為什么 commitlint 還專門提供了一個(gè) @commitlint/cz-commitlint 包來(lái)配合 commitizen。
那么 conventional-changelog 生態(tài)還包含什么呢?
支持 Conventional Changelog 的插件
Conventional Changelog 生態(tài)中的重要模塊
- conventional-changelog-cli - the full-featured command line interface --功能豐富的命令行接口
- standard-changelog - command line interface for the angular commit format. --angular 風(fēng)格的命令行接口
- conventional-github-releaser - Make a new GitHub release from git metadata --通過(guò) git 元數(shù)據(jù)生成一個(gè)新的 GitHub release
- conventional-recommended-bump - Get a recommended version bump based on conventional --commits 根據(jù) conventional 風(fēng)格的提交生成推薦的版本變更
- conventional-commits-detector - Detect what commit message convention your repository is using ---對(duì)存儲(chǔ)庫(kù)使用的 commit 消息約定進(jìn)行檢查
- commitizen - Simple commit conventions for internet citizens.
- commitlint - Lint commit messages
由于本文主要是講 git hooks,這里關(guān)于 conventional-changelog 生態(tài)就不展開(kāi)講了,有興趣的話可以自行去看一下他們的 github 倉(cāng)庫(kù) 和 這篇文章