Unison `update` 多余历史节点问题修复解析:从 fix-1381 看 namespace 依赖传播与因果历史去重

📅 发布时间:2026/10/10 6:08:33
Unison `update` 多余历史节点问题修复解析:从 fix-1381 看 namespace 依赖传播与因果历史去重
编程语言编译器语言运行时开发工具【免费下载链接】unisonA friendly programming language from the future项目地址https://gitcode.com/gh_mirrors/un/unison点击查看免费下载导读在 Unison 中update命令会遍历整个分支找出所有依赖被修改定义的代码并重新传播propagate更新。早期版本中存在一个隐蔽缺陷即使某个 namespace 并不包含任何依赖者只要它在传播过程中被访问过就会凭空多出一个历史节点导致history输出中出现幽灵提交。本文以官方回归测试脚本 fix-1381-excess-propagate.md 为主线完整还原该 bug 的复现步骤、期望行为与修复后的验证方式并结合 Unison 源码深入剖析consDistinct等历史去重机制帮助你理解 Unison 的因果历史causal history模型以及它如何保证无变化即无新节点。一、问题背景propagate 过程中的多余历史节点Unison 的update命令在语义上相当于就地修改定义并自动传播影响。当用户在 scratch 文件中修改了一个已有定义并执行update时Unison 会解析并类型检查新的定义在整个分支中搜索所有传递性依赖旧定义的代码将这些依赖者连同新定义一起重新类型检查必要时生成修复后的代码把结果保存回分支并推进分支的因果历史。问题在于传播过程的早期实现中每访问一个 namespace 就会对它执行一次step生成一个新的因果节点而不管这个 namespace 的内容是否真的发生了变化。于是出现了一个现象只要某个 namespace 位于传播路径上哪怕它空无一物、与更新毫无关系它的历史里就会多出一条记录。这个 bug 由 issue #1381 报告本仓库的回归测试 fix-1381-excess-propagate.md 就是为了锁定并验证该修复而编写的。二、完整复现过程来自官方回归测试回归测试的复现脚本非常精简其核心思路是构造两个互不依赖的定义更新其中一个然后检查另一个的历史是否保持原样。2.1 初始状态一个 term 与一个 namespacea a term X.foo a namespacea是一个 termX.foo位于X这个 namespace 下与a没有任何依赖关系。将它们加入代码库 add Okay, Im searching the branch for code that needs to be updated... Done.此时分支上同时存在a与X。2.2 执行一次本不该影响 X的更新接下来修改a注意这次修改不应该波及Xa an update update Okay, Im searching the branch for code that needs to be updated... Done.由于X中的X.foo并不依赖a合理的预期是X这个 namespace 的历史不应有任何新增。2.3 期望行为X的历史保持单一节点修复后的正确输出如下——X的历史应该只有一个节点#das1se4g2i即add时创建的那个节点 history X Note: The most recent namespace hash is immediately below this message. □ 1. #das1se4g2i (start of history)2.4 实际未修复时的行为凭空多出的节点在 bug 修复前的release/M1i版本中history X会出现一个多余的节点。测试脚本以应当失败的方式记录了这一点 history #7nl6ppokhg I dont know of a namespace with that hash.也就是说未修复版本会为X生成一个额外的历史哈希#7nl6ppokhg修复之后该哈希不再存在history查询它时 UCM 会给出不知道这个 namespace 哈希的错误——这恰好证明了多余的节点已经消失。该脚本位于 transcripts 的 idempotent 目录下意味着它可以在同一代码库状态上重复执行并得到完全相同的输出是验证这类不产生多余历史特性的理想载体。三、根因剖析传播逻辑为何会触碰无关的 namespace要理解 bug 的本质需要回到update命令的核心处理流程。其入口在 HandleInput/Update2.hs 的handleUpdate2先输出提示语Okay, Im searching the branch for code that needs to be updated...对应源码中的respondRegion调用调用getNamespaceDependentsOf计算本次更新涉及的定义集合的所有传递性依赖者见 Cli/UpdateUtils.hs若依赖者集合非空则把这些依赖者与新文件合并重新类型检查最后通过typecheckedUnisonFileToBranchUpdates把变更转换为(Path, Branch0 m - Branch0 m)形式的批量更新并写入分支。关键在于第 4 步之后分支历史如何推进。旧实现的问题在于历史推进时会对传播路径上经过的每个 child branch 都无条件cons一个新节点而缺少先比较内容是否真的变了的判定。于是即使某个 child如X在传播中只是被遍历到、内容完全未变也会被追加一个内容快照节点——这就是#7nl6ppokhg的来源。四、修复机制consDistinct与无变化即不写历史修复的核心落在两个底层函数上Branch.consBranchSnapshot与Causal.consDistinct。4.1Causal.consDistinct比较后再决定是否 cons因果历史Causal是 Unison 中表示分支演化的核心数据结构定义在 parser-typechecker/src/Unison/Codebase/Causal.hs。修复引入的关键函数是consDistinctconsDistinct :: (Applicative m, Eq e, Hashing.ContentAddressable e) e - Causal m e - Causal m e consDistinct e tl if head tl e then tl else cons e tl它的逻辑一目了然把新状态e追加到历史tl上之前先与当前 head 状态比较如果相等则直接返回原历史不产生新节点。与之配套的stepDistinct/stepDistinctM则是先对 head 应用变换再用consDistinct追加stepDistinct f c f (head c) consDistinct c stepDistinctM f c (consDistinct c) $ f (head c)正是这两处distinct判定从根源上杜绝了内容没变却新增历史节点的情况。4.2Branch.consBranchSnapshot递归合并子分支并去重在分支层面update通过stepManyAt批量应用更新其历史推进走的是 Branch.hs 中的consBranchSnapshotconsBranchSnapshot headBranch baseBranch if baseBranch headBranch then baseBranch else Branch $ Causal.consDistinct (head headBranch children_ .~ combinedChildren) (_history baseBranch)注意这里有两个层面的去重整体层面若baseBranch headBranch整个分支没有变化直接返回baseBranch不写历史递归层面对每个 child branch 用These对齐 base 与 head 的children并对同时存在于两边的 child 递归执行head \consBranchSnapshot base见 [Branch.hs](https://link.gitcode.com/i/0e60fe9b8d63679763bdd818f8eaed4f#L626-L640)——子分支在递归中同样经过consDistinct 判定无变化则不新增节点。combineChildren \case -- If we have a matching child in both base and head, squash the child head onto the -- child base recursively. (These base head) - Just (head consBranchSnapshot base) -- This child has been deleted, let it be (This _) - Nothing -- This child didnt exist in the base, we add any changes as a single commit (That head) - Just (discardHistory head)对于fix-1381场景X在 base 与 head 中都存在且内容完全一致递归调用consBranchSnapshot时会触发if baseBranch headBranch then baseBranch的短路分支直接保留原历史——X因此只保留add时的那一个节点#das1se4g2i。4.3 历史更新策略CompressHistory与AllowRewritingHistoryBranch.hs 中定义了两种历史更新策略理解它们有助于把握何时允许写历史、何时必须去重策略行为典型影响CompressHistory把所有变更压缩进单个 causal cons若新状态与现有状态相等不追加任何新节点update、add等普通操作采用保证无变化不产生历史AllowRewritingHistory允许直接用新分支的历史替换旧历史如X - Y - Z直接替换A - B - C用于需要重写历史的场景可覆盖子分支历史fix-1381修复属于前者默认的压缩去重语义保证了内容未变 历史不动。五、深层验证依赖者搜索本身不写历史值得强调的是即使搜索依赖者这一步也已经做到了只读不改。update中依赖者计算的完整调用链是handleUpdate2 └─ getNamespaceDependentsOf -- Cli/UpdateUtils.hs └─ transitiveDependentsWithinScope -- codebase-sqlite/U/Codebase/Sqlite/Operations.hs └─ getTransitiveDependentsWithinScope -- Queries.hsSQLite 递归 CTE其中 Operations.hs 的transitiveDependentsWithinScope在scope当前未冲突的 namespace与query被更新定义集合为空时会直接返回空集不触发任何遍历而在 Queries.hs 的 SQL 实现中则是借助dependents_index表构造WITH RECURSIVE查询从query集合的直接依赖者出发逐层展开用UNION而非UNION ALL保证每个引用只被追踪一次。这条链路本身只做查询、不做写入因此 bug 的根源不在依赖搜索而在于旧版更新传播结束后对被访问过的分支无条件cons历史节点。修复后consDistinct的内容比较机制在写入侧完成了兜底。六、如何用 transcript 验证与回归本仓库的 transcripts 体系把脚本 期望输出固化为可重复的回归测试。fix-1381-excess-propagate.md的结构正是标准范式unison :hide代码块喂给 UCM 的 Unison 源码:hide表示该输出不展示ucm代码块UCM 交互命令及其期望输出add、update、history Xucm :error代码块期望失败的输出——这里正是修复后#7nl6ppokhg不存在的断言。如果未来某次改动重新引入了多余历史节点问题history X的输出会再次出现第二个节点transcript 对比便会失败从而在 CI 中拦截回归。你可以通过仓库的 transcript 运行脚本见 scripts/transcripts.sh 与 scripts/check.sh在本地复跑验证。七、相关源码与文档导航如果你希望深入这条修复链路推荐按以下顺序阅读仓库内的实现回归测试脚本本文主体可直接作为最小复现用例Update2.hsupdate命令的完整处理流程含依赖者搜索与提示输出UpdateUtils.hsgetNamespaceDependentsOf的实现Operations.hstransitiveDependentsWithinScope事务层封装Queries.hs依赖者搜索的 SQLite 递归 CTEBranch.hsconsBranchSnapshot与历史更新策略Causal.hsconsDistinct/stepDistinct的去重核心。结语fix-1381这个案例以最小的复现脚本揭示了 Unison 因果历史模型的一个关键设计原则历史节点应当只反映真实的变化。consDistinct在Causal层、consBranchSnapshot在Branch层的双重比较让update即便遍历再多的 namespace也不会为无关的旁观者制造新的历史。理解这条链路不仅能读懂 UCM 中history输出的每一条记录从何而来也能在你阅读其他基于Causal的合并、更新逻辑时快速抓住何时写历史、何时去重的判定要点。赞分享编程语言编译器语言运行时开发工具【免费下载链接】unisonA friendly programming language from the future项目地址https://gitcode.com/gh_mirrors/un/unison点击查看免费下载相关推荐Unison 无分支Branchless代码库设计依赖跟踪、因果历史与命名空间架构Unison 无分支Branchless代码库设计依赖跟踪、因果历史与命名空间架构 本篇技术指南以 Unison 项目源码树中的 docs/branchl编程语言编译器语言运行时开发工具Unison branch.squash 命令深度解析用无历史快照合并分支历史Unison branch.squash 命令深度解析用无历史快照合并分支历史 导读 branch.squash 是 Unison 命令行工具UCM中用于编程语言编译器语言运行时开发工具上一篇如何让SRT白板动画上色更自然srt-whiteboard-animation的contour-wipe与brush调参指南下一篇TradingAgents-Astock的7位AI分析师揭秘政策、游资、解禁为何值得专设角色创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考