Husky工作原理及源码解析

860 阅读7分钟

文中涉及的代码来自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 commitgit 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.shhusky.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.jsinstall方法,参数为第四个参数,此过程中为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.jsadd方法,并将第三、第四个参数传了进去。以npx husky add .husky/pre-commit "npm test"为例,即参数为.husky/pre-commit"npm test"
继续看index.jsadd方法。

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

# 上面两行命令是等效的

代码实现了递归调用,目的是为了在出错时,可以捕获到并给出友好的提示。逻辑很简单,捕获错误的执行流程如下:

  1. git pre-commit hook触发,.husky/pre-commit开始执行
  2. .husky/_/husky.sh文件开始执行,此时husky_skip_init值为空,所以进入if语句
  3. 设置husky_skip_init1,并输出给子进程。然后,重新执行.husky/pre-commit,即开启了子进程(sh -e "$0" "$@")。
  4. 再次执行.husky/_/husky.sh,此时husky_skip_init不为空,跳过if,开始执行npm test
  5. 如果npm test执行出错,子进程直接退出。但无论成功失败,都会返回一个code。在主进程中通过$?捕获。
  6. 如果子进程的退出code等于0,则表示子进程执行正常,主进程什么也不做,程序执行完毕。
  7. 如果子进程的退出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中,--nono-install的缩写,即不安装远程包而使用本地的。使用了$1说明在调用commit-msg时,是传了参数的,而参数就是.git/COMMIT_EDITMSG。这个文件存储了我们正在提交的commitmsg信息。

最后

小知识

  1. dirname用于获取一个路径中的目录路径,basename用于获取路径中的文件名。
path=a/b/c.d

dirname -- "$path" # a/b
basename -- "$path" # c.d
  1. .命令等同于source。即在当前脚本文件(A)中执行另一个脚本(B),并且B中声明的变量可以在A中使用。$0变量本来是当前执行脚本文件的路径,使用source命令时,在B中的$0的值为A的路径。
  2. $@变量包含了所有执行命令时的参数,$?是上一行代码的执行结果。
sh a.sh bcd ef

# a.sh
echo $@ #bcd ef

# a.sh
cd a 
echo $? # success: 0 fail: 1
  1. sh -e参数-e的意思是执行出错后直接退出当前sh
  2. --的意思是将之后的内容视为命令,而不是参数。比如要查看文件名为--file的文件
ll --file # 会把file视为参数的键
ll -- --file #会把--file 作为文件名

了解更多

Husky Repository
Husky Docs
Husky Blog