首页
/ get-shit-done 的 Changeset-fragment 工作流:用 per-PR 碎片从根本上消灭 CHANGELOG 合并冲突

get-shit-done 的 Changeset-fragment 工作流:用 per-PR 碎片从根本上消灭 CHANGELOG 合并冲突

2026-09-07 21:19:54作者:宣聪麟

导读

在多人协作的仓库里,直接编辑 CHANGELOG.md 几乎注定会产生合并冲突——两个 PR 同时往同一段 ### Fixed 块追加文本时,git 无法替人类决定先后顺序。get-shit-done(GSD)通过 changeset-fragment 工作流(issue #2975 引入)解决了这一顽疾:每个 PR 各自投递一个位于 .changeset 目录的碎片文件,由发布流程在发版时统一聚合进 CHANGELOG.md 并删除已消费的碎片。本文结合仓库内真实源码与测试,完整拆解这套工作流的文件格式、CI 强制规则、发布渲染管线与底层解析实现,读完你可以直接在任意 PR 中正确投递、复核甚至扩展自己的 changeset 碎片。


一、问题根源:为什么并发编辑 CHANGELOG.md 必然冲突

先看这场工作流要解决的核心矛盾。传统做法里,每个改动 CHANGELOG 的 PR 都在往同一个文件的同一个区块追加文本。两个 PR 并发时,git 无法在不借助人工的情况下为两段追加内容挑选一个序列化顺序——于是只要两个 PR 同时触碰 ### Fixed 之类的区块,合并几乎必炸。

解法思路:把「人人共享的同一行」换成「每人独占的独立文件」。两个 PR 各自新增一个文件名唯一的 .changeset/<随机名>.md,因为彼此不共享任何行,就永远不会产生合并冲突。三个随机单词组成的文件名,保证了并发 PR 之间近乎不可能撞名。这一思路在 .changeset/README.md 中被一句话概括:

Two PRs that both edit the ### Fixed block of CHANGELOG.md always conflict on merge — git can't pick a serialization order without human input. Two PRs that each add a fresh .changeset/<unique-name>.md never conflict because they don't share lines.

仓库中负责这项能力奠基的正是本次关联文档 .changeset/eager-hawks-rally.md——它自身就是一个 type: Addedpr: 2975 的碎片,宣告了整条工作流的诞生。


二、碎片文件的格式规范:frontmatter + Markdown body

一个 fragment 就是 CHANGELOG.md 中某条条目的"半成品"。按 .changeset/README.md 的格式规范,它由一个 YAML frontmatter 块和一个 Markdown body 组成:

---
type: Fixed
pr: 1234
---
**`/gsd-foo` no longer drops trailing slashes** — explain the user-visible change.
  • type: 只允许六种取值,与 Keep a Changelog 保持一致:AddedChangedDeprecatedRemovedFixedSecurity。该白名单同时存在于两处源码中:写侧 scripts/changeset/new.cjsALLOWED_TYPES(写入前校验),以及读侧 scripts/changeset/parse.cjsALLOWED_TYPES(解析时校验)。
  • pr: 必须是正整数,发布渲染时会以 (#NNNN) 的形式拼接到 CHANGELOG 条目的末尾。
  • body 建议用加粗命令名开头的单句描述,聚焦"用户可见的变化",把 bug 修复与行为变更写得让读者一眼可懂。

仓库的 .changeset 目录里堆满了真实范例:本次关联的 eager-hawks-rally.mdAdded,宣告本工作流上线)、2937-statusline-context-position.md3195-quick-resurrection-guard.md3698-... 等数百个历史碎片,恰好也是检索各版本变更的第一手语料。


三、投递一个碎片:脚手架命令与「随机三词」命名

手工编写容易出错(比如 frontmatter 缺字段、类型拼错),仓库为此提供了脚手架命令。推荐走 package 脚本(见 package.json"changeset": "node scripts/changeset/new.cjs"):

npm run changeset -- --type Fixed --pr <YOUR_PR_NUMBER> \
  --body "**\`/gsd-foo\` no longer drops trailing slashes** — explain the user-visible change."

等价地,也可以直接调用源码:CONTRIBUTING.md.changeset/README.md 中给出的底命令是:

node scripts/changeset/new.cjs \
  --type Fixed \
  --pr 1234 \
  --body "fix the thing — explain the user-visible change in one sentence"

命令会写入 .changeset/<adjective>-<noun>-<verb>.md,例如 silly-bears-dance.mdeager-hawks-rally.md。三个随机单词意味着并发 PR 不会撞名——这正是整套设计对抗合并冲突的关键。

从源码看,脚手架在 scripts/changeset/new.cjs 中维护了三张词表:40 个形容词 × 40 个名词 × 40 个动词,共 64,000 种组合。文件名生成为纯随机抽取;写入使用 writeFileSync(..., { flag: 'wx' }) 的原子创建语义——当目标已存在时立刻抛 EEXIST 错误,随即重新抽名重试,最多尝试 16 次;若 16 次全部撞名则响亮报错并提示扩充词表或检查 .changeset/ 目录状态(见 scaffoldFragment)。这里同时体现了仓库的一贯安全纪律:类型白名单校验发生在写入边界,防止换行符之类的脏值被塞进 frontmatter。

scaffoldFragment 内部还会对 --type 先做一次白名单校验、再校验 --pr 等参数缺失情况,参数解析错误会以 exit code 2 与 usage 信息退出。


四、CI 强制执行:什么改动必须带碎片

光有规范不够,还要有机器把关。变更记录 lint 实现在 scripts/changeset/lint.cjs,由 npm run lint:changeset 触发(package.json)。它的判定核心是一个无副作用的纯函数 evaluateLint({ changedFiles, labels }),返回类型化的 { ok, reason } 结论,reason 取自冻结枚举 LINT_REASON

枚举值 含义
OK_FRAGMENT_PRESENT diff 中带了新的 .changeset/*.md,通过
OK_OPT_OUT_LABEL no-changelog 标签,显式退出,通过
OK_NO_USER_FACING_CHANGES 只改了非用户可见文件,无需碎片,通过
FAIL_MISSING_FRAGMENT 触碰用户可见文件却没有碎片,拒绝

哪些文件算"用户可见"?lint.cjs 中定义了两类:

const USER_FACING_PREFIXES = [
  'bin/', 'get-shit-done/', 'agents/',
  'commands/', 'hooks/', 'sdk/src/', 'sdk/prompts/',
];
const USER_FACING_FILES = new Set(['CHANGELOG.md']);
  • 前缀命中bin/get-shit-done/agents/commands/hooks/sdk/src/sdk/prompts/ 下任何改动都必须携带碎片;
  • 精确命中:把 CHANGELOG.md 本身单独列为用户可见文件,堵死了"直接手改 CHANGELOG 绕过工作流"的后门——tests/changeset-lint.test.cjs 里专门有一条用例锁死这一行为;
  • 不命中tests/.github/workflows/docs/ 等文件改动属于非用户可见,天然放行。

判定顺序值得注意(evaluateLint):先看是否带碎片、再看是否带退出标签、最后才看是否真的需要碎片。CI 包装器在 GitHub Actions 环境下从事件载荷读取 PR labels,并通过 git diff --name-only origin/<base>...HEAD(用 execFileSync + argv 数组,杜绝 shell 注入)计算改动文件集。

对应的行为级测试集中在 tests/changeset-lint.test.cjs,它断言的是结构化 verdict 而非错误文案:含碎片通过、纯测试改动放行、no-changelog 标签放行、以及直接编辑 CHANGELOG.md 必失败(防止绕过工作流)。


五、退出通道:no-changelog 标签

并非所有 PR 都对用户可见。测试重构、lint 规则调整、CI 配置微调、纯格式化改动这类确定没有用户影响的 PR,可以给 PR 打上 no-changelog 标签,CI 会放行。仓库的通行准则是:「拿不准是否属于用户可见变更时,就加上碎片」(When unsure, add the fragment)。

需要强调的是,标签退出与碎片一样在 lint 中留有痕迹——它针对的是整个 PR;若你希望更精细地在"混合型 PR"里逐条豁免,下文第六节还会介绍 per-fragment 的 docs-exempt 标记。


六、发布时刻:把碎片聚合渲染进 CHANGELOG.md

碎片在发布时被统一"回收"。渲染子命令位于 scripts/changeset/cli.cjs.changeset/README.md 给出的调用方式为:

node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD

或走 npm 脚本 npm run changelog:renderpackage.json),并支持 --repo <dir> 指定仓库路径与 --json 输出结构化报告。render 的整体流水线是:

  1. 读取 .changeset/ 下所有 .md 碎片(自动排除 README.md),逐个交给 scripts/changeset/parse.cjs 解析;
  2. 合并:用 scripts/changeset/render.cjs 按类型分组,并严格按 Keep a Changelog 的固定小节顺序 Added → Changed → Deprecated → Removed → Fixed → Security 输出(见 SECTION_ORDER);
  3. 落盘splitChangelog 把现有 CHANGELOG.md 拆成"文件头 + 历史区块",用新生成的 ## [vX.Y.Z] - YYYY-MM-DD 区块替换掉旧的 ## [Unreleased] 占位块,再在最顶部重新打开一个全新的 ## [Unreleased](见 cli.cjs);
  4. 清理:序列化写回后删除所有已消费碎片文件。

整个过程幂等——任何一步失败可安全重跑;即使某次 unlink 失败导致 changelog 已写入而碎片仍残留在磁盘,命令也会以 exit code 1 + 结构化 fail_fragment_delete 明细告警,防止操作员在不知情时重跑导致双重消费(cli.cjs)。

同一 CLI 还提供了 github-release-notes 子命令(--from REF --to REF--repo-slug--install-command 等参数),用同一批碎片直接生成 GitHub Release Notes,保证两个发布出口共享同一事实来源。


七、源码级细节:文本 → 类型化 IR → Markdown 的干净分层

这套管线在设计上刻意遵循"解析出结构化中间表示(IR),序列化只是独立关心点"的分层,与仓库在 CONTRIBUTING.md 中"测试必须断言在类型化结构而非渲染文本上"的测试哲学完全同构。

7.1 解析:parse.cjs 产出类型化记录

parse.cjs 用正则切出 frontmatter 与 body,逐一校验字段。任何失败都返回冻结枚举 FRAGMENT_ERROR 中的稳定错误码(missing_frontmattermissing_typeinvalid_typemissing_prinvalid_prempty_body),而非自由文本——这是为了让测试只依赖稳定契约。解析时还要做三件精细的事:

  • body 原样保留:只在空值判断时 trim,其余保持字节级 verbatim(含代码块),保证 render → serialize 往返一致;
  • CRLF 兼容:Windows 作者写出的 \r\n 会被归一,避免残留 \r 干扰后续 (#NNNN) 后缀拼接(parse.cjs);
  • docs-exempt 标记抽取:body 若在独占一行上出现 <!-- docs-exempt: <reason> -->,则该碎片声明免于"变更必须同步文档"的 docs lint(关联 #3213)。原因参数必填且非空——裸 <!-- docs-exempt --> 会被拒绝,因为没有审计轨迹的豁免等于没有豁免;该标记在解析期就被剥离,永不泄漏进 CHANGELOG 或 Release Notes。正则锚定 ^...$ + m 标志,因此行内出现的标记语法(如反引号示例)不会被误判;[^\r\n>] 有界字符类保证线性时间复杂度,避免对抗输入下的灾难性回溯(parse.cjs)。

7.2 渲染:render.cjs 返回类型化 IR

render.cjs纯函数,不做任何文件 IO,返回形状固定的 Changelog IR:

{
  releaseHeader: { version, date },          // 本次发布版本与日期
  sections: [{ type, bullets: [{ pr, body }] }],  // 按小节顺序分组
  priorChangelog: '<历史区块> | null',
}

这个 IR 就是测试断言的对象——后续新增发布、格式调整都只动序列化层。

7.3 序列化与逆解析:serialize.cjs 的往返保证

serialize.cjs 把 IR 序列化成 Keep a Changelog 标准形态,每个碎片渲染为 - <body> (#<pr>)

## [vX.Y.Z] - YYYY-MM-DD

### Fixed

- **`/gsd-foo` no longer drops trailing slashes** (#1234)

同时它提供逆解析 parseChangelog,把 CHANGELOG.md 文本还原回同样的 IR。两者在良构子集上互为反函数,测试通过 parse(serialize(ir)) 往返而非比对文本,实现了对渲染层重构的完全免疫。

7.4 配套测试矩阵

仓库为这条链路的每个环节都配了行为级测试,可直接在 tests 目录按名检索:changeset-lint.test.cjs(lint 判定矩阵)、changeset-new.test.cjs(脚手架与随机命名)、changeset-parse.test.cjs(frontmatter 对抗输入与 docs-exempt 标记)、changeset-render.test.cjschangeset-serialize.test.cjs(IR 往返)、changeset-cli.test.cjs(render/github-release-notes 命令契约)。


八、文档同步约束与发布工作流协同

碎片系统并非孤立存在,它与文档纪律深度耦合。按 CONTRIBUTING.md 的规则:

  • type 为 Added / Changed / Deprecated / Removed 的碎片,PR 必须同时改动至少一个 docs/ 下文件(CI 由 scripts/lint-docs-required.cjs 强制);
  • Fixed / Security 豁免该 lint——bug 修复只是恢复文档已描述过的行为,不引入新行为;当然若修复顺带纠正了文档本身的错误,也应更新文档;
  • 需要豁免时走两条留痕通道:全 PR 级打 no-docs 标签并留言说明,或逐碎片在 body 加 <!-- docs-exempt: <reason> -->

因此一个合格的"用户可见变更"PR 通常同时携带三样东西:一个 .changeset/*.md 碎片 + 一份对应的 docs/ 更新 + 行为级测试。碎片既是 CHANGELOG 的素材源,也是 docs lint 与 release 脚本的共同输入。


九、开发者实操速查

场景 命令 / 操作 关键参数
创建碎片 npm run changeset `--type <Added
底层脚手架 node scripts/changeset/new.cjs 同上,另支持 --repo <dir>
本地跑 lint npm run lint:changeset 判定规则见 scripts/changeset/lint.cjs
发布渲染 npm run changelog:render / node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD 可追加 --repo <dir>--json
生成 Release Notes node scripts/changeset/cli.cjs github-release-notes --from REF --to REF [--output FILE] 可追加 --repo-slug--install-command
无用户影响的 PR 给 PR 打 no-changelog 标签 纯测试/CI/格式化改动
免文档更新 no-docs 标签,或 body 末行加 <!-- docs-exempt: <理由> --> 理由必填且非空

记忆锚点:本文关联的 .changeset/eager-hawks-rally.md 本身就是这套工作流上线时的宣告性碎片——先有规则,再有记录规则的记录。若你的 PR 改动涉及 bin/get-shit-done/agents/commands/hooks/sdk/src/sdk/prompts/ 中的任一前缀,或者直接触碰 CHANGELOG.md,都请记得投递一个自己的碎片:为下一个发布周期留下一句清晰、独立、永不冲突的变更注脚。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
594
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
516
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388