原文地址
手写 git hooks 脚本(pre-commit、commit-msg)
npm script介绍(翻译)
child_process

目前的项目配置 husky+commitlint+prettier做一些规范化格式校验,但是之前从来没专门去了解过是怎么实现的。最近突然对 husky 实现产生好奇,于是自己尝试进行实现一个myhusky工具。

前置知识点

正式开始前,对可能需要的知识点进行简单介绍。

npm是如何处理 scripts 字段的

在项目开发过程中,都会往 package.json 添加一些项目使用到的 scripts 。不过 scripts 中也有一些隐藏的功能。

Pre & Post Scripts

  1. {
  2. "scripts": {
  3. "precompress": "{{ executes BEFORE the `compress` script }}",
  4. "compress": "{{ run command to compress files }}",
  5. "postcompress": "{{ executes AFTER `compress` script }}"
  6. }
  7. }

配置了上述 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 命令需要两个参数, 一个是传入新增的文件名,另外一个是对应触发的命令。

    • 如 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.sh
  • all.sh 的操作有:执行 build 命令 ,接着在执行第一个脚本时会再调用 functions.sh ,function.sh 里面定义了 setup 初始化函数,以及自己实现了 expect, expect_hooksPath_to_be 用于做测试验收的工具函数。

而我们主要通过 jest 进行单测实现:

  1. const cp = require('child_process')
  2. const fs = require('fs')
  3. const path = require('path')
  4. const { install, add } = require('../lib/index')
  5. const shell = require('shelljs')
  6. const hooksDir = '.myhusky'
  7. const removeFile = p => shell.rm('-rf', p)
  8. const git = args => cp.spawnSync('git', args, { stdio: 'inherit' });
  9. const reset = () => {
  10. const cwd = process.cwd()
  11. const absPath = path.resolve(cwd, hooksDir)
  12. removeFile(absPath)
  13. git(['config', '--unset', 'core.hooksPath'])
  14. }
  15. beforeAll(() => {
  16. process.chdir(path.resolve(__dirname, '../'))
  17. })
  18. // 每个单测都会执行的函数
  19. beforeEach(() => {
  20. reset()
  21. install(hooksDir) // 执行 install
  22. })
  23. // 测试 install 命令
  24. test('install cmd', async () => {
  25. // install 逻辑在每个单测开始前都会执行, 所以当前测试用例只需要对结果进行检测
  26. const pwd = process.cwd()
  27. const huskyDirP = path.resolve(pwd, hooksDir)
  28. const hasHuskyDir = fs.existsSync(huskyDirP) && fs.statSync(huskyDirP).isDirectory()
  29. // 读取 git config 文件信息
  30. const gitConfigFile = fs.readFileSync(path.resolve(pwd, '.git/config'), 'utf-8')
  31. // git config 文件需要含有 hooksPath配置
  32. expect(gitConfigFile).toContain('hooksPath = .myhusky')
  33. // 期望含有新创建的 git hooks 文件夹
  34. expect(hasHuskyDir).toBeTruthy()
  35. })
  36. // 测试 add 命令
  37. test('add cmd work', async () => {
  38. const hookFile = `${hooksDir}/commit-msg`
  39. const recordCurCommit = git(['rev-parse', '--short', 'HEAD'])
  40. // 往commit-msg文件写入脚本内容, exit 1
  41. add(hookFile, 'exit 1')
  42. git(['add', '.'])
  43. // 执行 git commit 操作触发钩子
  44. const std = git(['commit', '-m', 'fail commit msg'])
  45. // 查看进程返回的状态码
  46. expect(std.status).toBe(1)
  47. // 清除当前测试用例的副作用
  48. git(['reset', '--hard', recordCurCommit])
  49. removeFile(hookFile)
  50. })

实现

  • 配置 package.json , 添加为可执行的命令 myhusky 以及对应命令触发的文件 ./lib/bin.js ```json { “name”: “myhusky”, “version”: “1.0.0”, “description”: “”, “main”: “index.js”, “scripts” “bin”: { “myhusky”: “./lib/bin.js” } }
  1. 代码实现:
  2. ```javascript
  3. #!/usr/bin/env node
  4. // lib/bin.js
  5. const { install, add } = require('./')
  6. const [cmdType, ...args] = process.argv.slice(2);
  7. const ln = args.length;
  8. const cmds = { // 命令集合
  9. install,
  10. add
  11. }
  12. const cmd = cmds[cmdType]
  13. if (cmd) {
  14. cmd(...args)
  15. }