使用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 命令提交代码。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。