实现一些简单的git 提交校验

640 阅读5分钟

前言

目前大部分的项目中都会通过husky去做提交的校验(husky 可以防止使用 Git hooks 的一些不好的 commit 或者 push。),本次尝试自己去实现类似于husky的git hooks工具

1.前置知识点

当我们通过npm/cnpm/yarn/pnpm 去运行命令的时候,会去找package.json的script字段中找到对应的命令,如下:

"scripts": {
  "lint": "eslint --fix --ext .js src/",
  "prettier": "prettier --write --parser typescript "src/**/*.ts"",
  "vite": "vite --config build/vite/vite.config.js --force"
},

1.1 npm是如何运行的?

npm 脚本的原理非常简单。每当执行npm run,就会自动新建一个 Shell,在这个 Shell 里面执行指定的脚本命令。因此,只要是 Shell(一般是 Bash)可以运行的命令,就可以写在 npm 脚本里面。

比较特别的是,npm run新建的这个 Shell,会将当前目录的 node_modules/.bin 子目录加入 PATH 变量,执行结束后,再将 PATH 变量恢复原样。

这意味着,当前目录的node_modules/.bin子目录里面的所有脚本,都可以直接用脚本名调用,而不必加上路径。

"lint": "eslint --fix --ext .js src/"
// 不用写成下面这样
"lint": "./node_modules/.bin/eslint --fix --ext .js src/"

npm和yarn cnpm pnpm 等都是node的依赖管理器 他们都只是对 package.json 进行解析而已,npm run xxx的时候,首先会去项目的package.json文件里找scripts 里找对应的xxx,然后执行 xxx的命令(会先在当前 node_modules/.bin下面看有没有同名的可执行文件,如果有,则使用其运行。).bin下面的文件代表的是软连接(如何知道软连接在哪里执行:对应包文件package.json中的bin字段),在我们安装对应包的时候npm会帮我们配置好软连接,这 种软连接相当于一种映射 ,当我们运行对应的npm scripts命令的时候, 就会到 node_modules/bin中找对应的映射文件,然后再找到相应的js文件来执行。

举个栗子:

.bin目录

node_modules/vite 目录

1.2 git hooks 钩子的作用

Git Hooks是定制化的脚本程序,所以它实现的功能与相应的git动作相关,如下几个简单例子:
1.多人开发代码语法、规范强制统一
2.commit message 格式化、是否符合某种规范
3.如果有需要,测试用例的检测
4.服务器代码有新的更新的时候通知所有开发成员
5.代码提交后的项目自动打包(git receive之后) 等等...

更多的功能可以按照生产环境的需求写出来

举个栗子

"scripts": {
    "prebuild":"",
    "build": "",
    "postbuild": ""
  }

相当于

npm run prebuild&&npm run build&&npm run postbuild

因此可以在npm build 命令之前执行一些特殊的操作

钩子的情况

  • applypatch-msg(应用程序消息)
  • pre-applypatch(应用前批处理)
  • post-applypatch(应用程序批处理后)
  • pre-commit(预先提交)
  • pre-merge-commit(合并前提交)
  • prepare-commit-msg(准备提交消息)
  • commit-msg(提交信息)
  • post-commit(提交后)
  • pre-rebase(变基前)
  • post-checkout(结账后)
  • post-merge(合并后)
  • pre-push(预推)
  • ....

如何去使用:

默认情况下,hooks目录是$GIT_DIR/hooks,但是可以通过core.hooksPath配置变量来更改

commit操作有 4个挂钩被用来处理提交的过程,他们的触发时间顺序如下: pre-commit、prepare-commit-msg、commit-msg、post-commit

2.实现一些功能

  • 提交前对代码进行eslint校验
  • 提交身份校验(本次以邮箱为例)
  • 提交规范校验
  • 代码文件命名规范校验
  • 代码方法注释校验

2.1 实现提交eslint校验

在提交之前对代码进行eslint校验

安装eslint对应包

yarn add eslint eslint-plugin-prettier -D

配置package.json scripts

"scripts": {
  "lint": "eslint --fix --ext .js src/",
  "postinstall": "git config core.hooksPath hooks && chmod 700 hooks/*"
 },

在项目内新增hooks文件夹,在hooks文件夹中新建pre-commit文件

2.1.1 代码
#!/usr/bin/env node
const childProcess = require('child_process');
const chalk = require('chalk');
try {
  childProcess.execSync('npm run lint');
} catch (error) {
  console.log(chalk.red(error.stdout.toString()));
  process.exit(1);
}

此刻当我们运行git commit提交时,会进行eslint校验

2.1.2 举个栗子
/**
 * 加法
 * @param {*} a
 * @param {*} b
 * @returns
 */
function add(a, b) {
    return a + b;
}
/**
 * 减法
 * @param {*} a
 * @param {*} b
 * @returns
 */
function reduce(a, b) {
    return a - b;
}

add(1, 2);

此时会报错

2.2 实现提交邮箱校验

对提交用户进行身份校验,避免非仓库用户能够进行提交

2.2.1 代码
#!/usr/bin/env node
const testEmail = /xx.com$/;
//公司邮箱才能进行提交代码
const path = require('path');
const chalk = require('chalk');

/**
 * 校验邮箱
 * @param {*} testEmail
 */
const checkEmail = (testEmail) => {
  const email = childProcess.execSync('git config user.email').toString().trim();
    if (!testEmail.test(email)) {
        console.log(chalk.red('此用户没有权限,具有权限的用户为:xx.com'));
        process.exit(1);
    }
};
checkEmail(testEmail)
2.2.2 举个栗子

2.3 实现提交规范

实现husky的提交规范

2.3.1代码
#!/usr/bin/env node
const testEmail = /xx.com$/;
//公司邮箱才能进行提交代码
const path = require('path');
const chalk = require('chalk');
const childProcess = require('child_process');
const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)((.+))?: .{1,100}/;
const msg = fs.readFileSync(process.argv[2], 'utf-8').trim(); // 索引 2 对应的 commit 消息文件
/**
 * 校验提交规范
 */
const checkCommit = () => {
    // 校验提交规范
    if (!commitRE.test(msg)) {
        console.log(chalk.yellow(
            '不合法的 commit 消息格式,请使用正确的提交格式:\n' +
            '  fix: handle events on blur (close #28):\n' +
            '  详情请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md'
        ));
        process.exit(1);
    }
};
checkCommit()
2.3.2 举个栗子

2.4 实现文件命名规范校验

kebab-case命名规范,通过是否含大写字母为依据

2.4.1 代码
const chalk = require('chalk');
const fs = require('fs');
const path = require('path');

/**
 * 校验提交文件目录命名规范
 * @param {*} folder 
 */
const checkFileCase = async (folder) => {
    await fs.readdir(folder, async (_err, files) => {
        if (_err) {
            console.warn(_err);
            process.exit(1);
        }

        // 遍历读取到的文件列表
        files.forEach(filename => {
            // 获取当前文件的绝对路径
            const filedir = path.join(folder, filename);
            // 根据文件路径获取文件信息,返回一个fs.Stats对象
            fs.stat(filedir, async (err, stats) => {
                if (err) {
                    console.warn(err);
                    process.exit(1);
                } else {
                    const isDir = stats.isDirectory(); // 是文件夹
                    if (isDir) {
                        checkFileCase(folder + '/' + filename);
                    } else {
                        if (/[A-Z]/.test(filename)) {
                            // 目前只校验是否含大写字母
                            console.log(chalk.yellow(folder + '/' + filename + ' 文件请遵守kebab-case命名规范'));
                            process.exit(1);
                        }
                    }
                }
            });
        });
    });
};
checkFileCase(path.resolve() + '/src')
2.4.2 举个栗子

存在一个组件目录

提交后报错

2.5 实现方法注释校验

开发过程中经常有存在方法无注释的情况,通过添加git提交校验,强制开发人员对于方法注释的书写和维护

涉及以下两种书写规范

  • function
  • 剪头函数
2.5.1 以vue3为例

.vue文件中

<template>
  <div></div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';
export default defineComponent({
  name: 'App',
  setup() {
    const show = ref<boolean>(false);

    /**
     * 关闭弹窗
     */
    const closePopup = () => {
      show.value = false;
    };
  
    const openPopup = () => {
      show.value = true;
    };
    return {
      show,
      closePopup,
      openPopup
    };
  }
});
</script>

<style lang="less"></style>

工具函数中:

/**
 * 判断是否为空
 * @param {*} input
 * @returns
 */
const isEmpty = (input) => {
    return input == null || input == '';
};


const isBlank = (input) => {
    return input == null || /^\s*$/.test(input);
};
/**
 * 判断是空或者undefined
 * @param {*} input
 * @returns
 */
const isEmptyOrUndefined = (input) => {
    return isEmpty(input) || typeof input == 'undefined' || input == 'defined';
};
/**
 * 参数为空返回"",否则返回去空格的参数
 */
const trimToEmpty = (input) => {
    return isEmptyOrUndefined(input) ? '' : input.trim();
};

/** 只包含字母 **/
const isAlpha = (input) => {
    return /^[a-z]+$/i.test(input);
};

module.exports = {
    isAlpha,
    isBlank,
    isEmpty,
    isEmptyOrUndefined,
    trimToEmpty
};

也会存在以function命名的

/**
 * 加法
 * @param {*} a
 * @param {*} b
 * @returns
 */
function add(a, b) {
    return a + b;
}
function reduce(a, b) {
    return a - b;
}

add(1, 2);
reduce(2, 1);
2.5.2 代码

这个时候就需要babel来帮助我们分析代码了,但是对于.vue的文件 @babel/parser是无法解析成ast的,所以需要通过 @vue/compiler-sfc(Vue3) 先提取出script部分的代码,然后在通过@babel/parser进行解析

vue2版本通过vue-template-compiler 编译,版本需要与vue的版本对应一致

引入相关babel包,解析出ast结构,可通过astexplorer.net/网站对应分析

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// const vueCompiler = require('vue-template-compiler') //VUE2版本
const vueCompiler = require('@vue/compiler-sfc'); // VUE3版本

const isVue = fileDir.search('.vue') > -1;
const sourceCode = fs.readFileSync(fileDir, 'utf-8');
let ast = '';
if (isVue) {
  // // vueStr .vue 文件内容 VUE2版本
  // const vueCode = vueCompiler.parseComponent(sourceCode);
  // ast = parser.parse(vueCode.script.content, {
  //     allowImportExportEverywhere: true
  // });
  
  // 提取vueCode
  const vueCode = vueCompiler.parse(sourceCode);
  if (vueCode.descriptor.script) {
    // const { code } = vueCompiler.compileTemplate({
    //     id: fileDir,
    //     filename: fileDir,
    //     source: vueCode.descriptor.script.content
    // });
    ast = parser.parse(vueCode.descriptor.script.content, {
      sourceType: 'unambiguous'
    });
  }
} else {
  ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous'
  });

对应ast 结构,两种方法对应分别是VarDeclaration/ArrowFunctionExpression和FunctionDeclaration

其中const命名的方法对应的ast是

function命名的方法对应的ast是

leadingComments字段代表的是前面的注释,trailingComments后面的注释,所以我们可以通过去查找对应节点leadingComments数组内type类似是CommentBlock 的对象是否存在,来判断是否存在注释,也可以提取value值然后生成方法的文档

traverse(ast, {
  FunctionDeclaration: {
    enter(path, state) {
      checkLeadingComments(path.node, fileDir, path.node.id.name);
    }
  },
  VariableDeclaration: {
    enter(path, state) {
      const _declarations = path.node.declarations[0];
      if (_declarations.init.type === 'ArrowFunctionExpression') {
        checkLeadingComments(path.node, fileDir, _declarations.id.name);
      }
    }
  }
});
if (_errStats.length) {
  console.error(`${_errStats.join('\n')}`);
  process.exit(1);
}
2.5.3 举个栗子

3.完整check代码

const chalk = require('chalk');
const fs = require('fs');
const path = require('path');
const childProcess = require('child_process');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// const vueCompiler = require('vue-template-compiler') //VUE2版本
const vueCompiler = require('@vue/compiler-sfc'); // VUE3版本
const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)((.+))?: .{1,100}/;
const email = childProcess.execSync('git config user.email').toString().trim();
const msg = fs.readFileSync(process.argv[2], 'utf-8').trim(); // 索引 2 对应的 commit 消息文件
const _errStats = [];

/**
 * 校验提交规范
 */
const checkCommit = () => {
    if (!commitRE.test(msg)) {
        console.log(chalk.yellow(
            '不合法的 commit 消息格式,请使用正确的提交格式:\n' +
            '  fix: handle events on blur (close #28):\n' +
            '  详情请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md'
        ));
        process.exit(1);
    }
};
/**
 * 校验邮箱规范
 * @param {*} testEmail
 */
const checkEmail = (testEmail) => {
    if (!testEmail.test(email)) {
        console.log(chalk.red('此用户没有权限,具有权限的用户为: xxx@qq.com或者100.com'));
        process.exit(1);
    }
};

/**
 * 校验提交文件目录命名规范
 * @param {*} folder
 */
const checkFileCase = async (folder) => {
    checkFileStats(folder, false, (filename) => {
        if (/[A-Z]/.test(filename)) {
            // 目前只校验是否含大写字母
            console.log(chalk.yellow(folder + '/' + filename + ' 文件请遵守kebab-case命名规范'));
            process.exit(1);
        }
    });
};

/**
 * 检查提交文件内方法有没有写注释
 * @param {*} folder
 */
const checkFileExplanatory = async (folder) => {
    checkFileStats(folder, true, (fileDir) => {
        const isVue = fileDir.search('.vue') > -1;
        const sourceCode = fs.readFileSync(fileDir, 'utf-8');
        let ast = '';
        if (isVue) {
            // // vueStr .vue 文件内容 VUE2版本
            // const vueCode = vueCompiler.parseComponent(sourceCode);
            // ast = parser.parse(vueCode.script.content, {
            //     allowImportExportEverywhere: true
            // });

            // 提取vueCode
            const vueCode = vueCompiler.parse(sourceCode);
            if (vueCode.descriptor.script) {
                // const { code } = vueCompiler.compileTemplate({
                //     id: fileDir,
                //     filename: fileDir,
                //     source: vueCode.descriptor.script.content
                // });
                ast = parser.parse(vueCode.descriptor.script.content, {
                    sourceType: 'unambiguous'
                });
            }
        } else {
            ast = parser.parse(sourceCode, {
                sourceType: 'unambiguous'
            });
        }
        traverse(ast, {
            FunctionDeclaration: {
                enter(path, state) {
                    checkLeadingComments(path.node, fileDir, path.node.id.name);
                }
            },
            VariableDeclaration: {
                enter(path, state) {
                    const _declarations = path.node.declarations[0];
                    if (_declarations.init.type === 'ArrowFunctionExpression') {
                        checkLeadingComments(path.node, fileDir, _declarations.id.name);
                    }
                }
            }
        });
        if (_errStats.length) {
            console.error(`${_errStats.join('\n')}`);
            process.exit(1);
        }
    });
};

/**
 * 
 * @param {*} node ast path.node 节点
 * @param {*} fileDir 文件路劲
 * @param {*} name 方法名
 */
const checkLeadingComments = async (node, fileDir, name) => {
    const _leadingComments = node.leadingComments;
    // eslint-disable-next-line no-undef
    const hasCommentBlock = _leadingComments?.find(item => item.type === 'CommentBlock');
    if (!_leadingComments || !hasCommentBlock) {
        _errStats.push(`X: ${fileDir}中的${name}方法未添加注释!!`);
    }
};

/**
 * 文件状态校验
 * @param {*} folder 文件目录
 * @param {*} isFilePath 是否是文件路劲
 * @param {*} checkCallBack 回调方法
 */
const checkFileStats = async (folder, isFilePath, checkCallBack) => {
    await fs.readdir(folder, async (_err, files) => {
        if (_err) {
            console.warn(_err);
            process.exit(1);
        }
        // 遍历读取到的文件列表
        files.forEach(filename => {
            // 获取当前文件的绝对路径
            const fileDir = path.join(folder, filename);
            fs.stat(fileDir, async (err, stats) => {
                if (err) {
                    console.warn(err);
                    process.exit(1);
                } else {
                    const isDir = stats.isDirectory(); // 是文件夹
                    if (isDir) {
                        checkFileStats(folder + '/' + filename, isFilePath, checkCallBack);
                    } else {
                        checkCallBack(isFilePath ? fileDir : filename);
                    }
                }
            });
        }
        );
    });
};

module.exports = {
    checkCommit,
    checkEmail,
    checkFileCase,
    checkFileExplanatory
};

4.shell代码来一波

4.1一些知识点

4.1.1 # /bin/sh和# /bin/bash的区别

#/bin/sh 相当于 /bin/bash --posix # sh跟bash的区别,实际上就是bash有没有开启posix模式的区别。so,可以预想的是, # 如果第一行写成 #!/bin/bash --posix,那么脚本执行效果跟#!/bin/sh是一样的(遵循posix的特定规范,有可能就包括这样的规范:“当某行代码出错时,不继续往下解释”)

4.1.2 退出码

Shell脚本有三种方式返回退出码:

  1. exit命令,用exit返回退出码,exit后的指令不会再执行。
  2. return命令,但是要注意的是只有用source方式调用的脚本中才能用return返回退出码,return后的指令同样不会再执行。
  3. 如果脚本中既没有exit也没有return,则脚本的退出码为脚本中最后一条指令的退出码。

在脚本或函数执行完成后,紧接着通过$?就可以获得脚本或函数的退出码。

4.1.3 其他
  • “=~”正则匹配,用来判断其左侧的参数是否符合右边的要求,如果匹配则为真(返回1)
  • 逻辑与的表达:if [ xxx=aaxxx=a -a xx=b ] -a表示and的意思 / if [ xxx=a ] && [ xx=b ]
  • 逻辑或的表达: if [ xxx=aoxxx=a -o xx=b ] -o表示or的意思 / if [ xxx=a][xxx=a ] || [ xx=b ]

4.2 完成提交前eslint校验

pre-commit 代码如下

#!/bin/bash

# 执行 lint 脚本,如果不正确需要将退出码设为非零
npm run lint

# 在脚本或函数执行完成后,紧接着通过$?就可以获得脚本或函数的退出码。
exitCode="$?"
exit $exitCode

4.3 提交时提交规范以及邮箱校验

commit-msg代码如下

#!/bin/bash

# 获取当前提交的git commit -m '' $1获取输入的值
commit_msg=`cat $1`

# 获取用户 email
email=`git config user.email`
commit_re="^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)((.+))?: .{1,100}"


# “=~”正则匹配,用来判断其左侧的参数是否符合右边的要求,如果匹配则为真(返回1)
# ,不匹配则为假(返回0)
if [[ ! $commit_msg =~ $commit_re ]]
then
	echo "不合法的 commit 消息提交格式,请使用正确的格式:"
	echo "详情请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md"

	# 异常退出
	exit 1
fi


email_re="100.com"

# 逻辑与的表达:if [ $xxx=a -a $xx=b ] -a表示and的意思  /  if [ $xxx=a ] && [  $xx=b ] 
# 逻辑或的表达: if [ $xxx=a -o $xx=b ] -o表示or的意思  / if [ $xxx=a ] || [  $xx=b ]


if [[ ! $email =~ $email_re ]]
then
	echo "此用户没有权限,具有权限的用户为: 100.com"

	# 异常退出
	exit 1
fi


项目地址:github.com/jianwuG/jw-…