Git Hooks & Husky

众所周知,javascript一开始就是一种非常灵活的语言,随着新特性不断增加,框架层出不穷,加之个人的编程风格。前端代码的可维护性问题越来越突出。做一个有工程素养的前端的开发,我们要注重编码规范。做为架构或者团队的leader,更应该考虑编码规范的约束机制。所以做好代码风格检查(Code Linting,简称 Lint)已经成为前端团队必修课,是保障代码规范一致性的重要手段。 做好link可以有效的减少bug,提升开发效率和代码的可读性。其中,提交前lint更有效率。 如何着手呢?自然是从我们的代码管理工具。

Git hooks

现在最流行的版本管理工具非Git莫属,Git本身也增加了一些hooks(钩子),在git命令前置执行来阻止一些不规范的操作。

$ cd .git/hooks
$ ls -a
  • git hooks是在.git/hooks目录下的一些脚本文件,用于控制git工作的流程。
  • 内置的脚本示例都是shell脚本,其中一些还混杂了Perl代码,不过,任何正确命名的可执行脚本都可以正常使用 —— 你可以用RubyPython,当然我们前端也可以用Node来编写。
  • 钩子分为客户端钩子服务端钩子
  • 客户端钩子:pre-commit、prepare-commit-msg、commit-msg、post-commit等,主要用于控制客户端git的提交工作流。
  • 服务端钩子:pre-receive、post-receive、update,主要在服务端接收提交对象时、推送到服务器之前调用。
  • 钩子都是以.sample结尾的文件名。注意这些示例脚本是不会执行的,只有重命名去掉.sample后才会生效。

但是直接使用git hooks不方便在团队内推广。需要有工具自动把脚本安装到每个人的本地项目上才能生效。所以我们需要借助一些其他工具库。

Husky

前端常用的git hooks工具有 pre-commitHusky。这里我只介绍 Husky(更全面一些)。

原理

husky利用 git hooks会在相关命令执行前执行的特性,取而代之

#!/bin/sh
# husky
export HUSKY_GIT_PARAMS="$*"
node_modules/run-node/run-node ./node_modules/husky/lib/runner/bin `basename "$0"`
...
  • husky 使用了自定义的安装过程:node lib/installer/bin install(在node_modules/husky/package.json里)。执行的时会在项目的.git/hooks 目录生成所有 hook 的脚本(你自定义的hook脚本,husky不会覆盖)。

  • 每个hook脚本都是一样的, 关键的部分是 bashname "$0",这样可以拿到当前的 hook名,如pre-commitpre-push

  • 最后根据package.json的配置,执行我们定义的,相对应的hook脚本(我们可以用node写)。

安装

npm install husky --save-dev
// 或者
yarn add husky --dev

配置

//package.json文件
"husky": {
  "hooks": {
    "pre-commit": "eslint",
    "commit-msg": "node preCommit.js"  // 可以集成到自己框架的的cli中,比如:luna preCommit
  }
}
  • 当你git commit的时候,会触发hook(pre-commit)husky将会执行对应配置(pre-commit)里的eslint命令,没有问题才提交。
  • 当你git commit的时候,会触发hook (commit-msg)husky将会执行对应配置(commit-msg)里的preCommit.js脚本,没有问题才提交。

实际应用场景一:Commit message格式校验

上面配置中的preCommit.js文件是按公司要求,校验提交的message必须符合规定格式的脚本,代码如下:

/*
* 功能: git commit时,自动验证提交信息是否符合规范
* 提交规范: 范式 {ir_key}:  {subject_content}.例如:"STY-ABCD-TY-76379:某个功能开发"
* 主要是读取 .git/COMMIT_EDITMSG 这个文件,文件记录了当前commit之后的信息
*/
const fs = require('fs');
const chalk = require('chalk');

const warning = chalk.keyword('red');
const msg = chalk.keyword('yellow');
// const link = chalk.hex('#00bfff');

const pattern = /^((STY|DTK)-ABCD-TY-)\d{5}:[^]/;
const commitMsg = fs.readFileSync(process.env.HUSKY_GIT_PARAMS, 'utf-8').trim();

if (!pattern.test(commitMsg.toUpperCase())) {
  console.log(msg(`\nYour commit message: ${commitMsg}\n`));
  console.log(warning('-----------------------Git提交Message不符合规范------------------------------\n'));
  console.log(msg('范式:{ir_key}: {subject_content}。“ir_key”不区分大小写,“冒号”必须半角英文\n'));
  console.log(msg('示例1:DTK-ABCD-TY-76379: 某个bug fix\n'));
  console.log(msg('示例2:STY-ABCD-TY-76379: 某个功能开发\n'));
  console.log(warning('-----------------------------------------------------------------------------\n'));
  process.exit(1);
}
process.exit(0);

实际应用场景二:Commit msg自动格式化

我们公司commit msg要求范式:{ir_key}: {subject_content}。即具体信息前面要加ir_key:(需求号)。那我们如何自动在msg前面添加{ir_key}:。只需解决两个问题:

  • 如何修改commit msg
  • ir_key从哪里读取

解决思路:

  • 从场景一代码了解到,当前commitmsg是通过git/COMMIT_EDITMSG 这个文件获取。那我们可以通过hooks修改这个文件的内容,便可以修改msg
  • 同样,我们可以参考以上git的策略,我们只要把ir_key存在一个本地文件中,commit的时候读取即可。

具体方案:

我在项目的公共工具库项目新增了两个cli命令。

  • irk <ir_key>: 独立使用。后面带参数,且参数符合msg规范,则存入本地数据文件.git/msg(这样不会提交到远程,也无需再加.gitignore);无参数,则读取.git/msg,打印当前分支ir_key存储情况。数据文件中ir_key是按分支存储的。
  • commit-msg-init: 配合hookcommit-msg使用。首先校验当前msg是否合规,如果不合规,且存在当前分支ir_key缓存数据,则在当前msg前拼接ir_key, 存入git/COMMIT_EDITMSG文件。
    使用效果如下:

irk核心代码如下:

  try {
    // 获取当前分支号
    const branchName = getBranchName()
    // 读取本地数据缓存文件: .git/msg
    let dataObj = getFileObj(tempFilePath);
    // ir_key 需符合格式
    const pattern = /^((STY|DTK|BUG)-ABCD-(TY|GJ)-)\d{5}/;
    // 读取参数
    const param = process.argv[2];
    // 有参数set,无参数get
    if ( param ) {    
      if (!pattern.test(param.toUpperCase())) {
        // 不符合格式报错
        console.log(warning('Wrong irk, Need to match:/^((STY|DTK|BUG)-ABCD-(TY|GJ)-)\d{5}/\n'))
      } else {
        // 写入或复写
        dataObj[branchName] = param;
        setIrk(dataObj)
      }
    } else {
      //读取显示
      if (dataObj[branchName]) {
        console.log(msg(`irk: ${dataObj[branchName]} \n`))
      } else {
        console.log(msg("No irk for current branch"))
      }
    }
  } catch (err) {
    console.log(warning('Process err: ' + err))
  }

commit-msg-init核心代码如下:

const fs = require('fs');
const chalk = require('chalk');
const { getBranchName, getFileObj, printMsgRuleLog } = require('../utils/utils')
const { tempFilePath } = require('../config/config')
// msg格式
const pattern = /^((STY|DTK|BUG)-ABCD-(TY|GJ)-)\d{5}:[^]/;
// 当前commit msg
const commitMsg = fs.readFileSync(process.env.HUSKY_GIT_PARAMS, 'utf-8');
// 获取当前分支号
const branchName = getBranchName()
// 如果当前msg不符合规范
if (!pattern.test(commitMsg.toUpperCase())) {
  // 读取本地数据缓存文件: .git/msg
  const fileObj = getFileObj(tempFilePath);
  // 如果文件有数据,并且存在当前分支的数据
  if ( fileObj && fileObj[branchName] ) {
    // 拼接
    const newCommitMsg = `${fileObj[branchName]}:${commitMsg}`
    // 保险起见,二级校验拼接好的msg
    if (pattern.test(newCommitMsg.toUpperCase())){
      // 新msg写入文件
      fs.writeFileSync(process.env.HUSKY_GIT_PARAMS,newCommitMsg)
      console.log(chalk.green(`\n Formatted Message:${newCommitMsg}\n`))
      process.exit(0);
    } else {
      //打印规范日志
      printMsgRuleLog(commitMsg); 
      process.exit(1);
    }
  } else {
    printMsgRuleLog(commitMsg);
    process.exit(1);
  }
} 
process.exit(0);

实际应用场景三:Commit 文件校验

比如限制某些类型文件,某个特定目录的文件不允许修改,删除。

以下代码来自:https://github.com/y8n/git-hooks-node/blob/master/xgfe-ma/pre-commit.js#L45-L73

var child_process = require('child_process');
var execSync = child_process.execSync;
var spawnSync = child_process.spawnSync;
var path = require('path');

var files = getDiffFiles();
if (!files.length) {
    quit();
}
var libFiles = files.filter(function (file) {
    return isLibFiles(file.subpath) && ~['d', 'm', 'c', 'r'].indexOf(file.status);
});
if (libFiles.length) {
    console.log('[WARNING] You cannot delete/modify/copy/rename any file in lib directory!!\n' +
        'Listed below are thus files:');
    var libFilePaths = libFiles.map(function (file) {
        return file.subpath;
    }).join('\n');
    console.log(libFilePaths + '\n');
    quit(1);
}
// 待检查的文件相对路径
var lintFiles = files.filter(function (file) {
    return !isLibFiles(file.subpath)
        && !isDistFiles(file.subpath)
        && ~['a', 'm', 'c', 'r'].indexOf(file.status);
}).map(function (file) {
    return file.subpath;
});
if (!lintFiles.length) {
    quit();
}

var argv = ['lint'];
argv = argv.concat(lintFiles);
argv = argv.concat(['-c', './.lintrc']);
var result = spawnSync('xg', argv, {stdio: 'inherit'});
quit(result.status);

/**
 * 获取所有变动的文件,包括增(A)删(D)改(M)重命名(R)复制(C)等
 * @param [type] {string} - 文件变动类型
 * @returns {Array}
 */
function getDiffFiles(type) {
    var DIFF_COMMAND = 'git diff --cached --name-status HEAD';
    var root = process.cwd();
    var files = execSync(DIFF_COMMAND).toString().split('\n');
    var result = [];
    type = type || 'admrc';
    var types = type.split('').map(function (t) {
        return t.toLowerCase();
    });
    files.forEach(function (file) {
        if (!file) {
            return;
        }
        var temp = file.split(/[\n\t]/);
        var status = temp[0].toLowerCase();
        var filepath = root + '/' + temp[1];
        var extName = path.extname(filepath).slice(1);

        if (types.length && ~types.indexOf(status)) {
            result.push({
                status: status, // 文件变更状态-AMDRC
                path: filepath, // 文件绝对路径
                subpath: temp[1], // 文件相对路径
                extName: extName // 文件后缀名
            });
        }
    });
    return result;
}
/**
 * 是否是lib目录下的文件
 */
function isLibFiles(subpath) {
    return subpath.match(/^src\/lib\/.*/i);
}
/**
 * 是否是dist目录下的文件
 */
function isDistFiles(subpath) {
    return subpath.match(/^dist\/.*/i);
}
/**
 * 退出
 * @param errorCode
 */
function quit(errorCode) {
    if (errorCode) {
        console.log('Commit aborted.');
    }
    process.exit(errorCode || 0);
}

Eslint

ESLint是一个用来识别 ECMAScript 并且按照规则给出报告的代码检测工具,使用它可以避免低级错误和统一代码的风格。它附带有大量的规则.
运行 eslint --init 之后,.eslintrc 文件会在你的文件夹中自动创建。你只要在文件的rules属性中配置你想要的规则,利用pre-commit钩子触发校验即可。它主要的特点是:

  • 使用 Espree 解析 JavaScript。
  • 使用 AST 去分析代码中的模式。
  • 完全插件化的。每一个规则都是一个插件并且你可以在运行时添加更多的规则。
    eslint中文网

Lint-staged

直接触发eslint进行代码检测有一个问题:引入初期,你只改了文件 A,但是文件 B、C、D …中也有大量错误。你基本上没有时间和勇气去fix所有lint错误。这个时候,很多同学(包括我)选择 git commit -m "fix bug" --no-verify来逃避。只是‘很负责任’的把文件A的错误解决。
如果每次提交只检查本次提交所修改的文件,上面的痛点就解决了。lint-staged的开发者就是基于这个想法,其中 stagedGit里面的概念,指待提交区,使用 git commit -a,或者先 git add 然后 git commit 的时候,你的修改代码都会经过待提交区。
安装依赖:

npm install lint-staged --save-dev
// 或者
yarn add lint-staged --dev

引入Lint-staged之后, 之前的husky配置升级如下:

//package.json文件
"husky": {
  "hooks": {
    "pre-commit": "lint-staged",  // 看这里
    "commit-msg": "node preCommit.js" 
  }
},
"lint-staged": {                  // 和这里
  "src/**/*.js": "eslint"
}

prettier

prettier 是业界主流的代码风格格式化工具。虽然用eslint —fix也可以进行代码格式化,但是eslint已经配置繁多。我们还是用eslint检查代码,用prettier来格式化代码。术业有专攻

  • 你可以在vscode安装Prettier- Code formatter插件。默认快捷键是alt + shift + f。安装成功后,编辑器的配置setting.json会出现prettier插件的相关配置节点,同时也能看到一些默认的配置信息。
  • 实际项目中推荐在根目录创建.prettierrc文件配置(比配置在Package.json更独立)来使用,这样配置可以集成到脚手架,保证所有项目规则统一。

常用配置如下:

module.exports = {
  "printWidth": 80, //一行的字符数,如果超过会进行换行,默认为80
  "tabWidth": 2, //一个tab代表几个空格数,默认为80
  "useTabs": false, //是否使用tab进行缩进,默认为false,表示用空格进行缩减
  "singleQuote": false, //字符串是否使用单引号,默认为false,使用双引号
  "semi": true, //行位是否使用分号,默认为true
  "trailingComma": "none", //是否使用尾逗号,有三个可选值"<none|es5|all>"
  "bracketSpacing": true, //对象大括号直接是否有空格,默认为true,效果:{ foo: bar }
  "parser": "babylon" //代码的解析引擎,默认为babylon,与babel相同。
}

更多配置,可以参考官网: prettier 官网

参考资料

Git hooks文档
用 Node.js 写前端自己的 Git-hooks
阮一峰-Node.js 命令行程序开发教程