原文地址
手写 git hooks 脚本(pre-commit、commit-msg)
npm script介绍(翻译)
child_process
目前的项目配置 husky+commitlint+prettier做一些规范化格式校验,但是之前从来没专门去了解过是怎么实现的。最近突然对 husky 实现产生好奇,于是自己尝试进行实现一个myhusky工具。
前置知识点
npm是如何处理 scripts 字段的
在项目开发过程中,都会往 package.json 添加一些项目使用到的 scripts 。不过 scripts 中也有一些隐藏的功能。
Pre & Post Scripts
{
"scripts": {
"precompress": "{{ executes BEFORE the `compress` script }}",
"compress": "{{ run command to compress files }}",
"postcompress": "{{ executes AFTER `compress` script }}"
}
}
配置了上述 scripts, 当触发npm run compress时, 会按顺序触发 precompress, compress, postcompress 脚本。即实际命令类似于npm run precompress && npm run compress && npm run postcompress。
Lift Cycle Scripts
- 除了上面的 Pre & Post Scripts,还有一些特殊的生命周期在指定的情况下触发,这些脚本触发在除 pre
, post , 之外。 - prepare, prepublish, prepublishOnly, prepack, postpack ,以上几个就是内置的生命周期钩子。
以 prepare 为例, prepare 是从 npm@4.0.0 开始支持的钩子,主要触发时机在下面的任意一个流程中:
- 在打包之前运行时
- 在包发布之前运行时
- 在本地不带任何参数运行npm install时
- 运行prepublish之后,但是在运行prepublishOnly之前
注意:如果通过 git 安装的包包含prepare脚本时,dependencies,devDependencies将会被安装,此后prepare脚本会开始执行。
功能点
介绍完涉及到的知识点后,接下来仿照 husky, 梳理需要实现的功能点:
支持命令行调用 myhusky,
- 支持安装命令:myhusky install [dir] (default: .husky),
- 支持添加 hook 命令:myhusky add
[cmd]
- install命令手动触发, 触发后主要逻辑有:
- 创建对应的git hooksPath文件夹;
- 调用 git config core.hooksPath 设置项目 git hooks路径【即设置 git hook 脚本存放目录, 触发时往此目录上找对应的脚本执行】
add 命令需要两个参数, 一个是传入新增的文件名,另外一个是对应触发的命令。
测试 install 命令。
- 执行成功后,工作目录下会生成对应存放 git hooks 的文件夹;
- .git/config 文件内部会新增多一个配置 hooksPath = .myhusky
- 测试 add 命令。
- 往 git hooks 文件夹新增一个 commit-msg hook,用于在进行 git commit 时触发本地检验;
- 往 commit-msg hook 写入的检验内容为 exit 1, 失败退出;
- 进行 git commit 操作,此时 git commit 操作会提交失败,失败状态码为1;
测试用例的简单实现
husky 库是通过 shell 脚本 进行测试的,主要流程是:
- npm run test 实际上执行 sh test/all.sh
- all.sh 的操作有:执行 build 命令 ,接着在执行第一个脚本时会再调用 functions.sh ,function.sh 里面定义了 setup 初始化函数,以及自己实现了 expect, expect_hooksPath_to_be 用于做测试验收的工具函数。
而我们主要通过 jest 进行单测实现:
const cp = require('child_process')
const fs = require('fs')
const path = require('path')
const { install, add } = require('../lib/index')
const shell = require('shelljs')
const hooksDir = '.myhusky'
const removeFile = p => shell.rm('-rf', p)
const git = args => cp.spawnSync('git', args, { stdio: 'inherit' });
const reset = () => {
const cwd = process.cwd()
const absPath = path.resolve(cwd, hooksDir)
removeFile(absPath)
git(['config', '--unset', 'core.hooksPath'])
}
beforeAll(() => {
process.chdir(path.resolve(__dirname, '../'))
})
// 每个单测都会执行的函数
beforeEach(() => {
reset()
install(hooksDir) // 执行 install
})
// 测试 install 命令
test('install cmd', async () => {
// install 逻辑在每个单测开始前都会执行, 所以当前测试用例只需要对结果进行检测
const pwd = process.cwd()
const huskyDirP = path.resolve(pwd, hooksDir)
const hasHuskyDir = fs.existsSync(huskyDirP) && fs.statSync(huskyDirP).isDirectory()
// 读取 git config 文件信息
const gitConfigFile = fs.readFileSync(path.resolve(pwd, '.git/config'), 'utf-8')
// git config 文件需要含有 hooksPath配置
expect(gitConfigFile).toContain('hooksPath = .myhusky')
// 期望含有新创建的 git hooks 文件夹
expect(hasHuskyDir).toBeTruthy()
})
// 测试 add 命令
test('add cmd work', async () => {
const hookFile = `${hooksDir}/commit-msg`
const recordCurCommit = git(['rev-parse', '--short', 'HEAD'])
// 往commit-msg文件写入脚本内容, exit 1
add(hookFile, 'exit 1')
git(['add', '.'])
// 执行 git commit 操作触发钩子
const std = git(['commit', '-m', 'fail commit msg'])
// 查看进程返回的状态码
expect(std.status).toBe(1)
// 清除当前测试用例的副作用
git(['reset', '--hard', recordCurCommit])
removeFile(hookFile)
})
实现
- 配置 package.json , 添加为可执行的命令 myhusky 以及对应命令触发的文件 ./lib/bin.js ```json { “name”: “myhusky”, “version”: “1.0.0”, “description”: “”, “main”: “index.js”, “scripts” “bin”: { “myhusky”: “./lib/bin.js” } }
代码实现:
```javascript
#!/usr/bin/env node
// lib/bin.js
const { install, add } = require('./')
const [cmdType, ...args] = process.argv.slice(2);
const ln = args.length;
const cmds = { // 命令集合
install,
add
}
const cmd = cmds[cmdType]
if (cmd) {
cmd(...args)
}