get-shit-done 的 Changeset-fragment 工作流:用 per-PR 碎片从根本上消灭 CHANGELOG 合并冲突
导读
在多人协作的仓库里,直接编辑 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
### Fixedblock ofCHANGELOG.mdalways conflict on merge — git can't pick a serialization order without human input. Two PRs that each add a fresh.changeset/<unique-name>.mdnever conflict because they don't share lines.
仓库中负责这项能力奠基的正是本次关联文档 .changeset/eager-hawks-rally.md——它自身就是一个 type: Added、pr: 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 保持一致:Added、Changed、Deprecated、Removed、Fixed、Security。该白名单同时存在于两处源码中:写侧 scripts/changeset/new.cjs 的ALLOWED_TYPES(写入前校验),以及读侧 scripts/changeset/parse.cjs 的ALLOWED_TYPES(解析时校验)。pr:必须是正整数,发布渲染时会以(#NNNN)的形式拼接到 CHANGELOG 条目的末尾。- body 建议用加粗命令名开头的单句描述,聚焦"用户可见的变化",把 bug 修复与行为变更写得让读者一眼可懂。
仓库的 .changeset 目录里堆满了真实范例:本次关联的 eager-hawks-rally.md(Added,宣告本工作流上线)、2937-statusline-context-position.md、3195-quick-resurrection-guard.md、3698-... 等数百个历史碎片,恰好也是检索各版本变更的第一手语料。
三、投递一个碎片:脚手架命令与「随机三词」命名
手工编写容易出错(比如 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.md、eager-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:render(package.json),并支持 --repo <dir> 指定仓库路径与 --json 输出结构化报告。render 的整体流水线是:
- 读取
.changeset/下所有.md碎片(自动排除README.md),逐个交给 scripts/changeset/parse.cjs 解析; - 合并:用 scripts/changeset/render.cjs 按类型分组,并严格按 Keep a Changelog 的固定小节顺序
Added → Changed → Deprecated → Removed → Fixed → Security输出(见 SECTION_ORDER); - 落盘:
splitChangelog把现有CHANGELOG.md拆成"文件头 + 历史区块",用新生成的## [vX.Y.Z] - YYYY-MM-DD区块替换掉旧的## [Unreleased]占位块,再在最顶部重新打开一个全新的## [Unreleased](见 cli.cjs); - 清理:序列化写回后删除所有已消费碎片文件。
整个过程幂等——任何一步失败可安全重跑;即使某次 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_frontmatter、missing_type、invalid_type、missing_pr、invalid_pr、empty_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.cjs 与 changeset-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,都请记得投递一个自己的碎片:为下一个发布周期留下一句清晰、独立、永不冲突的变更注脚。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0630
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00