30 秒看懂
一位 Jane Street 程序员介绍了基于 Mercurial 的代码审查和发布工具 Iron 背后的设计理念。演讲讲解了合并过程中审查状态如何变化、行内审查评论和分支协作,以及有意保留杂乱历史带来的取舍。
- 1用差异记录审查状态Iron 为每位审查者和每个分支关联一个名为 brain 的可变差异。它记录此人接受过的内容,而不依赖于各个提交的标识。
- 2审查差异的变化合并或 rebase 后,Iron 会比较已审查的差异与当前分支的差异。这能显示冲突解决结果,而不必重读所有已审查的代码。
- 3评论写在代码里审查者直接向分支添加带标记的评论。编辑器集成让他们能从差异跳到源代码,甚至直接修复小问题。
- 4审查义务功能审查者明确确认分支后,文件审查者检查各自负责范围内的变更。元数据规定了负责人、审查严格程度和团队审查要求。
- 5大量合并带来的代价讲者表示,在大量合并提交中进行普通二分查找很痛苦;按功能分支进行二分查找会有帮助,但尚未实现。
要点建议
- 每位审查者的 brain 记录已经审查过的差异,使 Iron 在分支变化后能够识别尚需审查的内容。
- 对差异再做差异比较,可以显示合并冲突的解决结果,而无须让审查者重读整个分支。
- 审查者直接在代码中写评论,也可以在作者的分支上做小修复。
- 针对文件设定的审查义务和严格程度决定了发布前谁必须审查变更。
- 嵌套分支、独立工作区和仪表盘支持以分支为中心的审查流程。
- 这种方法用整洁的提交历史和便捷的二分查找换取持续审查与协作。
按章节阅读整理后的全文
Iron 与阅读代码的难题
讲者介绍内部工具,并说明分支变化时审查为何在操作上变得困难。合并和 rebase 让追踪已审查内容更加复杂。
我想介绍 Jane Street 如何审查代码,重点讲我们内部工具 Iron 背后的理念。Iron 兼具版本控制系统和审查工具的特点。我是使用它的程序员,而不是设计者,所以我会侧重使用体验和设计理念,不会把自己当作实现细节的权威。
Iron 是开源的,但公开仓库很难直接用于实际安装:开发在内部基于 Mercurial 的系统中进行,公开发布的代码则呈现为大块差异,安装说明也很少。我并不是要推销这个软件,而是希望其中一些想法能启发其他工具。
我用过的许多审查工具采用类似的模式:以网页和分支为中心,查看补丁并发表评论。Iron 走了另一条路。有些单独的设计选择看起来可能更糟,但当各部分配合起来时,好处就更明显了。
认真阅读代码很难。简单检查代码风格,与充分理解变更并提出改进建议不是一回事。审查的收获取决于投入;但我今天不打算解决理解程序语义的难题,而是聚焦如何在操作上追踪自己读过的内容。
一系列提交可以逐个查看差异,审查起来很直接。麻烦出现在分支发生分叉,有人将上游分支合并进来时。合并提交包含不相关的上游变更,但也可能包含需要审查的冲突解决结果,而这些结果可能带来真正的语义变化。
Rebase 带来另一种问题:旧提交被新提交取代。变更可能等价,但执行 rebase 的人也可以修改它们。审查者要么全部重读,要么信任 rebase 的结果,都不太理想。在 Jane Street,我们总是使用合并,虽然我们称这个操作为 rebase;因此提交历史比较杂乱,而 Iron 围绕分支设计,不追求整洁的单个提交。
无论按文件还是按提交审查,如果审查途中有新变更进来,都会出现类似问题。我希望工具能展示所有变更,包括冲突解决结果,又不用让人重复审查相同内容。在审查进行期间,它应该允许新变更进入,也允许合并上游或进行 rebase。
Brain 与差异的差异
Iron 将每位审查者接受过的变更记录为名叫 brain 的差异。通过比较这一状态与分支,可以发现尚未审查的变更,包括合并冲突的解决结果。
Iron 的答案是一种叫作 brain 的状态,它与一个人和一个分支关联。它最初是空差异。当我明确接受已经审查的变更时,这些变更就会应用到我的 brain 中。关键区别在于,Iron 记住的是我审查过的内容,而不只是我见过哪些提交的引用。
可以把分支看成一个差异,而我的 brain 则是我理解的差异。一旦两者一致,就说明我审查完了这个分支。这个简单模型让工具能够追踪我的审查进度,而不依赖我阅读的顺序,也不依赖最初包含这些变更的提交。
合并或 rebase 改变分支后,Iron 会将我的 brain 与新的分支差异进行比较。结果是差异的差异:它描述了我已经知道的内容与现在需要知道的内容之间的区别。读完这个结果,我的 brain 就能更新到与分支一致,无须从头开始。
来看一个小改动:某个函数在目标时间之后调用另一个函数,分支为了清晰起见颠倒了比较条件的顺序。与此同时,上游变更重命名了目标变量,并把一个匿名函数改成无点式写法。双方修改了相同的代码行,因此合并时产生冲突。


Iron 得到的差异的差异同时展示变更的新旧版本。有些行只是说明上下文移动了;有意义的部分则显示比较条件如何使用新变量名完成颠倒。即使这个简单例子看起来也有些吓人,这正是这种方法的代价之一。
大多数时候,Iron 会使用启发式方法将比较结果呈现为更易读的普通差异。遇到真正的冲突时,审查者可能需要阅读更复杂的差异的差异。从概念上说,这有点像函数式方法:工具追踪实际审查过的值,而不是依赖提交的引用。
一个缺点是,rebase 之后可能很难再按提交逐个审查分支:你可能需要考虑整个分支的差异。我们通过创建小分支来弥补。如果差异的差异很难读,审查者也可以丢弃相关审查状态,重新审查那一部分。
好处是,分支可以随时更新,而每位审查者仍能保持一致的视图。Rebase 引入的改动或合并产生的冲突解决结果,会作为相对于审查者已接受内容的差异显示出来。没有变化的内容不会仅仅因为历史改变就需要重读。
行内评论与协作
审查者直接向分支提交带标记的评论,也可以在同一工作区内修复问题。讲者讨论了这样做的好处以及由此产生的杂乱历史。
审查还能传播知识、降低巴士因子,但一个核心目的仍是通过反馈改进代码。Jane Street 的做法起初让我吃惊:审查者检出作者的分支,直接在源代码中添加特殊标记的评论,提交后再推回该分支。
传统审查评论可能附着在提交、差异、分支或某个特定版本的文件上。代码移动或提交在 rebase 中消失时,这些关联就可能变得尴尬。评论写在源代码中,分支更新时就会跟着代码一起走。

审查评论用 CR 标记注明作者和接收者。接收者会把它看作分支上的新变更,可以处理它,也可以用 XCR 标记回复,把讨论发回去。评论就在相关代码旁边,无须让工具猜测它该放在哪里。
这种做法有代价。历史中会包含添加和删除审查评论的提交,而每次评论都手动检出分支、找到相应代码行也会很痛苦。审查结束时评论已经删除,因此它们不会以同样的方式影响 blame,但历史看起来仍然很不寻常。
我们的 Emacs 审查客户端减轻了这些麻烦。阅读差异时,我可以按一个键跳转到该分支工作区中的对应代码行,然后在编辑器里写评论。从审查切换到编辑,比听起来要轻松得多。
这种安排也让修复代码和更新评论可以一起完成。如果审查时看到拼写错误或不够清晰的变量名,我通常可以直接修改,而不用先发表评论,再等待作者做一个很小的修复。随着分支变化,上下文仍然跟在代码旁。
允许审查者推送变更,也改变了人们对所有权的感觉。一个功能最初可能是我的工作,但它最终是 Jane Street 的代码,其他人可以从早期就参与进来。由于我也会审查自己的分支,brain 会向我展示别人做出的变更。
产生的历史无疑很杂乱,里面有针对单条评论和小修正的提交。Jane Street 接受了这种取舍,没有把整洁的提交图作为首要目标。我花了一些时间适应,但后来逐渐欣赏它所带来的协作。
审查义务与嵌套分支
功能审查者和文件审查者有明确职责,审查严格程度按文件配置。嵌套分支使小规模、相互依赖的审查单元成为可能。
审查义务决定谁必须查看某项变更。作者先审查自己的分支,然后指定整个功能的审查者,由他们阅读并明确确认分支。之后,审查范围扩大到对该功能所修改文件负有审查义务的人。
文件审查者了解相关代码,可以判断变更是否符合代码的设计意图。纳入版本控制的元数据为文件或文件组指定负责人、审查严格程度和审查义务。更重要的文件可以要求更多关注;规则也可以要求某个团队中至少指定数量的人审查,而非全员参与。

嵌套分支让一个功能可以依赖另一个功能。例如,测试 DSL 可能依赖新建的 monad transformer 库。手动维护这种层级可能很麻烦,但它适合我们的流程:审查的基本单元是分支,所以一系列相关变更会变成一系列相互依赖的小分支。
在我们的单体仓库中,长期存在的项目专属分支可以充当中间主分支,变更先合并到那里,再进入仓库的其他部分。Iron 将分支称为 feature,用 Mercurial 书签实现,并附加负责人、描述等元数据;描述的作用类似提交信息。
仪表盘、工作区与发布控制
仪表盘追踪审查和分支状态,独立工作区则方便切换任务。锁和集中式检查为发布提供保障。
Iron 客户端有一个待办仪表盘,显示审查、评论、分支状态和建议的下一步操作。它还会列出未清理的工作区:当许多分支拥有独立工作目录时,很容易忘记尚未提交或推送的工作,这个列表就很有用。

仪表盘让我们更容易在多个功能之间切换,同时不遗漏进展。我还把审查评论用作个人提醒。在 Emacs 仪表盘中,我可以发起异步 rebase;如果操作因为冲突失败,待办列表之后会提醒我介入处理。
工作区是为某个分支准备的独立工作目录,用途类似 Git worktree。它让我切换任务时不用替换某个检出目录的内容,也不用暂存未完成的工作。在我们庞大的单体仓库中,独立工作区还能让 OCaml 增量构建进程持续运行,而不必每次切换分支都重新构建。
Iron 使用锁的方式不同于传统的文件排他锁。如果某个功能有已知的严重缺陷,我可以锁住它,阻止发布;如果一次 rebase 需要谨慎处理,我也可以锁住该操作。生产环境补丁的分支也可以固定在较旧版本,避免自动更新意外引入较新的变更。
集中式服务器会执行规则:未完成审查的分支,或仍有阻塞性 CR 评论的分支,都不能发布。这限制了一些结构性操作,需要在安全与便利之间权衡。Mercurial 仍然允许不联系服务器就进行本地工作;这些限制主要在共享和发布变更时起作用。
源代码之外与问答
同一审查流程也用于配置和交易限额。问答中,讲者讨论了提交历史、生产环境回滚、缺陷标记、二分查找和文件所有权。
有了好用的审查工具后,我们把它用于源代码之外的内容。配置变更和博客文章都可以走同一流程。交易限额也设有审查义务,让有权批准变更的人在发布前进行审查。同一机制甚至可以用来审查一篇用 Markdown 写的演讲稿。
在问答环节,我指出嵌套分支主要是一种形式化的工作流程,而不是独特的基本功能。可以用分层的分支名称和更新依赖分支的脚本建立类似机制。至于 Mercurial 的历史和 rebase 惯例如何影响 Iron 的设计,我明确说过那只是猜测。
我以前会在审查前整理提交历史并写详细的提交信息。在 Jane Street,我不再把单个提交当作叙述变更的重要载体:审查和描述都在分支层面进行。很多人甚至会配置固定的提交信息,让自己自由提交中间工作,而不必精心整理出完美的提交序列。
生产环境出现缺陷时,我们通常把已部署的可执行程序回滚到归档版本。Iron 还有 trait 功能:可以标记某个修订版本包含一个缺陷,再标记后来的某个修订版本修复了它。由于已发布的历史通过合并保留,而非重写,工具可以追踪哪些修订继承了缺陷,以及重新构建是否包含修复。
我们通过新增变更解决问题,或者应用一个反向差异并提交,而不是从历史中改写掉旧提交。同样的反向差异方法也可以从分支中撤销某个变更,同时保留历史记录。
二分查找体现了这种历史的一个实际代价。通常,在已知正常和异常的修订版本之间进行二分搜索,可以找到引入缺陷的提交。我们的历史中有大量合并提交,沿相关祖先关系查找很痛苦。按功能发布节点进行分支级二分查找可能会有帮助,但我们还没有这个功能。
最后,文件所有权在操作上有明确含义。如果分支发布时仍有不阻塞发布的 CR-soon 评论,后续处理责任就会转交给文件负责人,并出现在其待办列表中。文件审查义务则是另一回事:它决定谁有机会审查变更,尽管负责人通常也会是审查者。
同频道的其他文章
1:48:29EN → 简体中文
1:36:37EN → 简体中文
1:05:45EN → 简体中文
1:25:00EN → 简体中文
1:16:29EN → 简体中文
4:41EN → 简体中文