commitizen规范提交message-从使用到源码分析

使用git提交代码时会要求填写commit message。好的commit message 可以帮助我们了解提交历史从而帮助我们快速分析问题,了解bug产生原因等。写出好的commit message 需要遵循一定的规范,目前最常用的是Angular规范。我以前是把Angular 规范写在便利贴粘到电脑上,提交代码时会瞄上几眼。直到我了解到commitizen 可以用轮询交互的方式帮我们生成符合规范的commit message。感觉使用它能够提高生产力,就研究了其源码。

1.前置知识——Angular规范

Angular 规范要求commit message包括三个部分:Header、Body、Footer三个部分

<type>(<scope>): <subject>
// 空一行
<body>
// 空一行
<footer>

只有Header是必填的,Body和Footer都是选填的。下面以导图的方式展示对如上内容的解释:

2.commitzen使用简介

(1)首先,安装 Commitizen CLI 工具

npm install commitizen -g

(2)接下来,通过输入以下命令,初始化项目以使用 cz-conventional-changelog 适配器

commitizen init cz-conventional-changelog --save-dev --save-exact

上面的命令为做了三件事:

第一,安装cz-conventional-changelog(适配器)的npm包;

第二,将其保存到 package.json 的 devDependency;

第三,在 package.json 文件的根目录中添加 config. com mitizen 键,如下所示:

(3)使用git cz提交代码

注意:再执行git cz之前要先执行 git add .

从上图可以看到我们此时是以轮询问答的方式输入commit message了。下面我们通过分析commitizen的源码来了解其原理。

3.commitzen源码分析

3.1 源码准备工作

我们需要clone commitizen 以及 cz-conventional-changelog 的源码:

git clone [email protected]:commitizen/cz-cli.git
git clone [email protected]:commitizen/cz-conventional-changelog.git

3.2 适配器安装源码分析

在3.1 中我们看到安装完commitizen 之后就是安装适配器 cz-conventional-changelog,而安装适配器是通过如下命令实现的:

commitizen init cz-conventional-changelog --save-dev --save-exact

可以猜测 commitizen init执行的是install操作,那么具体是如何实现的呢?

在package.json文件中bin选项有commitizen的定义:

可以看到执行commitizen的时候是执行了bin目录下的commitizen.js文件。

commitizen.js的代码如下:

require('../dist/cli/commitizen.js').bootstrap();

可以看到如上代码是引入了cli目录下的commitizen.js文件导出的bootstrap方法并执行。

cli目录下的comitizen.js文件如下:

import { init } from '../commitizen';
import { commitizen as commitizenParser } from './parsers';

let { parse } = commitizenParser;

export {
  bootstrap
};

function bootstrap (environment = {}, argv = process.argv) {

  let rawGitArgs = argv.slice(2, argv.length);
  let parsedArgs = parse(rawGitArgs);
  let command = parsedArgs._[0];

  if (command === "init") {
    let adapterNpmName = parsedArgs._[1];
    if (adapterNpmName) {
      console.log(`Attempting to initialize using the npm package ${adapterNpmName}`);
      try {
        init(process.cwd(), adapterNpmName, parsedArgs);
      } catch (e) {
        console.error(`Error: ${e}`);
      }
    } else {
      console.error('Error: You must provide an adapter name as the second argument.');
    }
  } else {
    console.log(`....`);// 省略
  }

}

bootstrap方法是cli commitizen命令的入口,process.argv对应执行命令的参数,调用parse方法对其解析并取到命令和其余参数(安装的依赖即适配器,以及依赖的安装方式)。真正安装依赖又是通过调用init方法实现的。

3.2.1 parse方法

parse方法定义如下:

import minimist from 'minimist';

export {
  parse
};

function parse (rawGitArgs) {
   var args = minimist(rawGitArgs, {
     boolean: true
   });

   return args;
}

minimist 是一个轻量级的命令解析工具,其特性也比较全面,包括 short options、long options、Boolean 和 Number类型的自动化。如上代码所示,设置了boolean为true则将所有没有等号的双连字符参数视为布尔型。

3.2.2 init方法

init方法是真正来执行安装动作的方法。如何安装一个npm 包呢?需要考虑哪些因素呢?学完这段代码我们将会受益匪浅。

function init (repoPath, adapterNpmName, {
  save = false,
  saveDev = true,
  saveExact = false,
  force = false,
  yarn = false,
  dev = false,
  exact = false,
  includeCommitizen = false
} = defaultInitOptions) {

  checkRequiredArguments(repoPath, adapterNpmName);

  let adapterConfig = loadAdapterConfig(repoPath);

  let stringMappings = yarn ? getYarnAddStringMappings(dev, exact, force) : getNpmInstallStringMappings(save, saveDev, saveExact, force);

  let installAdapterCommand = yarn ? generateYarnAddAdapterCommand(stringMappings, adapterNpmName) : generateNpmInstallAdapterCommand(stringMappings, adapterNpmName);

  let installCommitizenCommand = yarn ? generateYarnAddAdapterCommand(stringMappings, "commitizen") : generateNpmInstallAdapterCommand(stringMappings, "commitizen");

  if (adapterConfig && adapterConfig.path && adapterConfig.path.length > 0 && !force) {
    throw new Error(`A previous adapter is already configured. Use --force to override
    adapterConfig.path: ${adapterConfig.path}
    repoPath: ${repoPath}
    CLI_PATH: ${CLI_PATH}
    installAdapterCommand: ${installAdapterCommand}
    adapterNpmName: ${adapterNpmName}
    `);
  }
  try {
    childProcess.execSync(installAdapterCommand, { cwd: repoPath });
    if(includeCommitizen) {
      childProcess.execSync(installCommitizenCommand, { cwd: repoPath });
    }
    addPathToAdapterConfig(CLI_PATH, repoPath, adapterNpmName);
  } catch (e) {
    console.error(e);
  }
}

阅读如上代码可以知道:

(1)checkRequiredArguments用于参数检查,检查当前仓库(项目)地址和适配器名是否存在;loadAdapterConfig用于检查当前项目已有的适配器配置;

(2)然后根据使用的包管理器来处理安装npm 包的命令和参数,其中参数主要是指安装的方式(全局还是局部,开发依赖还是生产依赖等),笔者使用的包管理器是npm 所以执行的调用方法分别是getNpmInstallStringMappings和generateNpmInstallAdapterCommand。

(3)最后是执行安装命令的操作。执行命令使用的是child_process的execSync方法。

(4)addPathToAdapterConfig用于将适配器的配置添加到当前项目中,其实质是修改当前项目的package.json文件。

如上步骤的流程如下图所示:

下面分析一下上述过程用到的几个方法:

3.2.2.1 getNpmInstallStringMappings

此方法用于获取npm安装命令的其他参数,代码如下:

/**
 * Gets a map of arguments where the value is the corresponding npm strings
 */
function getNpmInstallStringMappings (save, saveDev, saveExact, force) {
  return new Map()
    .set('save', (save && !saveDev) ? '--save' : undefined)
    .set('saveDev', saveDev ? '--save-dev' : undefined)
    .set('saveExact', saveExact ? '--save-exact' : undefined)
    .set('force', force ? '--force' : undefined);
}

--save 指安装为依赖(生产环境);--save-dev指安装为dev依赖(开发环境);--save-exact是使用精确版本安装;--force是指强制安装,更多关于npm的安装命令可以查看npm官网介绍

3.2.2.2 generateNpmInstallAdapterCommand

此方法用于生成最终要执行npm安装命令,代码如下:

function generateNpmInstallAdapterCommand (stringMappings, adapterNpmName) {

  // Start with an initial npm install command
  let installAdapterCommand = `npm install ${adapterNpmName}`;

  // Append the neccesary arguments to it based on user preferences
  for (let value of stringMappings.values()) {
    if (value) {
      installAdapterCommand = installAdapterCommand + ' ' + value;
    }
  }

  return installAdapterCommand;
}

installAdapterCommand是要执行的命令字符串,初始值为 npm install 适配器名 ,之后的for循环则用于拼接存储在getNpmInstallStringMappings方法的返回值中的其他install 参数。

3.2.2.3 addPathToAdapterConfig

此方法用于将commitizen的适配器配置信息写入package.json,代码如下:

function addPathToAdapterConfig (cliPath, repoPath, adapterNpmName) {

  let commitizenAdapterConfig = {
    config: {
      commitizen: {
        path: `./node_modules/${adapterNpmName}`
      }
    }
  };

  let packageJsonPath = path.join(getNearestProjectRootDirectory(repoPath), 'package.json');
  let packageJsonString = fs.readFileSync(packageJsonPath, 'utf-8');
  // tries to detect the indentation and falls back to a default if it can't
  let indent = detectIndent(packageJsonString).indent || '  ';
  let packageJsonContent = JSON.parse(packageJsonString);
  let newPackageJsonContent = '';
  if (_.get(packageJsonContent, 'config.commitizen.path') !== adapterNpmName) {
    newPackageJsonContent = _.merge(packageJsonContent, commitizenAdapterConfig);
  }
  fs.writeFileSync(packageJsonPath, JSON.stringify(newPackageJsonContent, null, indent) + '\n');
}

commitizenAdapterConfig为要写入的适配器配置信息,fs.readFileSync读取package.json文件的内容,使用lodash提供的merge方法将适配器配置信息和原有信息整合到一起,最后使用fs.writeFileSync更新package.json文件的内容。

3.3 git-cz 命令分析

从package.json的bin选项可以看到cz 或者 git-cz命令执行的是bin目录下的git-cz.js文件:

bin目录下git-cz文件内容下:

require('../dist/cli/git-cz.js').bootstrap({
  cliPath: path.join(__dirname, '../')
});

可以看到引入的是cli下的git-cz.js文件导出的bootstrap方法并执行。

cli下的git-cz.js内容如下:

import { configLoader } from '../commitizen';
import { git as useGitStrategy, gitCz as useGitCzStrategy } from './strategies';

export {
  bootstrap
};

function bootstrap (environment = {}, argv = process.argv) {

  // Get cli args
  let rawGitArgs = argv.slice(2, argv.length);

  let adapterConfig = environment.config || configLoader.load();

  // Choose a strategy based on the existance the adapter config
  if (typeof adapterConfig !== 'undefined') {
    // This tells commitizen we're in business
    useGitCzStrategy(rawGitArgs, environment, adapterConfig);
  } else {
    // This tells commitizen that it is not needed, just use git
    useGitStrategy(rawGitArgs, environment);
  }
}

bootstrap方法根据是否安装了adapter决定使用何种git提交策略。如果安装了适配器则使用commitizen,否则使用git。这里的关键方法有configLoader的load()方法,useGitCzStrategy方法,还有useGitStrategy方法。

我们逐一介绍一下这些方法:

3.3.2 load

load方法定义如下:

import { loader } from '../configLoader';

export { load };

// Configuration sources in priority order.
var configs = ['.czrc', '.cz.json', 'package.json'];

function load (config, cwd) {
  return loader(configs, config, cwd);
}

load方法实际为返回loader方法的执行结果。loader方法定义如下

function loader (configs, config, cwd) {
    var content;
    var directory = cwd || process.cwd();

    // If config option is given, attempt to load it
    if (config) {
        return getContent(config, directory);
    }

    content = getContent(
        findup(configs, { nocase: true, cwd: directory }, function (configPath) {
            if (path.basename(configPath) === 'package.json') {
                // return !!this.getContent(configPath);
            }

            return true;
        })
    );

    if (content) {
        return content;
    }
    /* istanbul ignore if */
    if (!isInTest()) {
      // Try to load standard configs from home dir
      var directoryArr = [process.env.USERPROFILE, process.env.HOMEPATH, process.env.HOME];
      for (var i = 0, dirLen = directoryArr.length; i < dirLen; i++) {
          if (!directoryArr[i]) {
              continue;
          }

          for (var j = 0, len = configs.length; j < len; j++) {
              content = getContent(configs[j], directoryArr[i]);

              if (content) {
                  return content;
              }
          }
      }
    }
}

loader方法的执行逻辑并不难,目标是要找到适配器的配置文件。首先使用findup方法查找package.json文件,调用getContent方法在package.json文件中查找是否有config.commitizen(或者czConfig)配置项,如果有则返回相应配置,没有则进行下面for循环的查找过程。

双重for循环的作用也是查找关于适配器的配置,要在process.env.USERPROFILE, process.env.HOMEPATH, process.env.HOME这些目录中查找.czrc, .cz.json, package.json文件,如果查找到文件则执行getContent方法判断是否存在相应的配置。

findup和getContent两个方法的作用已经提到,就不具体介绍其详细实现了。

3.3.2 useGitCzStrategy

useGitCzStrategy是对gitCz方法的重新命名,gitCz代码如下(有删减):

function gitCz (rawGitArgs, environment, adapterConfig) {

  let parsedGitCzArgs = parse(rawGitArgs);

  let prompter = getPrompter(adapterConfig.path);

  isClean(process.cwd(), function (error, stagingIsClean) {
    if (error) {
      throw error;
    }
    if (stagingIsClean && !parsedGitCzArgs.includes('--allow-empty')) {
      throw new Error('No files added to staging! Did you forget to run git add?');
    }
    commit(inquirer, getGitRootPath(), prompter, {
      args: parsedGitCzArgs,
    }, function (error) {
      if (error) {
        throw error;
      }
    });
  }, shouldStageAllFiles);

}

parse()方法用于解析参数。getPrompter()方法用于获取适配器,参数为adapterConfig.path,也就是./node_modules/cz-conventional-changelog。isClean方法保证暂存区是空的、没有要提交的内容。commit方法是用于提交的方法。这里主要需要关注的是getPrompter方法和commit方法。

getPrompter时获取适配器也就是用于获取提交操作时的获取轮询问卷,也就是cz-conventional-changelog导出的内容,读者可以自行研究一下cz-conventional-changelog的源码,在此只简单介绍其主要流程:

(1)根目录下的index.js 导出engine方法的执行结果:module.exports = engine(options);

(2)根目录下的engine.js文件返回了一个对象,对象拥有唯一的名为prompter的属性,其属性值为一个函数:

module.exports = function(options) {
  // ...
  return {
    prompter: function(cz, commit) {
      cz.prompt([
        {
          type: 'list',
          name: 'type',
          message: "Select the type of change that you're committing:",
          choices: choices,
          default: options.defaultType
        },
        // ....
      ]
      //....
    }
  }
}

看到这里我们就能明白了为什么commitizen可以做到轮询问卷的方式生成commit message了。

commit方法则能够保证将轮询问卷的结果和其它commit参数整合到一起然后执行提交操作,其详细代码读者可以自行研究,在此只给出整理的函数调用关系图:

上图中gitCommit方法的原理和下面要介绍的useGitStrategy是一样的。

3.3.3 useGitStrategy

useGitStrategy是对git方法的重新命名,git代码如下

import childProcess from 'child_process';

export default git;

function git (rawGitArgs, environment) {
  if (environment.debug === true) {
    console.error('COMMITIZEN DEBUG: No cz friendly config was detected. I looked for .czrc, .cz.json, or czConfig in package.json.');
  } else {
    var vanillaGitArgs = ["commit"].concat(rawGitArgs);

    var child = childProcess.spawn('git', vanillaGitArgs, {
      stdio: 'inherit'
    });

    child.on('error', function (e, code) {
      console.error(e);
      throw e;
    });
  }
}

执行git命令使用的是child_process的spawn方法,child_process.spawn可以根据指定的命令行参数创建新的进程。child需要监听error事件,error事件发生可能有一下几种情况:

  • 无法生成子进程
  • 子进程无法杀死
  • 向子进程发送消息失败

至此,关于commitizen能够用轮询交互的方式帮我们生成符合规范的commit message的原理我们就清楚了。

4.总结

本文首先介绍了git 的commit message 的书写规范;然后介绍了commitizen的使用;之后重点分析了commitizen的源码。commitizen会为我们安装适配器,然后在执行cz命令的之后优先选择适配器提交策略。适配器以轮询交互的方式帮我们生成符合规范的 commit message。最后使用childProcess.spawn执行git commit 命令提交代码。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

猜你喜欢

转载自juejin.im/post/7124959972324016164
今日推荐