前言
前段時間,部門的前端專案遷移到 monorepo 架構,筆者在其中負責跟 git 工作流相關的事情,其中就包括 git hooks 相關的工程化的實踐。用到了一些常用的相關工具如 husky、lint-staged、commitizen、commit-lint 等,以此文記錄一下整個的實踐過程和踩過的坑。
注意:下文中的例子以及命令都是基於 Mac OS,如果你是 windows 使用者,也不用擔心,文中也會闡述大致原理和執行邏輯,對應的 windows 命令可以推理得知。
Git Hooks
Git Hooks 是什麼
大多數同學應該都對 git hooks
相當瞭解,但是筆者還是想在這裡詳細解釋一下。
首先是 hook
,這其實是計算機領域中一個很常見的概念,hook
翻譯過來的意思是鉤子或者勾住,而在計算機領域中則要分為兩種解釋:
- 攔截訊息,在訊息到達目標前,提前對訊息進行處理
- 對特定的事件進行監聽,當某個事件或動作被觸發時也會同時觸發對應的
hook
也就是說hook
本身也是一段程式,只是它會在特定的時機被觸發。
理解了 hook
這一概念,那麼 git hooks
也就不難理解了。git hooks 就是在執行某些 git 命令時,被觸發的對應的程式。
在前端領域,鉤子的概念也並不少見,比如 Vue 宣告週期鉤子、React Hooks、webpack 鉤子等,說到底它們都是在特定的時機觸發的方法或者函式
常見的 Git Hooks 有哪些
git hooks 分為兩類
客戶端 hook
pre-commit
hook, 在執行git commit
命令時且在 commit 完成前被觸發commit-msg
hook, 在編輯完 commit-msg 時被觸發,並且接受一個引數,這個引數是存放當前 commit-msg 的臨時檔案的路徑pre-push
hook, 在執行git push
命令時且在 push 命令完成前被觸發
服務端 hook
pre-receive
在服務端接受到推送時且在推送過程完成前被觸發post-receive
在服務端接收到推送且推送完成後被觸發
這裡只列舉了一部分,更多的 git hooks 詳細資訊見官方文件
在本地 git 倉庫中的 .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 的使用
- 安裝 husky
pnpm install husky --save-dev
- husky 初始化
npx husky install
- 設定 package.json 的 prepare。來保證 husky 可以正常執行
npm set-script prepare "husky install"
- 新增 git hooks
npx husky add .husky/${hook_name} ${command}
husky install 命令做了什麼
事實上,husky install
命令是解決 git hooks 問題的關鍵
- 第一步: husky install 會在專案根目錄下建立
.husky
以及.husky/_
資料夾(資料夾也可以自定義),然後在.husky/_
資料夾下建立husky.sh
指令碼檔案。 這個檔案的作用就是保證通過 husky 建立的指令碼能夠正常執行,它的實際應用的地方後面會講到。更多關於這個指令碼的討論可以看這裡 github issue。 - 第二步: husky install 會執行
git config core.hooksPath ${path/to/hooks_dir}
,這個命令用來指定 git hooks 的路徑,此時觀察專案下.git/config
檔案, [core] 下面會多出一條配置:hooksPath = xxx
。當 git hooks 被某些命令觸發時,Git 會執行core.hooksPath
指定的資料夾下的 git hook。
更多關於 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 的使用
安裝 lint-staged
pnpm install lint-staged --save-dev
配置 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 的使用
安裝 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 命令的執行,實際的規則則需要通過介面卡來定義,commitizen 留有對應的介面卡介面。而 cz-conventional-changelog
就是一個 commitizen 介面卡。
此時執行 npx cz
命令 就會出現以下命令列互動頁面:
這個介面卡生成的 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 的使用
commitizen 配置
"config": { "commitizen": { "path": "./node_modules/cz-customizable" } }
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
目錄下將會看到兩個指令碼: cz
和 git-cz
, 如下圖所示:
這兩個指令碼的內容是一模一樣的,官方的文件中會推薦在 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 自定義命令有如下幾個要求:
- 是一個可執行檔案
- 檔名必須是
git-XXX
- 這個檔案所在路徑必須在你的 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 的使用
- 安裝
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 配置
在專案根目錄增加一個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 失敗。
解決這個問題的辦法有兩種:
- 將 commitizen 的介面卡規則翻譯為 commitlint 規則集,已有的對應工具包為 commitlint-config-cz,這個包需要你所使用的 commitizen 介面卡為
cz-customizable
,也就是自定義介面卡。 - 將 commitlint 規則集轉化為 commitizen 的介面卡,已有對應的工具包為 @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 生態
開啟 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 生態中的重要模組
- conventional-changelog-cli - the full-featured command line interface --_功能豐富的命令列介面_
- standard-changelog - command line interface for the angular commit format. --_angular 風格的命令列介面_
- conventional-github-releaser - Make a new GitHub release from git metadata --_通過 git 後設資料生成一個新的 GitHub release_
- conventional-recommended-bump - Get a recommended version bump based on conventional --commits 根據 conventional 風格的提交生成推薦的版本變更
- conventional-commits-detector - Detect what commit message convention your repository is using ---_對儲存庫使用的 commit 訊息約定進行檢查_
- commitizen - Simple commit conventions for internet citizens.
- commitlint - Lint commit messages
由於本文主要是講 git hooks,這裡關於 conventional-changelog 生態就不展開講了,有興趣的話可以自行去看一下他們的 github 倉庫 和 這篇文章