原文地址
手写 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 1add(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.jsconst { 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)}
