
刚接手一个新项目那会儿我打开git log映入眼帘的是清一色的 update、fix、aaa 这样的提交信息有些甚至直接是一条空白提交。想搞清楚某个改动到底是修了bug还是加了功能只能逐个翻代码比对一天下来头昏脑涨。更麻烦的是到月底要做版本发布时完全没法按提交记录生成可靠的变更清单。就是从那时候起我开始系统性地研究提交信息规范也接触到了 Commitizen 这套交互式提交工具。Commitizen 做的事情其实很单纯把写提交信息从直接在终端里敲git commit -m xxx变成一个交互式向导。它按你预先定义好的规范一步步问你这次提交的类型是什么影响范围是哪个模块做了什么事情有没有关联的issue最后替你构建出一条结构标准、语义明确的提交信息。这背后依赖的是一套名为 Conventional Commits 的提交规范和围绕它衍生的 commitlint、husky、lint-staged 等工程化配套。这篇文章我会把这套体系完整拆开从规范本身的设计思路到 commitizen 的适配器原理再到实际项目中的配置落地和那些网上很少写到的坑。无论你是一个人维护开源项目还是在一个几十人的团队里做 code review这套东西都能让你的提交历史从一团乱麻变成一份清晰的项目档案。1. 当 git log 变成灾难现场为什么提交信息值得花力气规范很多开发者觉得提交信息是小事功能跑通了push上去就行了。这个想法短期看没什么问题但项目活过一年、参与人数超过五个你就会发现 git log 本身就是最被低估的文档。1.1 像写代码一样写提交信息我在项目里见过一个特别典型的例子同事花了两天做了一个支付模块的重构提交信息只写了更新支付逻辑。过了一个月产品经理问当时为什么要改支付逻辑大家打开代码一脸茫然最后翻了整整一天的聊天记录才拼凑出当时的决策过程。如果使用 Conventional Commits 规范这条提交应该长这样refactor(payment): 重构支付流程以支持多商户分账 - 将支付网关调用拆分为独立服务 - 增加分账比例配置项 - 统一异常处理中间件 Closes #234这条信息解决了三个问题类型refactor告诉我这是行为不变的重构scopepayment告诉我影响范围是支付模块正文告诉我具体的改动点和关联的issue。这样的提交信息可以被检索、被统计、被自动化工具消费而不是躺在历史里当摆设。1.2 规范带来的不仅仅是整洁提交信息规范化以后受益的远远不止是看起来舒服。我现在做代码审查扫一眼提交信息就能判断变更的意图和风险级别不需要先拉下来看一遍 diff 才知道对方想干什么。做版本发布时配合 conventional-changelog 工具可以直接根据提交记录生成 CHANGELOG自动把新功能、bug修复、破坏性变更分门别类整理好。对于开源项目来说规范提交还有一个额外的好处语义化版本号的自动推断。commit-type 是 feat 就升 minor是 fix 就升 patch有 BREAKING CHANGE 就升 major。这套联动机制在 semantic-release 等工具里已经非常成熟人工审版本的环节可以完全省略。1.3 规范是手段不是目的不过我想强调一点引入 Commitizen 不是为了给开发者增加负担反而是为了减少负担。规范如果靠制度去约束、靠 reviewer 在 code review 时逐一提醒效果差还容易引发矛盾。把规范变成一个交互式向导让工具在提交那一刻就引导开发人员走完整个流程才是可持续的方案。这就是 Commitizen 在整套体系里的真正位置——它不是一个校验器而是一个引导器。2. Conventional Commits 规范拆解把提交信息变成一段结构化数据Commitizen 的交互问答并不是随便设计的它完全围绕 Conventional Commits 的字段模型展开。理解了这个模型才能理解为什么问答要按那个顺序进行以及自定义配置时各个选项到底在控制什么。2.1 一条规范提交的完整结构一条符合 Conventional Commits 规范的提交信息由 header、body、footer 三部分组成。type(scope): subject -- 空行 -- body -- 空行 -- footerheader 是必须的body 和 footer 可有可无但推荐在改动较大时填写。type 是提交类型常见的有 feat新功能、fixBug 修复、docs文档变更、refactor重构既不是修bug也不是加功能、test测试相关、chore构建或辅助工具变更等。scope 是影响范围一般用模块名或包名比如 payment、user、router也可以省略。subject 是对这个提交的简要描述要求现在时态、小写开头、结尾不带句号——这其实是模仿了英文 commit 社区的习惯中文场景下可以适当放宽但时态的要求不受影响。body 用来补充细节可以说清楚为什么这么做、怎么做的也可以列举注意点。footer 里通常放破坏性变更说明和关闭的 issue 编号比如BREAKING CHANGE: xxx或Closes #123。2.2 为什么需要这么严格的格式这个结构最大的价值在于机器可解析。当你写的是自由文本时修复了用户无法登录的问题和fix(login): correct authentication flow在人类眼里都能懂但前者没有一个统一模式能让脚本处理。而后者可以非常容易地通过正则解析出 typefix、scopelogin、descriptioncorrect authentication flow。有了这种可解析的数据结构工具链的想象力就被打开了。刚才提到的 changelog 自动生成只是最基础的用法。更进一步CI 流程里可以检查当前分支的提交是否包含某些特定类型的变更来决定是否触发对应的流水线发布系统可以自动判断版本号需要从 patch 还是 minor 开始代码审查工具可以给不同 type 的提交附加不同的审查检查项。这些自动化全部建立在信息的结构化之上。2.3 Commitizen 在规范里的位置Commitizen 本身并不理解 Conventional Commits 是什么它只是一个交互框架。真正决定问什么问题、如何把答案拼成提交信息的是适配器adapter。官方生态里最常用的cz-conventional-changelog就是为 Conventional Commits 规范定制的一款适配器。我把它理解成一个两层结构Commitizen 负责提供对话环境适配器负责定义对话剧本。Adaptor 向 commitizen 注册一个prompter函数这个函数接收一个 commit 对象或者一个 resolve 回调通过命令行交互库通常是 inquirer逐个询问每个字段然后把收集到的答案按预定的模板拼接成最终的提交信息。理解了这个关系后面做自定义适配器的时候就清楚该从哪里下手了。3. 从零搭建一套完整的交互式提交工作流安装、配置、钩子校验只装一个 commitizen 其实只是完成了生成规范信息的一半如果团队里有人偏不用交互式命令直接git commit -m 随便写规范照样会被绕过。所以我习惯把整条链路都配齐commitizen 负责引导生成commitlint 负责卡住不合规的提交husky 把校验嵌进 git 提交的钩子里lint-staged 顺便把暂存区的代码格式化也做了。3.1 安装 Commitizen 和适配器在项目里按下面这几步安装。先用 npm 安装 commitizen 作为开发依赖同时安装 cz-conventional-changelog 适配器npm install --save-dev commitizen npm install --save-dev cz-conventional-changelog然后在package.json里声明提交时使用的适配器路径{ scripts: { commit: cz }, config: { commitizen: { path: ./node_modules/cz-conventional-changelog } } }之后在项目里不要直接敲git commit而是运行npm run commit就会进入 Commitizen 的交互问答界面。这里有个容易踩的细节config.commitizen.path指向的适配器路径可以写成包名也是写相对路径在有别名的包管理方式下建议直接指向 node_modules 里的具体路径避免解析偏差。3.2 用 commitlint 补上校验闭环Commitizen 只保证用它的命令提交时是规范的但拦不住别人用原生的git commit。因此必须加校验。安装 commitlint 和它的 conventional 配置包npm install --save-dev commitlint/cli commitlint/config-conventional新建.commitlintrc.jsmodule.exports { extends: [commitlint/config-conventional], rules: { type-enum: [2, always, [feat, fix, docs, style, refactor, performance, test, build, ci, chore, revert]], subject-case: [0] } };这段配置的意思是提交信息的 type 必须在一个枚举集合里subject 的大小写不做限制这样中文描述场景下不会被强制要求小写开头。[0]表示关闭该规则后面的数字含义是0 关闭、1 警告、2 错误。如果你希望规范化再强一点把 subject-case 设成[2, never, []那种程度也可以但实际体会下来中英文混写容易误伤推荐先关掉。3.3 配置 husky 钩子并把整个流程串起来husky 是往 git hooks 里注入命令的工具。新版 huskyv9的配置方式和老版本差别比较大我直接给出目前推荐的 v9 初始化方式npm install --save-dev husky npx husky init初始化之后项目里会生成一个.husky/目录里面有一个pre-commit示例钩子。我们需要做两件事pre-commit里跑 lint-staged 做暂存区检查commit-msg里跑 commitlint 校验提交信息。先装 lint-stagednpm install --save-dev lint-staged然后在package.json里加 lint-staged 配置{ lint-staged: { src/**/*.{js,ts,vue}: [eslint --fix, prettier --write] } }接着修改.husky/pre-commit和新建.husky/commit-msg# .husky/pre-commit npx lint-staged# .husky/commit-msg npx --no -- commitlint --edit $1如此配置完毕后整个工作流变成了这样开发者在终端执行npm run commitCommitizen 通过交互问答生成一条规范的提交信息。执行 git commit 时husky 的 pre-commit 钩子触发 lint-staged对暂存区的代码做格式修复。提交信息写入后commit-msg 钩子触发 commitlint校验信息是否符合规范不符合就 abort 本次提交。这套链路相当于在生成端和校验端两头都上了保险。3.4 快速给整个项目重新初始化如果是老项目要补这套流程不用手动改文件。commitizen 提供一个初始化命令npx commitizen init cz-conventional-changelog --save-dev --save-exact它会自动安装适配器包、写入 package.json 的相关配置。不过这个命令只处理 commitizen 部分commitlint 和 husky 还是得手动配。我实际使用后觉得还是手动改 package.json 更可控尤其当项目里已经有 lint-staged 配置时init 命令有时候会把别的字段顶掉。4. 交互式提交流程实操一步一步走完 Commitizen 的问答配置都完成了现在实际跑一遍npm run commit看看 Commitizen 的交互到底长什么样每一步又对应规范里的哪个字段。4.1 一个完整的实际操作演示输入npm run commit后终端出现第一个问题? Select the type of change that youre committing: (Use arrow keys) ❯ feat: A new feature fix: A bug fix docs: Documentation only changes style: Changes that do not affect the meaning of the code refactor: A code change that neither fixes a bug nor adds a feature performance: A code change that improves performance test: Adding missing tests or correcting existing tests build: Changes that affect the build system or external dependencies ci: Changes to our CI configuration files and scripts chore: Other changes that dont modify src or test files revert: Reverts a previous commit这里选中的 type 会放进最终提交信息的type位置。它本质上是一组枚举值配合 commitlint 的 type-enum 规则两边保证一致。接下来会问影响范围? What is the scope of this change (e.g. component or file name): (press enter to skip)这个对应scope。按回车可以留空比如改的是全局样式、跨模块的基础设施这种没法归到某个单模块的改动可以空着。然后是提交描述? Write a short, imperative tense description of the change (max 94 chars):这一步就是subject。有一个细节Commitizen 底层做了字数校验超过 94 个字符会提示重新输入。这个 94 是来自 git 的一条约定因为老版本的 git 对 commit message 的首行长度很敏感过长了在部分终端和网页 UI 里会显示得很糟糕。虽然现在很多平台是自适应换行但养成短描述的习惯没有坏处。接着问正文? Provide a longer description of the change: (press enter to skip)这里写 body按回车跳过也没问题。最后问是否有破坏性变更和是否需要关联 issue? Are there any breaking changes? (y/N) ? Does this commit affect any open issues? (y/N)这样走下来Commitizen 会把答案拼成一条完整提交信息显示出来确认后写入。4.2 跳过问答直接用参数提交有些场景不适合交互比如在 CI 脚本里或者批量提交时。Commitizen 支持非交互模式你可以用git commit配合符合规范的 -m 参数直接提交保持格式一致只要信息本身符合 commitlint 规则即可git commit -m fix(login): correct authentication flow for OAuth users -m Closes #123这里的-m可以多个第一个是 header第二个是 body会拼在提交信息里。这种写法在自动化脚本里很常用注意第一个-m的内容一定要符合规范否则会被 commit-msg 钩子拦下来。4.3 交互问答和提交钩子的配合顺序这里有一个很多人第一次用的时候会困惑的点npm run commit生成的提交信息最终也会走 husky 的 commit-msg 钩子去校验。有的团队问这不是多此一举吗Commitizen 生成的怎么会不合规——还真的会。如果你自定义了适配器的 type 枚举但 commitlint 的type-enum规则没同步更新就会出现 Commitizen 生成的信息被 commitlint 拒收的尴尬情况。所以我在团队里反复强调适配器的枚举和 commitlint 的规则必须是一份配置的两个投影改了一边另一边要同步改。否则这个闭环就会在某一环卡死。5. 自定义适配器把提交问答改造成团队自己的样子官方适配器 cz-conventional-changelog 对大多数项目够用了但也有人觉得选项太多、scope 列表要固定成下拉菜单、正文模板需要包含测试说明等。这时候就该做自定义适配器了。5.1 cz-customizable不写代码的定制方案如果你不想写完整的适配器代码社区里有cz-customizable这个包它把适配器做成了纯配置驱动。安装方式npm install --save-dev cz-customizable在 package.json 里把配置改为{ config: { commitizen: { path: node_modules/cz-customizable } } }然后新建.cz-config.jsmodule.exports { types: [ { value: feat, name: feat: 新功能 }, { value: fix, name: fix: Bug 修复 }, { value: docs, name: docs: 文档变更 }, { value: refactor, name: refactor: 代码重构不改变行为 } ], scopes: [core, user, payment, router, components], messages: { type: 请选择提交类型:, subject: 请填写简短描述:, body: 请填写详细描述可回车跳过:, footer: 请填写关联 issue可回车跳过: } };这种方式的优点是显而易见的——不需要写代码scope 可以做成固定下拉团队新人看到选择项就明白该选什么。缺点是cz-customizable的维护活跃度一般如果你用的 Node 版本太新可能会遇到依赖兼容问题。这时候就得考虑自己写适配器了。5.2 手写一个 Adapter 涉及哪些核心逻辑Commitizen 的适配器本质上是一个 CommonJS 模块对外暴露一个prompter函数。函数签名长这样const path require(path); const { promisify } require(util); const inquirer require(inquirer); async function prompter(cz, commit) { const questions [ { type: list, name: type, message: 请选择提交类型:, choices: [ { name: feat: 新功能, value: feat }, { name: fix: Bug 修复, value: fix } ] }, { type: input, name: subject, message: 请填写简短描述:, validate: (input) input.length 0 || 描述不能为空 } ]; const answers await inquirer.prompt(questions); const message ${answers.type}: ${answers.subject}; commit(message); } module.exports { prompter };commitizen加载适配器时会往prompter里传入两个参数一个是 commitizen 内置的cz对象除了用于加载第三方提交信息生成库大多数自定义场景下用不到另一个是commit回调函数把最终拼好的提交信息字符串传给它commitizen 就会拿去执行真正的 git commit。如果你需要收集的问题很多inquirer 还支持分步提问、条件跳过比如只有 type 选了 feat 才继续问 breaking change。这些交互逻辑完全掌握在自己手里。5.3 自定义时最容易翻车的三个细节第一个坑是关于 type 的枚举一致性。自定义适配器里的 choices 和 commitlint 的type-enum规则要严格对应。上面这个例子如果 commitlint 只允许feat和fix两个值没问题但如果你加了docs却忘了更新 commitlint 规则提交时就会被拦下。第二个坑是 subject 的换行问题。当你用模板拼接多层信息时要注意在 header 和 body、body 和 footer 之间保留空行。Conventional Commits 规范对空行是很敏感的解析器通常以空行作为分节的标记。我看过有人自定义的模板拼出来是feat: xxx yyy zzz整个信息被解析成了一句很长的 subject正文完全失效。正确的模板应该用\n\n分隔不同部分。第三个坑是不要把自定义适配器做成过度复杂的东西。有一个团队找到我说要加十几个问题每个提交都要花两分钟去回答最后成员们宁可绕过 commitizen 直接git commit --no-verify也不愿意走流程。这个工具是为人服务的交互的节奏要快默认项要合理能让回车解决的就不要让人手动选。6. 真实项目里的踩坑记录这些坑网上很少写清楚配置和原理都过了一遍我再把实际推进这套流程时踩过的几个比较典型的坑整理出来每个都是花过时间的教训。6.1 husky 版本升级导致的钩子失效有一阵子我们的 pre-commit 钩子突然不跑了代码检查全靠 CI 里兜底才发现问题。排查到最后发现是某次依赖安装时把 husky 从 v4 升到了 v9。husky v4 的配置写在 package.json 里的husky: { hooks: {...} }字段而 v9 改成了根目录.husky/文件加npx husky init的机制。如果项目是从老版本升级上来的包变了但配置还留在 package.json 里钩子自然不生效。排查思路可以记一下先手动执行钩子脚本看有没有权限问题sh .husky/pre-commit再用git config core.hooksPath确认 git 指向的 hooks 路径是不是项目的.husky目录。如果这两个都没问题再看一下依赖安装时 husky 的 prepare 脚本有没有执行。6.2 Windows 环境下中文输入的问题Commitizen 的交互式问答使用 inquirer 库在 Windows 的 CMD 和 PowerShell 下中文字符的展示和输入偶尔会出现乱码或光标错位。这个问题不是项目配置能解决的本质上是终端编码和 inquirer 渲染层的兼容性问题。一个比较实用的方案是统一要求团队使用 Windows Terminal 或 VS Code 集成终端并且在系统设置里把默认代码页切到 UTF-8。体验比自带的 conhost 好很多交互式问答的渲染基本不会出错。另外提交信息里的中文在 git log 里的显示有时候会乱可以在项目里加.gitattributes或者在全局 git 配置里设置core.quotepath false。6.3 非交互提交怎么保证规范前面提过 CI 和脚本场景用-m直接提交那怎么保证这种提交信息也符合规范呢答案是照样被 commit-msg 钩子拦。只要钩子配置了npx --no -- commitlint --edit $1不管提交信息是怎么生成的提交动作本身一定会经过 commitlint 校验。但有一个例外情况值得注意如果你的 CI 脚本还执行了git commit --no-verify有些依赖自动生成的 bot 机器人提交会这么做commitlint 就不会生效。对这种提交我建议不要用--no-verify绕过校验而是生成时就把信息写规范。绕过了校验的提交信息质量纯粹靠 bot 脚本的模板质量保证一旦模板出问题历史又变回一团乱麻。6.4 Monorepo 场景下让 scope 和包名联动现在很多团队用 pnpm workspace 或 lerna 管理多包仓库不同子包的提交需要标出对应包名。遇到这种情况把.commitlintrc.js里的 scope-enum 配置成动态读取 workspace 包名列表会很顺手const fs require(fs); const path require(path); const packages fs .readdirSync(path.resolve(__dirname, packages)) .filter((name) fs.existsSync(path.join(__dirname, packages, name, package.json))); module.exports { extends: [commitlint/config-conventional], rules: { scope-enum: [2, always, packages] } };这样在 Monorepo 里提交时scope 必须是 packages 目录下真实存在的包名从机制上防止了改了这个包却标了另一个包的错误。Commitizen 适配器那边如果用 cz-customizablescopes 也读取同样的列表两边保持一致。7. 给团队的推广建议先让工具值得用再谈规范最后一个部分聊点软的但我觉得比技术配置更影响这套体系能不能真正落地——怎么让团队成员愿意用它。7.1 先解决团队真正的痛点如果一个项目平时根本不需要生成 changelog也没有人翻历史提交记录硬要推规范提交反而显得做作。我在推这套流程时都是先找到团队真正的痛点比如每次发布时手工写 changelog 很痛苦或者 code review 时总要先问这个提交到底改了什么。把规范提交和这些痛点挂上钩大家才会觉得这不是平白多出来的形式主义。我们的做法是先用一个短的迭代两周把所有提交规范起来之后发布直接跑自动化 changelog 生成效果立竿见影。行政命令配上了真实收益推广阻力就小了很多。7.2 让拒绝交互的新成员有退路不管你交互做得多顺总有人习惯直接敲git commit -m。我的建议是不必强制所有人必须用npm run commit只要最终提交信息符合 commitlint 规则就行。Commitizen 是给不知道怎么组织信息的人用的引导工具而熟悉规范的人完全可以手写规范信息。工具被跳过不是坏事因为校验那一关还在兜底。7.3 模板和预设能少则少最后分享一个我个人的体会自定义适配器时尽量克制。交互里每多一个问题都是对使用者耐心的消耗。整体问答控制在四到五步最优type、scope可跳过、subject、breaking change 判断、关联 issue 判断。如果你们有 Jira 或 GitHub Projects 管理需求再加一个 issue 编号输入。再多建议想想是不是有其他环节能吸收这些信息而不是全都塞进提交信息里。这两天我刚好在处理一个项目从无规范到全面落地的过程把上面这套东西一步步补全后新的提交历史已经能直接用于生成变更记录回头看 commitizen 做的那层交互——它不是花架子它在人写和机器读之间搭了一座桥。