Skip to content

spec 生成物没有 merge driver:两个 PR 各改几行,语义上是集合运算,却每次都打成文本冲突 #4675

Closed

Description

@os-zhuang

症状

仓里没有 .gitattributes(整个仓一个都没有),所以 packages/spec 的生成物走默认的三路文本合并。这批文件是排序数组 / 追加式登记表,两个 PR 各自增删几行时,语义上是集合并与集合差、完全可组合,却表现为需要人工解决的文本冲突。

authorable-surface.json 是 315KB、8262 行的排序数组 —— 任何两个动它的 PR 只要落点相近就冲突。

2026-08-02 一个下午的实测

冲突 文件 结果
PR #4670(C8 RetryPolicy)vs #4664 authorable-surface.json、spec-changes.json、docs/protocol-upgrade-guide.md、conversions/registry.ts、migrations/registry.ts 全套门禁 + 全仓 typecheck 重跑一遍
PR #4662(C5 ActivationEvent)vs #4664 authorable-surface.json 重新生成 + 重跑
同上,期间 main 又前进到 #4668 authorable-surface.json + api-surface.json 再重跑一遍

三次冲突里没有一次是真正的语义冲突 —— 每次的正确解法都是「丢掉两边的文本,重新生成」。一个双源簇跑完整套门禁约十几分钟,而 v17 收尾期 main 上 spec PR 的密度让「跑完就已过期」成为常态。

根因不是「进了仓库」,是「以这个形式进了仓库」

这些文件必须提交(至少记忆类必须,见 #4676),但它们的合并语义不是文本级的。Git 对此有现成机制:自定义 merge driver。

建议

.gitattributes:

packages/spec/authorable-surface.json    merge=os-regen
packages/spec/api-surface.json           merge=os-regen
packages/spec/spec-changes.json          merge=os-regen
packages/spec/json-schema.manifest.json  merge=os-regen
docs/protocol-upgrade-guide.md           merge=os-regen
content/docs/references/**               merge=os-regen

driver 在冲突时不做文本合并,直接调对应生成器重算:

  • authorable-surface.json / json-schema.manifest.json ← gen:schema
  • api-surface.json ← 先 build(它 readsDist)再 gen:api-surface
  • spec-changes.json ← gen:spec-changes
  • protocol-upgrade-guide.md ← gen:upgrade-guide
  • content/docs/references/** ← gen:docs

实现注意:

  1. driver 必须在合并后的源码上重算(先让 git 正常合并 src/**,driver 只处理生成物),否则算出来的是半截状态。
  2. 两个手写登记表(conversions/registry.ts、migrations/registry.ts)不适用 —— 它们是手写代码,冲突要人看。今天两次都是双方各自追加、保留两边即可,但那是人的判断。
  3. driver 需要 pnpm install 过的 workspace;在裸 CI 或 git rebase 的中间态可能不可用,要考虑 fallback(失败时保留冲突标记并提示手工跑生成器,而不是产出错误内容)。

与 #4650 / #4663 互补

#4663 提的加固是「要求 authorable-surface.json 与重新生成的结果逐字节相同」。merge driver 保证冲突时必定走重新生成 —— 两者合起来,冲突类与证据删除类一起关掉:该文件永远只能是生成器的输出,手编和手工解冲突都无处落脚。

单独做本单也有价值(立刻消除返工),但和 #4663 一起做收益更大。

范围

窄、可立刻做、发布期也安全 —— 不改任何政策、不削弱任何门禁、不影响 review 可见性(文件仍然提交,diff 仍然可见)。

投影类是否该干脆退出仓库是另一个问题,见 #4676。

关联:#4650、#4663、#4659(门禁自证据链一族)、#4535(触发本单的双源清账流水线)

Activity

  1. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    ⚠️ 自我更正:merge driver 解决不了合并队列的弹出,只解决本地返工

    立单时我把本单说成「立刻消除返工」。这话对本地 git merge origin/main 成立,对 GitHub 合并队列不成立,差别很重要。

    .gitattributes 里的 merge=os-regen 只是给合并策略起个名字;真正的驱动要在 git config merge.os-regen.driver 里定义,那是本地配置,不随仓库提交。GitHub 的服务端合并(合并队列、Update branch、squash)不会、也无法执行自定义 merge driver。

    实测佐证(2026-08-02):PR #4670 在解决完与 #4664 的冲突、全绿入队后,仍被队列以 MERGE_CONFLICT 弹出 —— 因为期间 main 又前进了 #4668、#4660 两个 spec PR。merge driver 即使已落地也拦不住这一次。

    修正后的定位

    所以两单的优先级与我立单时写的相反:#4676 才是对队列有效的那个,#4675 是对本地开发体验有效的那个。已同步更新 #4676。

    (另一个与队列相关的现实解法是降低并发 —— v17 收尾期 main 上 spec PR 密度极高,最近 8 个提交里 6 个动 spec。这属于流程协调,不属于本单。)


    Generated by Claude Code

  2. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    已验证的先行实现(2026-08-02,用它解决了 PR #4670 的冲突)

    维护者问是否现在同步做本单。建议排到 v17 之后,理由见上一条更正(driver 是客户端机制,救不了合并队列)+ 两点:本单自身也要挤同一个队列;且 driver 需要每个 clone 单独 git config merge.<name>.driver,而 agent 都在新建 worktree 里工作,等于给派发流程加一步配置。

    但解法本身已经跑通了,留给实现者省一轮摸索。

    三路集合合并

    这批生成物是排序集合,合并语义是集合运算而非文本:

    • 任一侧显式删除某条目 → 结果中删除(退休 / 收敛清理)
    • 任一侧新增 → 结果中保留
    • 双方各自增删 → 直接可组合,无需人工判断

    git show :1:/:2:/:3: 拿到 base/ours/theirs 后按此规则合并即可。核心逻辑:

    function mergeStringArray(base, ours, theirs) {
      const B = new Set(base), O = new Set(ours), T = new Set(theirs);
      const out = new Set();
      for (const v of new Set([...O, ...T])) {
        const inB = B.has(v);
        if ((inB && !O.has(v)) || (inB && !T.has(v))) continue; // 任一侧的显式删除胜出
        out.add(v);
      }
      return [...out].sort();
    }

    对象节点递归、标量取「相对 base 变了的那一侧」。

    实测结果(PR #4670 合并 origin/main):7 个冲突文件里 api-surface.json / authorable-surface.json / dual-source-exports.baseline.json git 自己就合干净了,spec-changes.json 用上述集合合并秒级解决,结果正确 —— dual-source-exports.baseline.json 从 base 的 22 条正确收敛到 19(本 PR 删 RetryPolicy ×2,main 的 #4662 删 ActivationEventSchema ×1)。

    两个坑

    1. 必须保留生成器的尾换行约定。check:generated 比对字节,凭空加/减一个 \n 会让 CI 因纯装饰性原因变红。从 :2: 的原始字节判断即可。
    2. 手写登记表不适用集合合并。conversions/registry.ts / migrations/registry.ts 是代码,且双方新增的条目会共享结构行(});、fixture: {),union 策略会把两个条目的片段拼碎。feat(spec)!: converge RetryPolicy onto one declaration (#4661, C8) #4670 里可行的办法是:以 main 版为底,把 diff base ours 的 hunk 用 patch -F3 打上去,然后逐一验证注册落在正确的聚合数组里(fuzz 匹配可能落错位置 —— 我实测两个 hunk 都用了 fuzz 3,验证后确认正确)。

    所以 driver 的覆盖范围应当只含生成物,.gitattributes 里不要把两个 registry 纳进来。

    生成器不可用时的降级

    driver 跑不了生成器(裸 CI、rebase 中间态、worktree 未 pnpm install)时,集合合并不需要任何依赖,只要 node,可以作为主路径而不只是降级 —— 再由 CI 的 check:generated 验证结果确实等于重新生成。#4670 走的就是这条路。


    Generated by Claude Code

  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    ⚠️ 更正上一条:集合合并只对字符串数组成立,对象数组会丢条目

    上一条我把三路集合合并当作先行实现贴了出来。那段代码有缺陷,实测把 CI 打红了,在有人照着实现之前必须说清。

    发生了什么

    PR #4670 用它解决 spec-changes.json 的冲突后,CI 报:

    spec-changes.json is stale — the ADR-0087 registries changed without regenerating the manifest.
    Run: pnpm --filter @objectstack/spec gen:spec-changes  (and commit the result)
    

    原因:spec-changes.json 装的是对象数组(变更记录),不是字符串数组。我那段代码只在「两侧元素全是字符串」时走集合合并,其余情况回退成「取相对 base 变了的那一侧」—— 直接丢掉另一侧的条目。本 PR 因此丢了 main 侧 #3355 的 object-managed-by-system-to-system-data。

    修正后的适用边界

    文件 结构 集合合并
    dual-source-exports.baseline.json 字符串数组 ✅ 实测正确(22 → 19)
    authorable-surface.json 字符串数组 ✅ (本次 git 自己就合干净了)
    docs-import-surface.baseline.json 字符串数组 ✅
    api-surface.json 嵌套字符串数组 ✅
    spec-changes.json 对象数组 ❌ 必须跑生成器
    docs/protocol-upgrade-guide.md markdown ❌ 生成器(本次手工保留两行恰好与生成结果一致,是运气)

    对象数组要做正确的集合合并,得先定义元素身份(如 id),再按 id 做三路合并。 但 spec-changes.json 是 ADR-0087 登记表的投影 —— 与其猜它的合并语义,不如直接从已合并好的登记表重算,那才是唯一正确的定义。

    给实现者的结论

    driver 不要试图用通用集合合并覆盖所有生成物。分两档:

    1. 纯字符串集合的 ratchet 基线 —— 集合合并安全,且不需要任何依赖(只要 node),在裸环境也能跑。
    2. 投影类(spec-changes.json、upgrade guide、reference 文档、api-surface.json)—— 必须跑生成器。好消息是成本比想象低:本次 pnpm install --filter @objectstack/spec... 走缓存 2.5 秒,gen:spec-changes + gen:upgrade-guide 秒级完成。

    也就是说本单原本设想的「driver 调生成器」是对的,我上一条提出的「集合合并作为主路径」只适用于第 1 档。

    附带教训

    「让 CI 验」这条快路径确实抓住了错误 —— 但它抓住的是一个本可以在本地十秒内避免的错误(跑一次 check:spec-changes 即可)。跳过本地验证换来的时间,被一次 CI 往返和一次队列重排赔了回去。若 driver 落地,它应当在解决完冲突后自动跑对应的 check:*,而不是把验证推给 CI。


    Generated by Claude Code

  4. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    第四个样本,以及一条会改变实现方式的失败模式

    今晚 #4687 与 main(742cebb)又撞了一次,按本单的分类拆开完全吻合:

    冲突文件 建议清单里的 driver 实际正确解法
    packages/spec/spec-changes.json gen:spec-changes 丢掉两边文本,重新生成
    docs/protocol-upgrade-guide.md gen:upgrade-guide 丢掉两边文本,重新生成
    packages/spec/src/migrations/registry.ts 本单明确排除 双方各自追加一条 retirement,保留两边

    前两个是生成物,我实际做的就是 git checkout 掉冲突副本再跑生成器——和本单的处方逐字一致。第三个是手写登记表,正如实现注意 2 所说需要人看,但也确实又一次是「保留两边」。driver 会把 3 个冲突降到 1 个,且消掉的恰好是贵的那两个(重新生成 + 重跑门禁)。三次冲突里仍然没有一次是真语义冲突。

    实现注意 3 需要加强:不可用是安全的,陈旧才是危险的

    原文的顾虑是「driver 在裸 CI 或 rebase 中间态可能不可用」。不可用其实是安全的失败——它会报错,人能看见。真正危险的是可用但 dist 陈旧。

    api-surface.json 的生成器 readsDist。本会话实测过一次:在不完整的 dist 上跑 gen:api-surface,它静默抹掉了一个与本次改动完全无关的 ./studio 的 ActivationEvent (type) 导出,并顺手往 docs-import-surface.baseline.json 里为这个缺口加了一条棘轮豁免——正是该工具自己警告过的「棘轮悄悄停止棘轮」。没有任何报错;是把生成物与 main 逐文件对比才发现的。

    放到 merge driver 的语境里,这个性质会翻转本单的收益:

    冲突标记是可见的错误;静默生成的错误内容是不可见的错误。

    一个对 api-surface.json 乐观重算的 driver,会在 dist 陈旧时把「人能看见的冲突」换成「人看不见的错误产物」,而且它落在 diff 里长得完全合理。那比现状更糟。

    建议

    api-surface.json 这一条(以及任何 readsDist 的生成器)应当 fail-loud 而非 best-effort:

    1. 先 build 再 regen;或
    2. 检测到 dist 比 src 旧 / 缺失时拒绝生成,保留冲突标记并提示手工跑生成器。

    不读 dist 的那几个(gen:schema、gen:spec-changes、gen:upgrade-guide、gen:docs)没有这个问题,可以直接按原方案重算——今晚我手工走的正是这条路径,结果与门禁完全一致(17 项 check:* 全绿)。

    与 #4663 的互补关系不变:那边要求生成物与重算结果逐字节相同,本单保证冲突时必定走重算——前提是重算本身可信,这也正是上面那条要求存在的理由。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions