前端 Git-Hooks 工程化實踐

袋鼠雲數棧UED發表於2022-06-29

前言

前段時間,部門的前端專案遷移到 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

  • 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 示例

file

從圖中可以看到,預設的 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 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 的使用

  1. 安裝 lint-staged

    pnpm install lint-staged --save-dev
  2. 配置 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
  2. 初始化 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 命令 就會出現以下命令列互動頁面:

file

這個介面卡生成的 commit message 模板如下

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

這也是最常見的提交約定,當然也可以安裝其他介面卡,或者自定義介面卡來定製自己想要的 commit message 模板。
當執行 npx cz, commitizen 在通過介面卡模板以及使用者的輸入拿到最終的 commit message 後,會在內部執行 git commit -m "XXX" 命令,到此為止,就完成了一次 git commit 操作

更多關於 commitizen 的詳細等資訊可以看 githubcz-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"
      }
    }
  2. 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 , 如下圖所示:

file

這兩個指令碼的內容是一模一樣的,官方的文件中會推薦在 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
  2. 修改 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 倉庫這篇文章

相關文章