likes
comments
collection
share

现代本地git钩子变得很容易: husky!定制Git拦截规范

作者站长头像
站长
· 阅读数 61

现代本地git钩子变得很容易: husky

由于一些同学没有协同工作得相关经验,或者说说缺乏相关得git、代码规范知识。写的代码、Git CommitGit BranchesGit Tags等比较随意,造成得问题呢就是越迭代,越不好维护等。

那么有没有手段能控制呢?有太多了,eslintstylelintprettierlint-staged等等,但是还需要控制把一些不规范得给拦截. lint-staged插件中有一句介绍得话:对阶段性git文件运行检查器,不要让💩溜进代码库!(Run linters against staged git files and don't let 💩 slip into your code base!)

那么咱们今天就是利用githooks得一些钩子来把控这些代码等相关得质量。那么你不使用Husky可以吗?也可以。由于git默认是没有这些钩子得如下可以测试

git init
cd .git/hooks/
ls

正常会打印出来如下这些钩子

applypatch-msg.sample*      pre-push.sample*
commit-msg.sample*          pre-rebase.sample*
fsmonitor-watchman.sample*  pre-receive.sample*
post-update.sample*         prepare-commit-msg.sample*
pre-applypatch.sample*      push-to-checkout.sample*
pre-commit.sample*          update.sample*
pre-merge-commit.sample*

安装并执行husky官网命令v3如下:

yarn add husky@3.0.0

看到多增加了好多钩子

applypatch-msg*             pre-commit*
applypatch-msg.sample*      pre-commit.sample*
commit-msg*                 pre-merge-commit.sample*
commit-msg.sample*          pre-push*
fsmonitor-watchman.sample*  pre-push.sample*
post-applypatch*            pre-rebase*
post-checkout*              pre-rebase.sample*
post-commit*                pre-receive*
post-merge*                 pre-receive.sample*
post-receive*               prepare-commit-msg*
post-rewrite*               prepare-commit-msg.sample*
post-update*                push-to-checkout*
post-update.sample*         push-to-checkout.sample*
pre-applypatch*             sendemail-validate*
pre-applypatch.sample*      update*
pre-auto-gc*                update.sample*

咱们查看下pre-commit钩子如下:

cat pre-commit
#!/bin/sh
# husky

# Hook created by Husky
#   Version: 3.0.0
#   At: 2023/7/26 11:45:02
#   See: https://github.com/typicode/husky#readme

# From
#   Directory: /githooks
#   Homepage: https://github.com/typicode/husky#readme

scriptPath="node_modules/husky/run.js"
hookName=`basename "$0"`
gitParams="$*"

debug() {
  if [ "${HUSKY_DEBUG}" = "true" ] || [ "${HUSKY_DEBUG}" = "1" ]; then
    echo "husky:debug $1"
  fi
}

debug "$hookName hook started"

if [ "${HUSKY_SKIP_HOOKS}" = "true" ] || [ "${HUSKY_SKIP_HOOKS}" = "1" ]; then
  debug "HUSKY_SKIP_HOOKS is set to ${HUSKY_SKIP_HOOKS}, skipping hook"
  exit 0
fi

if [ "${HUSKY_USE_YARN}" = "true" ] || [ "${HUSKY_USE_YARN}" = "1" ]; then
  debug "calling husky through Yarn"
  yarn husky-run $hookName "$gitParams"
else

  if [ -f "$scriptPath" ]; then
    # if [ -t 1 ]; then
    #   exec < /dev/tty
    # fi
    if [ -f ~/.huskyrc ]; then
      debug "source ~/.huskyrc"
      . ~/.huskyrc
    fi
    node "$scriptPath" $hookName "$gitParams"
  else
    echo "Can't find Husky, skipping $hookName hook"
    echo "You can reinstall it using 'npm install husky --save-dev' or delete this hook"
  fi
fi

node "$scriptPath" $hookName "$gitParams"主要是这么一行可以看出来是使用node运行命令如下:

$ cat node_modules/husky/run.js
// run.js
/* eslint-disable @typescript-eslint/no-var-requires */
const pleaseUpgradeNode = require('please-upgrade-node')
const pkg = require('./package.json')

// Node version isn't supported, skip
pleaseUpgradeNode(pkg, {
  message(requiredVersion) {
    return 'Husky requires Node ' + requiredVersion + ", can't run Git hook."
  }
})

// Node version is supported, continue
require('./lib/runner/bin')

反正知道就是运行咱们下面配置的指令就好了,如果想知道他说怎么运行的可以再往源码看下

那么这些钩子就是咱们可以使用得了,那么husky可以看出来干了什么嘛,他就是让你使用本地git钩子变得很容易。使用如下package.js添加如下(上面不带.sample得都是咱们可以直接用的钩子自己按需要选择哈):

{
  "name": "githooks",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "husky": "3.0.0"
  },
  "husky": {  // 上面不带.sample得都是咱们可以直接用的钩子自己按需要选择哈
    "hooks": {
      "pre-commit": "指令",
      "commit-msg": "指令",
      "pre-push": "指令"
    }
  }
}

我经常用的就就上面几种钩子去拦截:

  • pre-commit: 提交commit信息前可以去校验提交得相关代码是否符合规范。
  • commit-msg: 提交得commit信息可以做一些对commit做一部分规范校验。
  • pre-push: git push前得相关校验可以校验你提交得分支、tags是否符合规范。

相关规范拦截

如何定制化拦截规范呢?接下面一步步带你定制相关规范

  • 代码规范拦截
  • commit信息规范拦截
  • 分支、tags规范拦截

代码规范定义

代码规范拦截使用lint-staged如下:

yarn add lint-staged -D

package.json现在是如下:

{
 "lint-staged": {
    "*.{js,jsx,vue}": [
      "eslint --fix --ext .js,.jsx,.vue,.ts,.tsx"
    ]
  },
 "husky": {
    "hooks": {
      "pre-commit": "lint-staged", // commit 之前的钩子
      "commit-msg": "XXXXXXX", // commit 时的钩子
      "pre-push": "XXXXXXX" // push 之前的钩子
    }
  }
}

如果发现你得代码有eslint未修复的就会报错误如下图**(必须修复方可提交,如果遇到无法修复切必须要提交的可以再后缀加个--no-ignore 或者简写 -n git commit -m 'feat: XXXXX' -n,还可以配置.eslintignore忽略掉)**:

定义commit提交信息规范

commit信息规范咱们准寻如下也是大家通用的commit规范如下:

类型描述
revert回复
feat提交新特性代码
fix修复bug
docs编写文档
style修改样式
refactor代码重构
perf性能优化
test测试用例
workflow工作流
ci持续集成
chore构建过程的变化
build构建打包

例如:

  • git commit -m 'feat: 我完成了某个新模块的XXX开发'
  • git commit -m 'fix: 我修复了某个问题'
  • git commit -m 'style: 我修改了某块css'
  • git commit -m 'refactor: XX模块部分代码重构'
  • git commit -m 'test: XXX模块单元测试'
  • ...

规范咱们约束好了那么如何制定拦截呢?package.json更改如下:

{
 "lint-staged": {
    "*.{js,jsx,vue}": [
      "eslint --fix --ext .js,.jsx,.vue,.ts,.tsx"
    ]
  },
 "husky": {
    "hooks": {
      "pre-commit": "lint-staged", // commit 之前的钩子
      "commit-msg": "node scripts/verify-commit-msg.js", // commit 时的钩子
      "pre-push": "XXXXXXX" // push 之前的钩子
    }
  }
}

使用node执行了verify-commit-msg.js脚本内容如下(Vue官方verifyCommit.js链接):

const msgPath = process.env.HUSKY_GIT_PARAMS; //使用环境变量获取到commit message
const msg = require('fs').readFileSync(msgPath, 'utf-8').trim();

const commitRE = /^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build)(\(.+\))?: .{1,50}/;

if (!commitRE.test(msg)) {
  // 下面是一些错误日志提醒
  console.log('提交信息不符合规范格式!\n');
  console.log('格式为:[类型]: [描述]\n');
  console.warning('\n  feat: 完成详情页面布局\n  fix: 修复刷新时间不准确的问题\n  docs: 某某文档编写\n');
  process.exit(1); // 如果不规范退出运行进程
}

分支、tags规范拦截

下面也是使用拦截commit信息差不多的手段去拦截分支、tags相关规范的具体如下:

{
 "lint-staged": {
    "*.{js,jsx,vue}": [
      "eslint --fix --ext .js,.jsx,.vue,.ts,.tsx"
    ]
  },
 "husky": {
    "hooks": {
      "pre-commit": "lint-staged", // commit 之前的钩子
      "commit-msg": "node scripts/verify-commit-msg.js", // commit 时的钩子
      "pre-push": "node scripts/verify-git-branch/index.js" // push 之前的钩子
    }
  }
}

使用node执行verify-git-branch相关脚本关键代码如下:

const params  = process.env.HUSKY_GIT_STDIN; // 使用环境变量能获取到一些分子、tags信息如下面注释:
// //params: refs/heads/test1 7fe5f9d5ae41 refs/heads/test1 00000000000000000000000

那么咱们做的就是截取到需要的信息如下:

const params  = process.env.HUSKY_GIT_STDIN; // 使用环境变量能获取到一些分子、tags信息如下面注释:
// //params: refs/heads/test1 7fe5f9d5ae41 refs/heads/test1 00000000000000000000000

const list = params.trim().split('\n'); // 去掉前后的空格截取

const branchList = list.reduce((result, item) => {
  result.push(item.split(' ')[2]); // 继续截取
  return result;
}, []); 

console.log(branchList, 'branchList'); // 会得到['refs/heads/test1']

拿到了branchList后就需要会得到['refs/heads/test1']这个一个数组,其中heads代表是分支,test1是分支名。如果是tags的话你能得到['refs/tags/v1.0.0']的数组,其中tags代表是标签,test1是标签名。

其实看到这里应该很能明白接下来要怎么做了吧如下:

let errorMessage = []; //错误收集


const GIT_TYPE = {
  BRANCH: 'heads',
  TAGS: 'tags',
};

const patterns = {
  branchPattern: '^(master|dev){1}$|^(feature|hotfix|release|bugfix)\\/.+$', // 这个是以mester或者dev 再或者feature|hotfix|release|bugfix)开头
  // 比如:feature/XXX模块
  // 比如:bugfix/XXX
  // 比如:hotfix/XXX
  // 比如:release/XXX
  // 经常用的就这些了
  /***
   * 1. **Feature Branch(功能分支)**:Feature分支用于开发新功能或增加新的功能模块。这些分支通常从主分支(如master或develop)上创建,并在特性完成后合并回主分支。

    2. **Bugfix Branch(修复分支)**:Bugfix分支用于修复已知的问题或缺陷。当在主分支上发现错误时,可以从主分支上创建一个修复分支,并在修复完成后将其合并回主分支。

    3. **Hotfix Branch(热修复分支)**:Hotfix分支类似于Bugfix分支,但它们用于紧急修复生产环境中的严重错误或问题。热修复分支通常从与生产环境匹配的标记或版本上创建,并在修复完成后合并到主分支和其他相关分支。

    4. **Release Branch(发布分支)**:Release分支用于准备发布版本。在软件开发周期的最后阶段,通常会从开发分支(如develop)上创建一个发布分支,并在版本稳定和经过测试后进行发布。在发布之后,发布分支通常会合并回主分支来包含最新的更改。
   * 
   */
  tagPattern: '^v\\d+\\.\\d+\\.\\d+.*',
  // 已v开头比如: v1.0.0.alpah.0
  // 已v开头比如: v1.0.0.bate.0
  // 已v开头比如: v1.0.0.rc.0
  // 已v开头比如: v1.0.0
};

const validate = (name, pattern) => {
  const regExp = new RegExp(pattern, 'g');
  return regExp.test(name);
}

branchList.forEach((branch) => {
  const nameRefs = branch.split('\/'); // 截取得到['refs', 'heads', 'test']
  const type = nameRefs[1];
  const name = nameRefs.slice(2).join('\/');

  if (type === GIT_TYPE.BRANCH) {
    const branchResult = validate(name, patterns.branchPattern);
    if(!branchResult) {
      errorMessage.push({ type, name });
    }
  } 
  
  if (type === GIT_TYPE.TAGS) {
    const tagResult = validate(name, patterns.tagPattern);
    if(!tagResult) {
      errorMessage.push({ type, name });
    }
  }
});

if (errorMessage.length) {
  const GIT_TYPE_MAP = {
   [GIT_TYPE.BRANCH]: 'branch',
   [GIT_TYPE.TAGS]: 'tag'
  }

  const formatMessage = errorMessage.map((item) => `${GIT_TYPE_MAP[item.type]}${item.name})命名格式错误`).join('\n');
  console.log(`
   '[ERROR]: 提交的branch/tag不符合规范格式!',
    '\n',
    'branch格式:[feature|hotfix|release|bugfix]/[描述]',
    'tag格式:v[major].[minor].[patch][额外版本信息]',
    '\n',
    '例如:',
    "branch:feature/login",
    "tag:v1.0.0",
    '\n'
    提交错误信息如下:
    ${formatMessage}
  `)
  process.exit(1); // 退出进程
}

通过以上方法就可以做一些相关规范拦截了,代码也很简单、清晰但是呢要是每个项目里面都有一个感觉有些不太友好。那么咱们做些什么优化呢?

优化拦截配置

使用commander封装成一个命令去运行相关的脚本,把相关的脚本放在这个commander封装的插件中项目结构如下:

关于commander相关配置可以去官方看下就是制作自定义指令的,经常用在制作CLI等地方,使用也是非常简单,大致功能如下:

  1. 定义命令和选项:通过commander,你可以定义和注册多个命令和选项,指定它们的名称、别名、描述等信息。

  2. 解析命令行参数和选项commander会解析用户在命令行中输入的参数和选项,并将它们与你定义的命令和选项进行匹配。

  3. 处理命令和选项逻辑:一旦解析了命令行参数,你可以使用commander来处理不同命令和选项的逻辑。你可以为每个命令和选项定义相应的回调函数,以执行特定的操作或触发相应的业务逻辑。

  4. 生成帮助信息commander可以自动生成帮助文档,包括已定义的命令、选项以及它们的描述、用法示例等信息。这样用户可以通过--help选项或无效命令时获取帮助。

|bin
|commands 
  -- githooks.js
|scripts
  --verify-git-branch
  --verify-git-branch
|package.json
...

bin中就是定义的一些命令如下:

#!/usr/bin/env node
const commander = require('commander');
const program = new commander.Command();

async function main() {
  program
    .version(require('../package.json').version)
    .usage('<command> [options] ');
  
  program
    .command('githooks <name>')
    .description('githooks一些规范拦截')
    .action((...args) => {
      require('../commands/githooks')(...args); // 加载githooks相关拦截脚本
  });
  await program.parseAsync(process.argv);
}

main().catch((e) => { // 错误日志要暴漏出来
  error(e);
  process.exit(1); // 退出进程
});

githooks.js如下:

const path = require('path');
const spawn = require('./spawn.js');

const GIT_HOOKS_MAP = {
  PRE_COMMIT: 'pre-commit',
  COMMIT_MSG: 'commit-msg',
  PRE_PUSH: 'pre-push',
}

const reslove = (dir) => path.join(__dirname, '..', dir);

module.exports = async (name) => {
  try {
    switch (name) {
      case GIT_HOOKS_MAP.PRE_COMMIT:
         await spawn(
          'lint-staged',
          [],
          { stdio: 'inherit', shell: true },
        );
        break;
      case GIT_HOOKS_MAP.COMMIT_MSG:
        await spawn(
          'node',
          [resolve('/scripts/verify-commit-msg'), 'HUSKY_GIT_PARAMS'], // 把环境变量名带上
          { stdio: 'inherit', shell: true },
        );
        break;
      case GIT_HOOKS_MAP.PRE_PUSH:
        await spawn(
          'node',
          [resolve('/scripts/verify-git-branch'), 'HUSKY_GIT_STDIN'],// 把环境变量名带上
          { stdio: 'inherit', shell: true },
        );
        break;
      default:
        error(`未知的错误: ${name}`);
        process.exit(1);
    }
  } catch (e) {
    process.exit(1);
  }
};

child_process.spawn()是Node.js中一个用于创建子进程的方法,它可以执行外部命令并获得其输出。spawn()方法接受三个个参数:要执行的命令和命令的参数(可选),还有一些配置。

const { spawn } = require('child_process');

module.exports = (shell, args, options = {}) => new Promise((resolve, reject) => {
  const sh = spawn(shell, args, { shell: true, stdio: 'inherit', ...options });

  sh.on('exit', (code) => {
    if (code === 0) {
      resolve();
    } else {
      reject(new Error(code));
    }
  });

  sh.on('error', (err) => {
    reject(err);
  });
});

package.json如下:

{
  "name": "git-scripts-test",
  "bin": {
    "git-scripts-test": "bin/index.js"
  },
  "version": "1.0.0.bate.1",
  //...
}

然后再发布到npm就可以下载到项目里面如下:

yarn add git-scripts-test --dev

使用git-scripts-test如下更改

回到咱们上面的项目里面package.json如下更改,不用再添加什么脚本文件了都在咱们封装的git-scripts-test包里面了:

{
  "name": "githooks",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "husky": "3.0.0",
    "git-scripts-test": "^1.0.0.bate.1",
  },
  "lint-staged": {
    "*.{js,jsx,vue}": [
      "eslint --fix --ext .js,.jsx,.vue,.ts,.tsx"
    ]
  },
  "husky": {  
    "hooks": {
      "pre-commit": "git-scripts-test githooks pre-commit",
      "commit-msg": "git-scripts-test githooks commit-msg",
      "pre-push": "git-scripts-test githooks pre-push"
    }
  }
}

利用新版本的husky再优化程序

其实使用上面发包自定义指令的方式使用的多了,会发现也挺费劲的每次package.json里面都要配置一个信息。那么husky经过迭代升级也是考虑到这个问题了,V4+以上版本都支持set、add俩api了其实是可以做到不用再package.json配置了的。

husky api源码地址 整个api的还是比较清晰的代码不是很多有相关需求的可以去看下。

使用如下:

npm install husky --save-dev
npx husky install
npm pkg set scripts.prepare="husky install" # 要在安装后自动启用Git钩子,请编辑package.json
{
  "scripts": {
    "prepare": "husky install" 
  }
}

创建一个新的hooks可以使用如下:

npx husky add .husky/pre-commit "npm test"

这个时候可以看下cat .husky/pre-commit了如下:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm test

到这里应该就能想到怎么使用升级版本的husky了吧。再封装插件的时候可以利用script的postinstall钩子,再安装的该插件的时候可以执行些命令"postinstall": "node initHusky.js"

// initHusky.js
const husky = require('husky');
const hooksPath = path.resolve('./node_modules/.husky/');

const GIT_HOOKS_MAP = {
  PRE_COMMIT: 'pre-commit',
  COMMIT_MSG: 'commit-msg',
  PRE_PUSH: 'pre-push',
};
husky.install(hooksPath);
husky.set(path.join(hooksPath, GIT_HOOKS_MAP.PRE_COMMIT), 'XXXXX自定义命令'); //
husky.set(path.join(hooksPath, GIT_HOOKS_MAP.COMMIT_MSG), 'XXXXX自定义命令'); //
husky.set(path.join(hooksPath, GIT_HOOKS_MAP.PRE_PUSH), 'XXXXX自定义命令'); //

具体还没经过实践,比如怎么获取到commit信息,怎么获取到分支、tag版本号等相关还没相关思路。

结语

相信经过学习husky你得项目里也能应用起来相关规范拦截了。 如果对您有帮助欢迎点赞收藏

转载自:https://juejin.cn/post/7261600077914947643
评论
请登录