文中涉及的代码来自Husky 8.0.1版本
什么是Husky?
根据官方的介绍:Husky支持监听所有的git hooks,可以在这些hooks被触发的时候,执行一些操作。
You can use it to lint your commit messages, run tests, lint code, etc... when you commit or push. Husky supports all Git hooks.
Git Hooks是基于Git事件的,一般我们常用的有两个:
hooks | 说明 | 常用场景 |
---|---|---|
pre-commit | 执行git commit 时触发,顾名思义,在commit创建之前被调用。如果钩子的回调方法执行异常(返回code != 0),commit也将不会被创建。 | 格式化代码(lint code) 执行测试代码(run test) |
commit-msg | 执行git commit 、git merge 时触发,可以在回调钩子里安装某些规范修改message文件,也可以在检查之后中止该命令执行(返回code != 0)。 | 格式化提交信息(lint commit message) |
Husky4.x
4.x版本的husky安装之后,可以在package.json
或者.huskyrc.json
中进行配置:
{
"hooks": {
"pre-commit": "npm run lint",
"commit-msg": "npx commitlint -E HUSKY_GIT_PARAMS"
}
}
然后,在对应的git hooks被触发之后,就可以执行配置的命令。
其实现原理大概如下:
概括起来就是:
在项目安装依赖(npm i
)时,在.git/hooks
目录中创建所有的hook
可执行文件(如pre-commit
),每个hook
文件里面很简单,就是执行husky.sh
。husky.sh
也很简单,就是执行husky-run
附带一些参数。下面是pre-commit hook
文件内容
#!/bin/sh
# husky
# Created by Husky v4.2.5 (https://github.com/typicode/husky#readme)
# At: 2022/9/5 12:25:46
# From: /Users/zpc/cuixote/work/yibao/legend/node_modules/husky (undefined)
. "$(dirname "$0")/husky.sh"
所以当git事件触发对应的hook
并执行,husky检查在所有的配置文件中是否配置了对应的hook,如果配置了,就拿到回调命令并执行。
在实际的使用场景中,用户使用起来还是很方便的。需要某个hook就在配置文件里添加,不需要就删除。但是,执行效率其实不高,并且因为流程过长也导致了很多意想不到的异常(大家可以看一下husky4.x
的源码,很复杂)。
比如,执行效率方面,无论hook是否存在,任一git事件(这很频繁)触发时,husky/runner.js
都要执行一次检查。
简而言之,husky4.x是通过node
连通的JS世界和Git世界。这其实是一种妥协方案。
思考与改进
让我们回到最开始的出发点:git事件触发对应的hooks,执行操作。
那我们为什么不直接更改git的hooks文件呢?git的hooks文件都放在.git/hooks/
目录之下,如果需要pre-commit hook
,直接在目录下创建pre-commit
可执行文件即可。
#!/bin/sh
echo "hello world"
exit 0
这样做,效率确实更快了,但是也有很多问题。
.git
目录下的文件是本地的,他不能通过git
本身在团队内共享.git
目录一般在编辑器下是不可见的,需要每次通过命令行打开或者修改
好像比husky 4.x
问题严重的多(这也算是确定了husky 4.x
)的价值)。那有没有办法把这些问题都解决了呢?我们现在梳理一下,我们需要一个怎样的方案。
- 快速。直接执行shell 脚本效率远大于间接。
- 友好。可以通过尽量简单的方式进行配置。
- 协作。可以作为固定配置,跟随
git
做到可以共享。
下面,我们看下husky 5-8
,它是如何实现这个方案的。
Husky5 - Husky8
在2016年,Git2.9推出了core.hooksPath
,它的作用在于,Git hooks可执行文件的文件夹路径,不必是.git/hooks/
目录了。
这意味着我们上面提到的协作问题自然而然的解决了——我们只需要将它指向一个项目中的目录就行(当然,这个目录不包含在.gitignore
里面)
至于友好,husky
提供了命令行工具,只需一行命令,即可自动创建一个可执行文件。
至于快速,新版本的husky
不再有node
作为桥接层了,git事件触发后,会直接执行.husky
目录里对应的可执行文件。
如何使用?
我们看一下官方教程:
# 1. 安装
npm install husky --save-dev
# 2. 设置git hooks目录
npx husky install
# 3. 设置pre-commit hooks
npx husky add .husky/pre-commit "npm test"
# 4. 执行git commit,触发pre-commit
git add .
git commit -m "提交之前会执行npm test"
# 5. pre-commit被触发,执行 npm test
# 如果npm test执行成功,则继续执行 git commit
# 如果npm test执行失败,则终止执行 git commit
下面是完整流程:
npm hooks
因为每次在项目重新初始化时,都要执行npx husky install
,所以我们设置npm prepare hook,重新安装依赖时,就可以自动执行husky install
了。
可以在scripts
里直接设置,也可以使用下面的命令。
# 1. 启用自动设置git hooks(以后)
npm pkg set scripts.prepare "husky install"
// package.json
{
"scripts": {
// npm的hooks,执行npm install之前会被触发
"prepare": "husky install"
}
}
深入源码分析
新的方案之后,husky
的源码变得非常简单。现在让我们深入看一下它是如何实现的。
npx husky install
pkg.bin
node_modules/husky/package.json
配置了bin: lib/bin.js
,所以会有node_modules/.bin/husky
,而在node_modules/.bin
目录中的文件,都是可直接在命令行执行的。执行的实际文件即node_modules/husky/lib/bin.js
。
{
"name": "husky",
"version": "8.0.1",
"description": "Modern native Git hooks made easy",
"bin": "lib/bin.js",
"main": "lib/index.js",
}
husky/lib/bin.js
我们继续看在这个文件里如何执行命令install
命令的。
参数解析
const p = require("path");
const h = require("./");
function help(code) {
console.log(`Usage:
husky install [dir] (default: .husky)
husky uninstall
husky set|add <file> [cmd]`);
process.exit(code);
}
const [, , cmd, ...args] = process.argv;
const ln = args.length;
const [x, y] = args;
const cmds = {
install: () => (ln > 1 ? help(2) : h.install(x))
};
try {
cmds[cmd] ? cmds[cmd]() : help(0);
}
catch (e) {
console.error(e instanceof Error ? `husky - ${e.message}` : e);
process.exit(1);
}
process.argv
是一个数组,第一项是node可执行文件的路径,第二个是当前执行文件的路径。参数解析完成后,会执行依赖的index.js
的install
方法,参数为第四个参数,此过程中为undefined
.
命令执行
// 1. dir设置默认值'.husky'
function install(dir = '.husky') {
// 2. 如果全局变量HUSKY等于'0',跳过安装
if (process.env.HUSKY === '0') {
l('HUSKY env variable is set to 0, skipping install');
return;
}
// 3. 如果git rev-parse 执行失败,即当前项目不是git项目,跳过安装
// git rev-parse什么也不会做
if (git(['rev-parse']).status !== 0) {
return;
}
const url = 'https://typicode.github.io/husky/#/?id=custom-directory';
// 4. 可以自己设置dir参数,但必须是在根目录之下
if (!p.resolve(process.cwd(), dir).startsWith(process.cwd())) {
throw new Error(`.. not allowed (see ${url})`);
}
// 5. 如果当前目录不存在git配置文件。抛错
if (!fs.existsSync('.git')) {
throw new Error(`.git can't be found (see ${url})`);
}
try {
// 6. 创建 .husky/_ 文件夹
// recursive: true 根据路径递归创建多个目录
fs.mkdirSync(p.join(dir, '_'), { recursive: true });
// 7. 创建 .husky/_/.gitignore 文件,并设置内容为 *
fs.writeFileSync(p.join(dir, '_/.gitignore'), '*');
// 8. 将文件从 node_modules/husky/husky.sh 拷贝到 .husky/_/husky.sh
fs.copyFileSync(p.join(__dirname, '../husky.sh'), p.join(dir, '_/husky.sh'));
// 9. 设置 git.config.core.hooksPath 路径为 .husky
const { error } = git(['config', 'core.hooksPath', dir]);
if (error) {
throw error;
}
}
catch (e) {
l('Git hooks failed to install');
throw e;
}
l('Git hooks installed');
}
我们看一下设置完成后的git config
cat .git/config
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
hooksPath = .husky
这就意味着,以后每一个git hook触发的时候,都会去hookPath执行的目录即.husky
中寻找同名的可执行文件,找到后就立即执行。接下来,我们就去添加这些文件。
npx husky add
向.husky
目录添加文件,可以直接添加,也可以使用husky提供的命令。推荐使用命令。我们看下这个命令做了什么。
还是看husky/lib/bin.js
。
const [, , cmd, ...args] = process.argv;
const ln = args.length;
const [x, y] = args;
const hook = (fn) => () => !ln || ln > 2 ? help(2) : fn(x, y);
const cmds = {
add: hook(h.add),
};
try {
cmds[cmd] ? cmds[cmd]() : help(0);
}
catch (e) {
console.error(e instanceof Error ? `husky - ${e.message}` : e);
process.exit(1);
}
可以知道,这个命令最终调用了index.js
的add
方法,并将第三、第四个参数传了进去。以npx husky add .husky/pre-commit "npm test"
为例,即参数为.husky/pre-commit
和"npm test"
。
继续看index.js
的add
方法。
export function set(file: string, cmd: string): void {
// 4. 获取文件的目录,即.husky
const dir = p.dirname(file)
// 5. 如果目录不存在,即未执行husky install,抛错
if (!fs.existsSync(dir)) {
throw new Error(
`can't create hook, ${dir} directory doesn't exist (try running husky install)`,
)
}
// 6. 如果目录存在,则设置目录文件内容。
// 设置权限为拥有者可读/写/执行、其他用户可读/执行
fs.writeFileSync(
file,
`#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
${cmd}
`,
{ mode: 0o0755 },
)
l(`created ${file}`)
}
// 1. file = .husky/pre-commit cmd = "npm test"
export function add(file: string, cmd: string): void {
// 2. 如果文件已经存在
if (fs.existsSync(file)) {
// 向指定文件后面添加内容,如果文件不存在则创建文件
fs.appendFileSync(file, `${cmd}\n`)
l(`updated ${file}`)
} else {
// 3. 如果文件不存在,则去设置
set(file, cmd)
}
}
过程很清晰。设置完成后我们可以看到.husky/pre-commit
文件
#!/usr/bin/env sh
# source .husky/_/husky.sh
. "$(dirname -- "$0")/_/husky.sh"
npm test
pre-commit hook
在git pre-commit hook
被触发时,.husky/pre-commit
文件会开始执行。
我们接着看.husky/_/husky.sh
做了什么
#!/usr/bin/env sh
# 如果$husky_skip_init为空
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
# hook_name=pre-commit
readonly hook_name="$(basename -- "$0")"
debug "starting $hook_name..."
# 设置变量HUSKY="0"可以跳过
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
# 载入配置文件
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
# 给变量husky_skip_init赋值
readonly husky_skip_init=1
export husky_skip_init
# sh -e .husky/pre-commit
# 重新执行,发生错误则终止运行
sh -e "$0" "$@"
# 如果执行出错
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
if [ $exitCode = 127 ]; then
echo "husky - command not found in PATH=$PATH"
fi
exit $exitCode
fi
HUSKY = 0
可以跳过hook,当然,git本身也提供了跳过hook的设置。
HUSKY=0 git commit -m "yolo!"
git commit -m "yolo!" --no-verify
# 上面两行命令是等效的
代码实现了递归调用,目的是为了在出错时,可以捕获到并给出友好的提示。逻辑很简单,捕获错误的执行流程如下:
git pre-commit hook
触发,.husky/pre-commit
开始执行.husky/_/husky.sh
文件开始执行,此时husky_skip_init
值为空,所以进入if
语句- 设置
husky_skip_init
为1
,并输出给子进程。然后,重新执行.husky/pre-commit
,即开启了子进程(sh -e "$0" "$@"
)。 - 再次执行
.husky/_/husky.sh
,此时husky_skip_init
不为空,跳过if
,开始执行npm test
- 如果
npm test
执行出错,子进程直接退出。但无论成功失败,都会返回一个code
。在主进程中通过$?
捕获。 - 如果子进程的退出
code
等于0,则表示子进程执行正常,主进程什么也不做,程序执行完毕。 - 如果子进程的退出
code
不等于0,则进入下面的两个if
语句,给出错误提示。
commit-msg hook
我们再添加一个commit-msg
,我们发现与pre-commit
还是有些不同的。
cat <<EEE > .husky/commit-msg
#!/bin/sh
. "\$(dirname "\$0")/_/husky.sh"
npx --no -- commitlint --edit "\${1}"
EEE
也可以使用husky install
npx husky add .husky/commit-msg 'npx --no -- commitlint --edit `echo \"\$1\"`'
最终的commit-msg
文件如下:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# npx --no -- commitlint --edit .git/COMMIT_EDITMSG
npx --no -- commitlint --edit "${1}"
npx --no
中,--no
是no-install
的缩写,即不安装远程包而使用本地的。使用了$1
说明在调用commit-msg
时,是传了参数的,而参数就是.git/COMMIT_EDITMSG
。这个文件存储了我们正在提交的commit
的msg
信息。
最后
小知识
dirname
用于获取一个路径中的目录路径,basename
用于获取路径中的文件名。
path=a/b/c.d
dirname -- "$path" # a/b
basename -- "$path" # c.d
.
命令等同于source
。即在当前脚本文件(A)中执行另一个脚本(B),并且B中声明的变量可以在A中使用。$0
变量本来是当前执行脚本文件的路径,使用source
命令时,在B中的$0
的值为A的路径。$@
变量包含了所有执行命令时的参数,$?
是上一行代码的执行结果。
sh a.sh bcd ef
# a.sh
echo $@ #bcd ef
# a.sh
cd a
echo $? # success: 0 fail: 1
sh -e
参数-e
的意思是执行出错后直接退出当前sh
。--
的意思是将之后的内容视为命令,而不是参数。比如要查看文件名为--file
的文件
ll --file # 会把file视为参数的键
ll -- --file #会把--file 作为文件名