目前的项目配置 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<event>,post<event>,<event>之外。 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 <file> [cmd]
- 支持安装命令:
install命令手动触发, 触发后主要逻辑有:- 创建对应的
git hooksPath文件夹; - 调用
git config core.hooksPath设置项目git hooks路径【即设置git hook脚本存放目录, 触发时往此目录上找对应的脚本执行】
- 创建对应的
add命令需要两个参数, 一个是传入新增的文件名,另外一个是对应触发的命令。- 如
myhusky add .myhusky/commit-msg 'npx commitlint -e $1', 添加commit-msg钩子, 在进行commit操作时会触发npx commitlint -e $1命令。
- 如
测试先行
结合功能点,先整理需要测试的内容
- 测试
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.shall.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{ "name": "myhusky", "version": "1.0.0", "description": "", "main": "index.js", "scripts" "bin": { "myhusky": "./lib/bin.js" } } -
代码实现:
#!/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) }// lib/index.js const cp = require('child_process') const git = (args) => cp.spawnSync('git', args, { stdio: 'inherit' }); const fs = require('fs'); const path = require('path'); const cwd = process.cwd(); // 初始化安装命令 exports.install = function install (dir = '.myhusky') { const huskyP = path.resolve(cwd, dir) if (!fs.existsSync(path.join(cwd, '.git'))) { throw new Error('cannot find .git directory'); } // 创建hooksPath文件夹 fs.mkdirSync(huskyP) // git config core.hooksPath dir 设置git hooks触发文件目录 git(['config', 'core.hooksPath', dir]) } // 添加hook命令 exports.add = function add (file, cmd) { // 追加命令函数 // 往git hooks目录下追加 `commit-msg` 脚本 fs.writeFileSync( path.join(cwd, file), `#!/bin/sh ${cmd}`, { mode: 0o0755 } ) }
测试
-
项目中
npm link, 将myhusky包添加到npm全局下 -
进入一个测试项目,
npm link myhusky, 将本地myhusky依赖安装进项目内 -
测试项目安装
commitlint, 并添加commitlint.config.js配置// commitlint.config.js module.exports = { rules: { 'body-leading-blank': [1, 'always'], 'body-max-line-length': [2, 'always', 100], 'footer-leading-blank': [1, 'always'], 'footer-max-line-length': [2, 'always', 100], 'header-max-length': [2, 'always', 100], 'subject-case': [ 2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], ], 'subject-empty': [2, 'never'], 'subject-full-stop': [2, 'never', '.'], 'type-case': [2, 'always', 'lower-case'], 'type-empty': [2, 'never'], 'type-enum': [ 2, 'always', [ // 支持的类型枚举 'build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', ], ], } }; -
测试项目中,调用
npx myhusky install,npx myhusky add .myhusky/commit-msg 'npx commitlint -e $1'
【这里$1代表什么呢? 】这个是在执行 shell 传入的参数, $1 具体的值是 .git/COMMIT_EDITMSG, 这个文件记录着当前的commit信息, 将这个作为 commitlint -e的配置项传入, commitlint 会根据这个配置项去读取 commit信息 根据配置的规则进行验证, 如果失败则退出导致 commit 失败。
- 至此, 进行测试的配置已完成,添加一个文件并进行提交
git commit -m 'try: add pkg', 会发现拦截校验不通过
校验不通过:
校验通过:
.
总结
至此, 一个简单的git hook工具大致功能已经完成。通过了解husky的内部实现,并且自己手写实现一个仿照的例子,从中能够学习到:
git hooks是怎么起到作用的;scripts一些隐藏的钩子;huskycommitlint是怎么配合实现的;
参考资料
关注我们
大家的支持是我们继续前进的动力,快来关注我们深信服前端团队吧~
同时,如果对我们感兴趣的话,欢迎加入我们,投递简历到 uedc@sangfor.com.cn。