去年我在团队里推提交信息规范的时候,发现一个很有意思的现象:几乎没有人反对“规范提交信息”这件事,大家都在问“到底按什么格式写”。光是把 Angular 的提交风格贴在文档里还不够,因为只要一打开终端,大多数人还是会习惯性地敲 git commit -m "fixed something"。这时候 Commitizen 适配器的价值就体现出来了——它把“写提交信息”从一道自由发挥的填空题,变成了一个有标准答案的选择题。Commitizen 本身只是一个调度器,真正决定问题的顺序、选项的枚举、消息的拼装规则的,是它的适配器。这篇文章我打算把适配器这个组件彻底拆开讲一遍,包括为什么要设计成可插拔、它的接口协议、主流适配器怎么选,以及如何从零手写一个走完整个调试流程。
1. 先搞清楚一件事:Commitizen 为什么需要适配器
1.1 提交规范与交互工具不该耦合
如果你去看 Commitizen 的源码,会发现它做的事情非常克制:拦截 git commit,弹出一系列交互式问题,收集答案后拼成一条 commit message,然后交给 Git 去执行。但它不关心这些问题长什么样。
不同团队的提交规范差异很大。有的团队面向开源项目,要求严格遵循 Conventional Commits;有的团队需要在提交信息里带上 Jira 单号;有的团队希望允许类似 feat: xxx 和 chore: xxx 的简单枚举;还有的团队会用中文写 type 描述。如果 Commitizen 把这些规范直接内置,它就变成了一个“只支持某一种规范”的工具。更合理的做法是把规则部分抽象出去,让每个团队选择自己的规则集。这就是适配器的价值:适配器负责定义交互流程和消息模板,Commitizen 负责调度和执行。
1.2 适配器模式在 Commitizen 里的具体体现
适配器模式本身是一个很经典的设计思路:客户端依赖一个抽象接口,而不是依赖具体实现。在 Commitizen 生态里,抽象接口就是“一个 npm 包默认导出一个 prompt 函数”,具体实现则是一堆 cz- 开头的包。
你只需要在配置里告诉 Commitizen 当前项目用哪一个适配器,比如:
json复制{
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}
它就会加载这个包,调用里面的交互逻辑。换适配器的成本只是改一行配置,不用改动 Commitizen 本身,也不用改 Git 的钩子逻辑。这一点对一个团队的基础设施来说非常重要:当规范演进时,你不需要把工具链推翻重来。
1.3 使用适配器前后的提交体验对比
没有适配器时,提交信息的质量完全取决于开发者的自觉:
code复制git commit -m "fix bug"
这行提交信息放到后面的 changelog 生成工具里,几乎不能提供任何有效信息。引入 Commitizen 和合适适配器之后,同样一次修复,流程变成了:运行 git cz,在列出的类型里选 fix,填写影响范围,填写主题描述,确认是否有破坏性变更。最终生成:
code复制fix(login): 修复登录接口在 token 过期后返回 401 的问题
这段过程最大的价值,不是帮你省下了敲键盘的时间,而是把模糊的规范变成了具体的操作路径。人在看到一个空输入框时会产生选择困难,但看到一组明确选项时,会顺畅得多。如果你手头恰好管理着一个代码规范文档,把文档里的文字转换成适配器里的交互式问题,往往比贴文档更容易落地。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 适配器的接口协议到底长什么样
2.1 一个适配器的唯一硬性要求
Commitizen 对适配器的要求其实非常小:这个 npm 包需要导出名为 prompt 的函数,函数接收一个 inquirer 实例,返回一个 Promise,最终 resolve 出一个包含提交信息的对象。
我用一个最小示例来说明:
js复制// index.js
module.exports = {
prompt: (inquirer) => {
const questions = [
{
type: 'list',
name: 'type',
message: '提交类型:',
choices: [
{ name: 'feat: 新功能', value: 'feat' },
{ name: 'fix: 修复缺陷', value: 'fix' },
{ name: 'refactor: 重构', value: 'refactor' }
]
},
{
type: 'input',
name: 'subject',
message: '主题描述:'
}
];
return inquirer.prompt(questions).then((answers) => {
return {
commit: `${answers.type}: ${answers.subject}`
};
});
}
};
只要 Commitizen 能加载到这个模块,然后调用 prompt(inquirer),git cz 就能正常工作了。这个设计之所以让我觉得巧妙,在于它对开发者几乎没有框架层面的心智负担:你不需要继承某个基类,不需要实现一堆抽象方法,只需要写一个返回 Promise 的函数。
2.2 prompt 与 format 的分工
在稍微复杂一点的适配器里,你会发现代码一般会拆成两部分:一部分是 prompt,负责收集答案;一部分是 format,负责把答案渲染成最终的提交信息。
js复制function format(answers) {
const scope = answers.scope ? `(${answers.scope})` : '';
const head = `${answers.type}${scope}: ${answers.subject}`;
const body = answers.body ? `\n\n${answers.body}` : '';
const footer = answers.footer ? `\n\n${answers.footer}` : '';
return `${head}${body}${footer}`;
}
把渲染逻辑从交互逻辑里拆出来的好处,是你可以单独测试 format。提交信息是个典型的“中间产品”,如果你在团队里建了 commitlint 校验规则,那么 format 的输出是否符合规则变得很重要。把 format 做成纯函数,配合单元测试,能让适配器的行为可预测。
2.3 inquirer 是适配器的事实标准
真正在用 prompt 函数时,你会发现它的核心依赖是 inquirer。这个库提供了非常丰富的交互组件:
list:单选列表checkbox:多选input:单行输入confirm:确认editor:打开系统编辑器编写多行内容
适配器间的差别,本质上就是这些交互组件的排列组合方式不同。比如需求方要求在提交时维护一个 ChangeLog 分类,你可以用 checkbox 让开发者勾选变更模块;如果想收集破坏性变更描述,可以用 confirm 先确认是“是”,再用 input 收集详细内容。
关于 inquirer 版本,这里要提醒一句:inquirer 8 和 inquirer 9 的模块导入方式有差异,如果你在自己的适配器里直接用 require('inquirer'),而 Commitizen 内部注入的实例和你的实例不是同一个版本,有可能出现 UI 显示异常。稳妥做法是直接用传入的 inquirer 参数,不要自己再安装一份。
2.4 主流的返回格式约定:commit 字段优先
市面上适配器的返回格式其实有两代差异。早期一批适配器会 resolve 出带有 type、subject、body 等字段的对象,由 Commitizen 负责把它们拼成一条 commit message。后来的主流做法是直接返回 { commit: '完整提交信息' },把拼装逻辑完全交给适配器。
这两种方式在配置层面都能工作,但如果你要自己写适配器,我建议直接返回 commit 字段,理由有三个:
- 拼接逻辑完全可控,出现格式问题时不用去翻 Commitizen 的源码;
- 方便你做整体校验,比如限制 header 不超过 72 字符;
- 新老协议来回切换时不容易踩坑。
3. 主流适配器横向对比:选型而不是选热闹
3.1 cz-conventional-changelog:默认中的默认
如果你对提交规范没有强烈的个性化需求,cz-conventional-changelog 会是一个很省心的默认选择。它实现的是 Conventional Commits 规范,questions 一般包含 type、scope、subject、body、breaking changes、closed issues。相应的输出长这样:
code复制feat(ui): 新增主题切换能力
支持跟随系统亮暗模式,并提供手动切换入口
Closes #12
这套结构能直接配合 standard-version、semantic-release 这类工具生成 changelog。它的缺点也很明显:问题顺序和文案是固定的,如果不喜欢英文 prompt,或者希望去掉某些问题环节,就需要换一个适配器。
3.2 cz-customizable:配置驱动,适合自定义需求
cz-customizable 是很多国内团队的过渡选择:它不要求你写代码,只要求维护一份配置文件。安装后在 .cz-config.cjs 里可以自定义 type 列表、问题顺序、是否显示 scope、body 是否必填等:
js复制module.exports = {
types: [
{ value: 'feat', name: 'feat: 一个新功能' },
{ value: 'fix', name: 'fix: 一个 bug 修复' }
],
messages: {
type: '选择提交类型:',
subject: '填写主题:'
},
allowBreakingChanges: ['feat', 'fix'],
subjectLimit: 100
};
这个适配器的好处是零代码定制,团队里非前端出身的人也能改配置。需要注意的是它的配置字段在不同大版本里有过调整,升级大版本时记得比对配置文件格式,别直接把老配置原封不动搬过去。
3.3 面向特定需求的适配器
还有一类适配器是针对特定场景的,比如:
cz-emoji:在 type 后面自动附加 emoji,适合偏年轻化的团队产品;cz-jira-smart-commit:把 Jira 操作指令嵌入提交信息,适合重度使用 Jira 的团队;cz-git:功能更现代的适配器,支持在新版 inquirer 下使用更多交互形态,允许通过配置实现深度的个性化。
选型时需要思考一个问题:适配器里的枚举和你的 commitlint 规则是否一致。很多团队把适配器配成了 feature,但 commitlint 校验只认 feat,结果每次提交都被 hook 拦下来,这种摩擦对开发体验的伤害远大于规范缺失本身。
3.4 安装与切换适配器的几种方式
安装官方适配器最简单的方式是让 Commitizen 的 init 命令代劳:
bash复制npx commitizen init cz-conventional-changelog --save-dev --save-exact
这条命令会往 package.json 的 config.commitizen.path 里写入适配器路径。如果你想手动切换,改这一处配置即可,也可以用轻量的 .czrc 文件:
json复制{
"path": "cz-customizable"
}
我个人习惯在 package.json 里集中管理,少一个文件。但 .czrc 的好处是它不会被各种依赖安装流程覆盖,尤其适合放在 CI 或 Docker 构建目录里。
4. 从零手写一个适配器:完整实现与调试
4.1 为什么值得自己写
很多人觉得直接装一个现成适配器就够了,没必要自己写。但当你给团队做工具链的时候,总会碰到这些诉求:
- type 列表需要和公司的项目管理规范强绑定;
- prompt 文案必须中文,且要附上示例说明;
- 提交信息需要自动带出当前分支名中的需求编号;
- 某些信息希望读一个本地配置文件,而不是每次手敲。
这些需求用现成适配器往往要绕很多弯,而自己写一个 cz-xxx 包,代码量通常只有一两百行。更重要的价值是,你会彻底理解 Commitizen 的工作方式,后续遇到问题排查起来会快很多。
4.2 项目骨架与依赖
创建一个目录,初始化 npm 包,命名成 cz-team-convention 这种带 cz- 前缀的形式,方便其他人从命名就能看出用途。实际开发只需要安装一个依赖:
bash复制npm init -y
npm install inquirer
目录结构保持单文件也能跑,但我更推荐这样组织:
code复制cz-team-convention/
├── index.js
├── format.js
└── test.js
index.js 是适配器入口,format.js 管渲染逻辑,test.js 用来本地模拟调用,这样后续扩展和维护都会清晰。
4.3 实现 prompt 函数:完整案例
下面是一个带实际业务含义的适配器。它的要求是:type 只能从四个值里选;scope 可选;subject 必须小于 100 字;如果有破坏性变更,则额外询问具体说明。
js复制// format.js
function format(answers) {
const scope = answers.scope ? `(${answers.scope})` : '';
const head = `${answers.type}${scope}: ${answers.subject.trim()}`;
if (head.length > 100) {
throw new Error(`subject 过长:当前 ${head.length} 字,限制 100 字`);
}
let body = '';
if (answers.body && answers.body.trim()) {
body = `\n\n${answers.body.trim()}`;
}
let footer = '';
if (answers.isBreaking === 'yes') {
footer = `\n\nBREAKING CHANGE: ${answers.breakingDesc.trim()}`;
}
return `${head}${body}${footer}`;
}
module.exports = format;
js复制// index.js
const format = require('./format');
const questions = [
{
type: 'list',
name: 'type',
message: '本期变更类型:',
choices: [
{ name: '功能新增', value: 'feat' },
{ name: '缺陷修复', value: 'fix' },
{ name: '代码重构', value: 'refactor' },
{ name: '工程配置', value: 'chore' }
]
},
{
type: 'input',
name: 'scope',
message: '影响范围(可留空):'
},
{
type: 'input',
name: 'subject',
message: '一句话描述(100 字内):'
},
{
type: 'confirm',
name: 'hasBody',
message: '补充详细说明?'
},
{
type: 'editor',
name: 'body',
message: '详细说明:',
when: (answers) => answers.hasBody
},
{
type: 'confirm',
name: 'isBreaking',
message: '是否存在破坏性变更?'
},
{
type: 'input',
name: 'breakingDesc',
message: '破坏性变更说明:',
when: (answers) => answers.isBreaking
}
];
module.exports = {
prompt: (inquirer) => {
return inquirer.prompt(questions).then((answers) => {
return { commit: format(answers) };
});
}
};
这里有一个容易忽视的细节:editor 类型的输入会打开系统编辑器,很多开发者第一次用时以为界面卡住了。如果你希望提交体验是纯命令行的,建议把这个类型改成 input,或者用最大长度控制代替编辑器交互。这类交互细节直接决定了适配器在团队里的口碑。
4.4 本地验证与调试技巧
写完之后,先别急着挂在 Commitizen 上,直接在项目里用一段脚本验证 prompt 逻辑:
js复制// test.js
const inquirer = require('inquirer');
const adapter = require('./index');
adapter.prompt(inquirer).then((result) => {
console.log('生成的提交信息:');
console.log(result.commit);
});
运行 node test.js,手动玩一遍所有分支,重点检查这些情况:
- scope 留空时,输出没有空括号;
- subject 超长时会抛出明确的错误;
- 确认破坏性变更后,footer 格式正确。
如果你希望看到 Commitizen 本身的加载过程,可以设置:
bash复制DEBUG=commitizen:* git cz --dry-run
它会输出从适配器解析到最终执行 Git 命令的完整日志,这个开关在排查路径问题时特别有用。
5. 把适配器接进提交链路:husky + commitlint + cz
5.1 一次完整提交的路径
适配器不是孤立工作的。一个完整的提交链路通常是这样:
code复制git cz
-> 加载 adapter
-> 交互式收集 answers
-> 渲染 commit message
-> 触发 git commit
-> 触发 husky 的 commit-msg 钩子
-> commitlint 校验信息
-> 通过则提交成功,不通过则终止
把这个链路讲清楚,是为了让你意识到一个问题:适配器负责让提交变得容易,commitlint 负责让提交变得正确。 两者是配合关系,不是替代关系。适配器里校验了一次 subject 长度,commitlint 里再校验一次 header 格式,并不会造成冗余,因为开发者也可能绕过 git cz,直接用 git commit -m。
5.2 配置文件怎么落位
项目根目录的 package.json 里放适配器路径:
json复制{
"config": {
"commitizen": {
"path": "cz-team-convention"
}
}
}
commitlint 的配置放 commitlint.config.cjs:
js复制module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', ['feat', 'fix', 'refactor', 'chore']],
'subject-max-length': [2, 'always', 100]
}
};
husky 的钩子配置取决于你用的版本。husky 7 以前是在 package.json 里写:
json复制{
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
}
husky 7 以上推荐在 .husky/commit-msg 文件里写:
bash复制npx --no -- commitlint --edit "$1"
新版配置方式更清晰,但如果团队里有人机器上的 husky 没重新安装钩子,可能会遇到 hook 不生效的情况,这时候跑一遍 npx husky install 能解决大部分问题。
5.3 适配器和 commitlint 的规则同步
适配器里的 choices 和 commitlint 的 type-enum 必须一致。我见过最经典的翻车现场是这样的:适配器列表里写的是 feat、fix,但 commitlint 配置沿用了某个内部标准枚举 feature、bugfix。开发者在交互界面里自信地选完,提交时直接被 hook 打回,来回两三次,别人就开始怀疑这套工具链是来添乱的。
建议把所有规则沉淀到一个共享配置文件里,适配器和 commitlint 都从它读取。至少也要做一个注释互指,改了一边必须同步另一边。
5.4 lint-staged 与交互式命令的冲突
另一个经常被人忽略的点是 lint-staged 和 git cz 的配合。lint-staged 默认会尝试重新暂存被修改的文件,如果你在 pre-commit 钩子里跑了代码修复命令(比如 eslint --fix),这些文件会被重新写入。绝大多数场景下没问题,但在某些插件或文件监听场景下,交互式命令和 staged 内容快照会形成诡异的竞争。我的经验是:预提交阶段的 lint/format 尽量做成幂等的,并在执行后重新 git add;提交信息层面则完全交给 commit-msg 钩子兜底,不要在处理提交信息的环节里混入文件级操作。
6. 日常使用中最容易踩的坑
6.1 适配器生成的消息被空格和换行破坏
有一次我发现提交信息变成了这样:
code复制feat(ui): 新增按钮
type 和 subject 之间出现了两个空格。原因是我在拼接时没有统一处理用户的输入,有人习惯在 subject 前加一个空格,format 里又没有 trim()。这类问题只靠肉眼看很难发现,因为单个空格在 Git 历史里很隐蔽。解决方法是所有输入都经过 trim(),并用 head 长度作为一个整体去校验,不要分别校验再拼接。
6.2 “No adapter found” 与 path 配置
运行 git cz 时提示找不到适配器,这种问题大多出在三种情况:
config.commitizen.path指向的包没安装;.czrc文件里的路径写错了;- 全局 Commitizen 和项目本地适配器版本不匹配。
排查顺序建议是:先确认 npm ls cz-xxx 有输出,再看 package.json 里的 config.commitizen.path,最后清理 npm 缓存重装一次。如果项目使用的是 npx cz,要特别留意 npx 在当前目录查找模块的机制,有时候会落到全局目录里找不到项目依赖,那就显式指定路径:
json复制{
"path": "./node_modules/cz-conventional-changelog"
}
6.3 Windows 下的路径与 shell 兼容性
Windows 上踩坑主要集中在脚本调用方式上。package.json 的 script 里如果直接写 commitizen init 或 git cz,在不同 shell 下的表现会有差异,尤其是在项目路径带空格时。更稳妥的做法是用 npx cz 统一入口。另外,如果你的自定义适配器里有路径相关逻辑(比如读取某个配置文件),尽量用 path.resolve(__dirname, ...) 而不是相对于当前工作目录的写法,因为交互式命令可能从项目任意子目录发起。
6.4 CI 环境不要用交互式适配器
这是一个容易被忽视的原则。在 CI 环境里,不存在一个能够交互的终端,任何 cz 命令都会挂在等待输入的阶段。如果自动化流程需要提交代码,直接使用 git commit -m,并让 commitlint 去校验信息的格式。或者,为 CI 场景准备一个专门的非交互式脚本,传入结构化信息,渲染出完整的提交消息:
bash复制git commit -m "$(node scripts/build-commit-message.cjs --type feat --scope ui --subject "新增主题")"
这种方式既能保证信息稳定,又不会把 CI 进程卡在伪终端里。另一个相关经验是:如果你用 Docker 跑构建镜像,镜像里没有全局安装 git-cz,而 package.json 里又写死了 git cz,也会出现类似问题。CI 环境的一切命令都要假设没有交互能力。
我在实际使用中还有一个体会:刚开始引入 Commitizen 时,先别急着换花式适配器,直接用 cz-conventional-changelog 跑上两周,等团队养成了“提交前分类”的习惯,再按自己的业务场景去定制适配器也不迟。自研适配器真正的回报,是在团队规模上来后体现的——那时每个人对分支名、版本号、变更类型的理解都不一样,一个与团队业务紧耦合的交互流程,比任何文档都更能减少摩擦。如果你后面打算做整个团队的工程效能建设,从 Commitizen 适配器入手,是一个性价比非常高的起点。
