什么是 Git Hook

Git Hook 是能在 Git 操作的特定重要动作发生时触发自定义脚本,也被称为 “钩子”,这样的脚本被存储在 .git/hooks 目录中,脚本分为客户端的和服务端两种,这些钩子文件的后缀名默认为 .sample,其存在的目的就是为了让这些脚本默认不被执行,如果需要其被执行则去掉后缀名,可以通过项目需求制定钩子的功能和程序编写。

实现 Hook 功能介绍

本次将使用 Node.js 实现一个 Git Hook,功能为在提交代码之前检测功能如下:

  • 检测是否为 Git 项目;
  • 检测邮箱是否符合规格;
  • 检测代码是否含有冲突;
  • 自动执行 Eslint,并检测问题。

需求的由来

在开始代码的编写之前,一定要清楚,团队开发时为什么需要这样的 hook,下面列举的场景可能都会对团队项目持续集成的历史 “树” 造成污染,或在协同开发时对团队成员造成麻烦。

邮箱错误:

当团队 Gitlab 仓库对邮箱格式进行了严格的限制,必须为公司邮箱才可以进行推送,很可能邮箱配置错误进行了提交,而推代码到远端时发现邮箱错了,要对本地的 commit 记录修正,再重新推到远端。

容易造成邮箱设置错误的常见原因:

  • 新入职员工刚刚领了新的笔记本或老员工电脑重做系统;
  • 维护不同团队的开源项目太多,不同项目需要配置不同的邮箱,很可能导致邮箱配置错误;
  • 当团队中有外包开发人员,且由于权限问题同一套代码是存放在两个仓库,正式员工需要在本地项目中通过 remote 来同时指定两个仓库地址,并在本地代码修改后拉取外包仓库的代码进行合并,同时同步到正式仓库和外包仓库,如果正式仓库对推送过来的提交邮箱格式进行了严格的限制,并且外包提交记录的邮箱错误,就导致正式员工合并后的提交被正式仓库拒绝,如果使用 rebase 强行修正错误的邮箱,变基后的 commit 哈希发生变化可能与远端仓库不一致,需要进行强推到两个仓库,并全员的本地回滚到 rebase 之前的公共 commit 节点。

代码冲突:

开发时和其他人同时修改了相同部分造成冲突,如果冲突不能及时被发现,提交并推送到远端是对远端仓库的污染,也可能其他开发人员正好拉取了这样的代码,会对团队造成麻烦。

容易造成冲突未及时处理的原因:

  • 项目过大,文件较多;
  • 编辑器不智能;
  • 前端项目使用了路由懒加载,不切换到冲突代码所在的路由对组件进行渲染,项目不会报错。

Eslint 检查:

有些团队的项目对代码规范要求高,并为了减小线上 Bug 率,会在项目中集成 Eslint 对代码风格进行检查,通常都是在命令行手动执行检测命令,有些时候可能忘记执行命令进行检测,就将代码进行了提交和推送。

为了规避上面的情况,所以才有了这次关于 Git Hook 的需求,以及下面的代码实现,目的是防患于未然,将大家在开发时容易犯的错误或对项目代码持续集成和管理的潜在风险扼杀在摇篮中。

目录结构及文件简介

  
    git-hooks
      |- default-events.js
      |- default-rules.js
      |- git-checker.js
      |- pre-commit.js
  
  • default-events.js:用来编写默认的检测事件;
  • default-rules.js:用来管理默认检测事件用到的规则(正则);
  • git-checker.js:用来构建 Hook 的核心逻辑;
  • pre-commit.js:用来编写执行检测的调用逻辑。

项目依赖

在编写这个 hook 之前需要用到第三方模块 husky,这个模块的作用是根据项目中 package.json 的配置来向 .git/hooks 中的脚本写入我们的逻辑,项目中需要安装。

$ npm install husky

代码设计思路分析

设计这个 hook 时提供了 Git 目录检测、邮箱验证、冲突检测、和执行 Eslint 的功能,当然我们希望检测函数不是强制的,是可以选择性使用,而使用者也可以编写自己需要的检测函数来覆盖其他的场景。

项目中的 husky 配置如下:

/* 使用 hook 项目的 package.json */
{
  "husky": {
    "hooks": {
      "pre-commit": "node git-hooks/pre-commit"
    }
  }
}

可以看出,husky 帮我们执行了 git-hooks/pre-commit.js 文件。

我们希望使用者的用法如下:

/* ~git-hooks/pre-commit.js */
const GitChecker = require('./git-checker');

const commitChecker = new GitChecker('pre-commit', {
  // default event names
  defaultEventNames: ['isGit', 'email', 'conflict', 'eslint'],
  rules: {
    // your costom rules
  },
  checkEvents: {
    // your custom check events
  }
});

commitChecker.checkStart();

上面的用法通过创建实例来创建 checker,即 “检测者”,调用 checkStart 方法帮助我们检测,创建实例的参数为 options,类型为对象。

上面的用法既可以让用户通过配置 optionsdefaultEventNames 属性来选择性的使用默认的检测函数,又可以通过 checkEvents 属性来让使用者编写检测函数。

rules 属性是来存放使用者编写检测函数时使用的正则,会和默认检测函数中的正则合并,我们专门用 default-rules.js 文件来管理默认检测函数中使用的正则。

/* ~git-hooks/default-rules.js */
module.exports = {
  emailCheck: /\S+((@youemail\.com)|(@enterprise\.com))(\n|\r\n)*$/,
  conflictCheck: '^<<<<<<<\\s|^=======$|^>>>>>>>\\s'
};

GitChecker 类的实现

我们需要一个工厂创造 “检测者”,取名为 GitChecker,在 GitChecker 中需要使用发布订阅模式,对检测函数进行注册,并在执行实例的 checkStart 方法时依次执行,代码如下:

/* ~git-hooks/git-checker.js */
// 引入依赖
const EventEmitter = require('events');
const exec = require('child_process').execSync;
const chalk = require('chalk');
const defaultRules = require('./default-rules');
const defaultEvents = require('./default-events');

const { log } = console;

// 创建 GitChecker 类并继承 EventEmitter,目的是继承 on 和 emit
class GitChecker extends EventEmitter {
  constructor(type, options) {
    super();

    // 防止使用者 options 内部属性传错,进行初始化
    const {
      rules = {},
      defaultEventNames = [],
      checkEvents = {}
    } = options;

    // 合并默认检测函数使用的正则和用户自定义检测函数使用的正则统一管理
    this.rules = Object.assign(defaultRules, rules);

    // 合并用户选择使用的默认检测函数和自定义检测函数
    this.checkEvents = Object.assign(this.getDefaultEvents(defaultEventNames), checkEvents);
    this.type = type; // git 操作类型
    this.isCommit = true; // 当前是否可以被提交
    this.gitConfigEnvs = ['local', 'global', 'system']; // 取邮箱时的环境

    // 将提交状态更改为禁止,绑定 this 是为了防止在检测函数内解构更改指向
    this.forbiddenCommit = this.forbiddenCommit.bind(this);
    this.init(); // 初始化
  }

  init() {
    // 将检测函数常用方法挂载到实例上
    this.log = log;
    this.exec = exec;
    this.chalk = chalk;

    // 注册当前类型 git 操作对应的检测函数
    this.register(this.type);
  }

  getDefaultEvents(eventsNames) {
    return eventsNames.reduce((memo, eventName) => {
      memo[eventName + 'CheckTask'] = defaultEvents[eventName + 'CheckTask'];
      return memo;
    }, {});
  }

  register(type) {
    Object.keys(this.checkEvents).forEach((event) => {
      // 订阅事件,每一个函数传入当前实例,方便取实例上的属性和方法
      this.on(type, () => this.checkEvents[event](this));
    });
  }

  forbiddenCommit() {
    this.isCommit = false;
  }

  async checkStart() {
    log(chalk.green('开始代码检测'));

    // 发布执行检测函数
    await this.emit(this.type);

    // 结束后结束当前 git 操作进程
    this.checkEnd();
  }

  checkEnd() {
    // 如果当前状态为不可提交,则退出进程号不为 0(git 规定)
    if (!this.isCommit) process.exit(1);
    log(chalk.green('检测通过'));
    process.exit(0);
  }
}

module.exports = GitChecker;

在上面的设计中之所以将一些常用方法都挂载在了实例上,目的是为了让使用者编写自定义检测函数时不再需要引入依赖和更方便的获取实例上的属性、方法,当然也方便了我自己编写默认检测函数。

默认检测函数的实现

由于检测工厂 GitChecker 已经将自己创建的 “检测者” 塞入了检测函数的参数中去,那就可以把所有的默认检测函数放入一个 default-events.js 文件中统一管理。

检测目录是否被 Git 管理

/* ~git-hooks/default-events.js */
exports.isGitCheckTask = ({ exec, log, chalk, forbiddenCommit }) => {
  // 执行 git 命令,如果跑出异常证明不是一个 git 管理的项目
  try {
    exec('git status');
  } catch (e) {
    log(chalk.red('错误:当前不是一个git项目目录'));
    forbiddenCommit(); // 更改提交状态太为不能提交
  }
};

检测是否为一个 Git 所管理的项目只需执行 git status 来检测一下文件变化,如果抛出异常则说明不被 Git 所管理。

检测邮箱是否合规

上一个方法使用了从参数解构的方式获取实例属性和方法,为了更便于理解这个方法正常使用参数。

/* ~git-hooks/default-events.js */
exports.emailCheckTask = (checker) => {
  const checkEmailEnvs = (i) => {
    // 取出正则和获取 git 邮箱的环境参数集合
    const gitConfigEnvs = checker.gitConfigEnvs;
    const rules = checker.rules;

    // 获取邮箱的 git 命令
    const command = 'git config --' + gitConfigEnvs[i] + ' user.email';

    // 如果获取邮箱成功,则校验邮箱是否合规
    try {
      const userEmail = checker.exec(command).toString();
      const isValidate = rules.emailCheck.test(userEmail);

      if (!isValidate) {
        checker.log(checker.chalk.red('错误:请使用正确的邮箱提交代码'));
        checker.log(checker.chalk.yellow('你当前的邮箱是:' + userEmail));
        checker.forbiddenCommit();
      } else {
        checker.log(checker.chalk.green('邮箱校验通过'));
      }
    } catch (e) {
      if (i === gitConfigEnvs.length) {
        checker.log(checker.chalk.red('错误:请设置git的提交邮箱'));
        checker.forbiddenCommit();
      } else {
        checkEmailEnvs(i + 1);
      }
    }
  };
  checkEmailEnvs(0);
};

Git 中有三个参数设置邮箱,分别 --local--global--system,分别对应项目、用户和系统三个环境,顺序即为获取优先级,所以获取也是如此。

该方法使用了递归的思想实现,从优先级最高的环境开始获取邮箱,如果取到邮箱则进行验证,没取到则选择优先级次之的环境获取,直到取到邮箱为止,若都取不到则提示用户设置邮箱,如果取到邮箱,校验不通过则提示用户当前邮箱,并提醒用户设置正确的邮箱。

检测冲突

/* ~git-hooks/default-events.js */
exports.conflictCheckTask = (checker) => {
  // 对文件进行正则匹配的 git 命令
  const command = 'git grep -n -P -E "' + rules.conflictCheck + '"';

  // 如果没有成功匹配,则抛出异常,成功匹配打印冲突代码
  try {
    const conflicts = checker.exec(command, { encoding: 'utf-8' });
    if (conflicts) {
      checker.log(checker.chalk.red('错误:发现冲突,请解决后再提交'));
      checker.log(checker.chalk.red('错误代码:'));
      checker.log(checker.chalk.red(conflicts.trim()));
      checker.forbiddenCommit();
    }
  } catch (e) {
    checker.log(checker.chalk.green('未发现冲突'));
  }
};

在上面的 Git 命令中,-n 为显示匹配文件的行号,因为 shell 的正则支持不全,-P-E 是为了支持正则扩展,保证正则生效。

执行 Eslint

/* ~git-hooks/default-events.js */
exports.eslintCheckTask = ({ exec, log, chalk, forbiddenCommit }) => {
  try {
    exec('lint-staged');
    log(chalk.green('Eslint 校验通过'));
  } catch (e) {
    log(chalk.red('错误:Eslint 校验不通过'));
    forbiddenCommit();
  }
};

Eslint 本身具备检测冲突的功能,检测冲突的函数更适用于没有集成 Eslint 的项目,如果项目已经集成了 Eslint 可以不适用检测冲突函数。

关于扩展

当需求变更,需要在 push 之前执行某些脚本应该怎么办,可以在 git-hooks 文件夹增加一个 pre-push.js 文件,文件内容如下。

/* ~git-hooks/pre-push.js */
const GitChecker = require('./git-checker');

const pushChecker = new GitChecker('pre-push', {
  defaultEventNames: ['isGit', 'email'], // default event names
  rules: {
    // your costom rules
  },
  checkEvents: {
    myHook: ({ log, chalk, forbiddenCommit }) => {
      log(chalk.red('check prev push'));
      forbiddenCommit();
    }
  }
});

commitChecker.checkStart();

由于我们的 hook 依赖于 husky,所以项目 package.json 中的 husky 也有所修改如下:

/* 使用 hook 项目的 package.json */
{
  "husky": {
    "hooks": {
      "pre-commit": "node git-hooks/pre-commit",
      "pre-push": "node git-hooks/pre-push"
    }
  }
}

最后

以上就是本次 Git Hook 的使用场景和实现,也希望通过本文,能让大家对 Git Hook 的相关知识有一定了解,另附赠 Github 地址 git-hooks