Go to my github

【译】 我们如何同步 .NET 的虚拟单体仓库(二)

原文 | Přemek Vysoký

翻译 | 郑子铭

术语

让我们来看看本节剩余部分将用到的一些术语:

  • 源代码/产品存储库– 当前开发存储库之一,例如dotnet/runtime。不是 VMR。
  • 前向流程——将变更从产品存储库移动到虚拟存储库的过程。
  • 回流– 将变更从 VMR 移动到产品存储库的过程。
  • 代码流——指在虚拟存储库 (VMR) 和产品存储库之间传递变更的过程。这是一个通用术语,既可以指正向流,也可以指反向流。
  • 代码流 PR – 指在代码流流程中发起的包含代码变更的拉取请求。它可以是正向流 PR,也可以是反向流 PR。

双向代码流 v1

代码流算法的第一版设计目标是,每次需要提交变更时,都必须能够在目标仓库中创建一个拉取请求。该拉取请求必须包含所需的变更,但可能与目标分支冲突。我们将展示后来我们如何意识到这是一个错误的指导原则,因为它引入了一些有趣的问题。

简而言之,该算法的工作原理是:我们使用上述跟踪元数据来追踪双方之间的最后数据流。然后,我们找到目标仓库中创建 PR 分支的正确位置(提交),将更改物化到该分支之上,并创建一个拉取请求。我们必须向您保证,如果双方之间存在冲突的更改,这些冲突也会出现在 PR 中。这意味着 PR 分支必须基于足够旧的提交,以便将源分支的提交和目标分支中的更改都引入到冲突状态。

代码流算法的第一个版本用于发布大部分 .NET 10 预览版以及 10.0 正式版。该算法会考虑前一个代码流的方向,并据此应用不同的策略。从技术上讲,有四种情况需要考虑(正向-正向、正向-反向、反向-正向、反向-反向),但后两种情况是对称的,因此我们不再单独讨论。

相反方向的流动

让我们先来看一个比前两种情况更复杂的场景——当有两个方向相反的流体流动时。本节中的图表使用以下符号:

  • 🟠 橙色– 文件内容转换。文件以内容开头A,B -> C表示提交将内容从更改B为C。
  • 🟢 绿色– 之前的成功流程。显示正在执行的提交(虚线)以及另一端 PR 分支将形成的样子(实线)。
  • 🔵 蓝色——当前正在讨论的趋势。
  • 🟣 紫色– 正在传递到对应存储库的差异。
  • ⚫ 灰色– 与被跟踪文件无关的提交。
  • 提交记录按时间顺序编号。标记1和2通常表示之前的同步操作。

backward-forward-codeflow

代码流程图显示了存储库和 VMR 之间的两个连续流程,每个流程的方向都不同。

图中的变化流程如下:

  • 1并2表示之前的某个同步点。
  • 4VMR 中的提交会将内容更改A为B。
  • 5从这一点开始发生回流。
  • 6仓库中创建了一个回流分支(绿色)。该分支基于上次同步的提交(1)。此流程的创建方式并非本图的重点。这里,我们关注的是以下流程:从该分支发起一个 PR。
  • 7Backflow PR 已合并,有效地将仓库主分支中的更新A从A更新到。B
  • 8仓库中执行了一次提交操作,将内容从更改B为C。
  • 9VMR 中进行了一项无关的提交。
  • 10从这一点开始,正向流动就此开始。
  • 11在代码仓库中创建了一个前向流分支(蓝色)。该分支基于上次同步的(5)基本提交。从该分支打开了一个 PR。在前向流 PR 中进行了一次额外的提交,将的内容更改A为D。
  • 12PR 已合并,有效更新A为。BD

您可以注意到以下几个特点:

  • 没有出现 Git 冲突。这是因为这个具体示例只考虑了一个文件,该文件按时间顺序逐步更改A。D在大部分更改都发生在单个代码仓库的情况下,我们期望代码能够流畅地运行。
  • 整个流程类似于开发人员在单个代码仓库的开发分支上工作。然后,开发人员向主分支(在本例中为主代码仓库)提交一个 PR。在单个代码仓库的情况下如果出现冲突,这里也会出现冲突,这是设计使然。

11接下来需要讨论的是如何创建正向分支的提交( )。我们知道,7在上次回流 PR 合并后,我们从仓库收到了作为提交一部分的增量。我们考虑到使用了压缩合并,因此提交可能不再可用。在提交和6之间,回流 PR 分支上也可能存在其他提交。当我们进行正向提交时,需要流出的变更集实际上包括提交、、和。基本上,就是仓库端所有尚未流入 VMR 的变更。它以和之间的紫色差异可视化。此差异正确地表示了增量,因为:6710367810106

  • 它包含了 VMR 的最后一个已知快照(6)
  • 自上次提交以来,VMR 中发生的所有提交——提交3和7。
  • 8自同步以来,VMR 中发生的其他提交10。

正向分支的基础提交是上次回溯分支的基础提交,因为我们将增量更新应用到该回溯分支上。如果提交9与增量更新存在冲突,PR 会显示这些冲突,开发人员需要解决这些冲突。

两股流向相同方向

当有两个连续的、方向相同的流时,情况就简单一些:

forward-forward-codeflow

代码流程图,展示了从代码库到虚拟存储库的两个连续流程。

当我们形成前向流提交(10)时,我们知道自从上次将所有更新发送到 VMR 以来,唯一发生的事情就是提交9和10。然后我们可以将此新增量应用到上次前向流提交()之上8。

冲突

冲突是指同一文件的同一部分同时在代码仓库和虚拟代码库 (VMR) 中被以不同的方式修改的情况。此时需要人工干预来决定哪个修改生效。该算法的目标是确保在修改再次发生之前,这些冲突能够被发现并得到解决。然而,我们将展示冲突的引入方式如何影响正在进行的代码流程。我们还将展示即使没有进行任何冲突的修改,冲突也可能出现。

让我们考虑以下示例,其中冲突是由正向流 PR 中一个无关的提交引入的:

forward-forward-codeflow-with-conflict-scaled

代码流程图,展示了代码流程 PR 中引入的冲突。

在这种情况下,第一个正向流 PR 中新增的提交6与仓库中的一个提交冲突10。由于没有回流,仓库端无法获知此信息。后续的正向流也存在问题,因为更改10无法11应用到 之上8。事实上,我们甚至根本无法为 PR 分支创建任何提交!在这种情况下,唯一可行的办法是基于最后一个已知的有效提交 来创建 PR 分支,2并重建之前的流程(通过重新应用,5这实际上是和),然后应用和,创建一个由于 冲突而与目标分支冲突的 PR 分支。之后,用户将被指示将目标分支 合并到 PR 分支中以解决冲突。对于 中包含的与 中的更改基本相同的更改,git 将透明地进行匹配,只留下实际冲突的文件进行解决。下一次回流会将此解决方案带到仓库。1341011610958

还有无数其他可能发生的冲突,但这些冲突通常会在 PR 中表现为冲突。上面的例子更有意思,因为正向流甚至一开始就无法创建 PR 分支。这是因为8(之前的正向流提交)包含6与冲突的10文件。需要注意的是,冲突文件集不仅包含问题文件,还包含跟踪上次同步提交的清单文件。这是因为清单文件在提交中被更新,8其中包含提交的 SHA 值,4而 PR 分支正在将同一行更新为11。即使我们知道源清单的预期内容,也无法部分解决这个问题,因为 Git 不允许部分合并解决。这意味着,当真正的冲突迫使我们变基到较旧的提交时,就会带来麻烦……

冲突,到处都是冲突

一旦我们开始在实践中测试算法,就逐渐意识到真正的复杂性在于问题在实际开发节奏中的动态变化。流程很少像乒乓球比赛那样来回往复,变化以可预测的方式流畅进行。相反,我们必须预料到流程会以各自的频率在两个方向上并行发生,而且从开始到结束往往需要很长时间。

让我们来看这样一个场景:源代码库中的单个文件经历了一系列渐进式的变化,虽然我们没有对它进行任何实际的冲突性更改,但我们仍然会在代码流 PR 中看到冲突:

conflict-during-gradual-changes-scaled

代码流程图,展示了文件逐步更改过程中出现的问题。

在这个例子中,仓库中的一个文件的内容逐渐从 A<file1> 变为 B<file2> 再变为 <file3> C。虚拟仓库 (VMR) 中没有对该文件进行任何更改。正向流🔵和反向流🟢并行启动,并且都已成功合并。正向流🔵成功地将 VMR 中的文件从 <file1> 更新为 A<file2> B。现在我们来看第二个正向流🔴,理论上它应该将更改带到 B<file3>。第二个正向流遵循上述C反向流的算法,其 PR 分支基于最后一个反向流的提交。这意味着 PR 分支实际上包含了从 <file1><file2> 的更改。然而,目标分支已经包含了从 <file1><file2>的更改。当我们尝试合并 PR 时,Git 会发现从 <file1><file2>(PR 分支)的更改与从 <file1><file2>(目标分支)的更改之间存在冲突,因此合并失败。🟢ACABACAB

在这个例子中可以看到,即使文件本身没有发生任何冲突性的更改,流程的交错方式仍然导致了冲突。为了解决这个问题,我们可以利用之前两个流程(而不仅仅是最后一个流程)的信息,将目标分支合并到 PR 分支中,并根据我们对文件最终状态的理解,以编程方式解决冲突。然而,这种方法仅在文件没有发生任何我们意料之外的更改时才有效。此外,一旦出现真正的冲突,合并分支就再次变得不可能。这意味着用户不仅要处理真正的冲突,还要处理原本不应该发生冲突的、逐渐变化的文件中的冲突!例如,源清单文件会在每个流程中发生变化,并且在这种情况下总是会导致冲突。开发人员随后必须自行解决跟踪数据中的冲突,但这并非理想之选,因为它很容易导致灾难性的后果。

这种情况并不少见。引入新文件并在后续 PR 中快速修改的工作流程很常见(例如本地化)。

回滚问题

上述问题虽然可行,但也暴露出该方法的一些固有局限性。压垮骆驼的最后一根稻草,促使我们重新思考部分目标,就是所谓的“回滚问题”。

这个想法与之前描述的场景类似,但不同之处在于,我们不是逐步修改文件,而是先修改文件,然后再撤销修改。如果我们设法将修改和撤销操作分开处理,而第二次包含撤销操作的流程最终也导致冲突,那么这种“完美风暴”将阻止我们自动合并分支,最终导致撤销操作完全丢失!

the-revert-problem-scaled

代码流程图,展示了文件更改回滚时出现的问题。

在这个例子中,我们可以观察到以下几点:

  • 文件B被添加🟢后又被删除(还原)🔴,同时文件A收到不相关的冲突更改🟠。
  • 正向流 PR 分支包含所有三个更改,其中还原操作会否定原始更改,表现为文件B根本没有改变。
  • 文件冲突导致我们无法将 PR 分支建立在提交(上次回溯)A之上,而必须将其建立在提交之上,同时重新创建之前的流程。82
  • 由于新重新基于 PR 分支的更改在技术上不包含对文件的任何更改B(更改及其还原相互抵消),因此当我们合并 PR 时,还原操作将完全丢失,文件B将保留在 VMR 中,而从原始存储库中删除。

此外,这不仅适用于整个文件的还原,也适用于任何更改,无论多么微小,即使之后被还原。太可怕了!

尽管需要多个条件同时满足,但这种情况在实践中仍然可能发生。具体来说,我们曾在代码中引入临时解决方案或功能标志,之后又将其移除时遇到过这种情况。在繁忙的仓库中,当 PR 被完全回滚时,也会出现这种情况,因为实际冲突可能频繁发生。无论如何,我们不能接受就这样悄无声息地丢失更改。一切都得从头再来!

重新调整我们的方法

至此,我们已经穷尽了所有通过简单的分支和合并实现的可能性。我们提出的每一个变通方案和想法,都会有一个反例将其彻底推翻!

改变游戏规则

这促使我们重新考虑“始终能够在目标仓库中创建包含一定内容的 PR”这一目标。由于我们无法部分解决冲突,因此必须在与 GitHub PR 用户界面不同的环境中进行解决。能否在开发人员的本地机器上进行?我们能否允许用户运行一个命令,在本地执行流程,使本地仓库处于冲突状态,解决已知的(非)冲突(例如源清单更改),然后让用户处理实际问题?

不同的游戏

我们尝试遵循的另一项设计指南是,比较我们的双仓库方案与只有一个仓库但有多个分支的方案有何不同。我们的工作流程和 Git 操作与在功能分支上进行功能 PR 的日常工作有何不同?人们通常如何处理冲突?当冲突发生时,流程又是怎样的?这促使我们探索一种完全不同的方法。我们现在要讨论的是流程git rebase。

让我们互动起来

结合以上理念,我们得到了一种全新的代码流程体验,它更具交互性,并且在出现冲突时需要用户进行不同的干预。新流程与常规的 Git 变基流程并没有太大区别:

  • 代码流服务仍然以与以前相同的方式计算更改——考虑以前的流程,根据最后一个流程构建分支等等。
  • 然后我们尝试将 PR 分支变基到目标分支的最新版本。当存在冲突时,此操作会失败,并导致代码库处于冲突状态。
  • 如果没有发生冲突,则 rebase 操作将被提交并推送到新的 PR 中,这样就完成了。
  • 如果发生冲突,代码流服务将无法继续运行。它会创建一个空的 PR,并指示用户使用自定义工具在本地执行该流程。
  • 然后,该服务会通过自定义状态检查来阻止 PR 合并,直到看到所需的更改推送到 PR 分支为止。
  • 定制工具获取必要信息并在本地执行相同的代码流程。
  • 如果出现冲突,它会解决任何已知的冲突,这些冲突可能是由于如上所述的双向并行数据流造成的。然后,它会将实际的冲突留给用户解决。
  • 用户随后提交并推送更改。
  • 最后,该服务验证推送的内容,并解除 PR 的合并限制(它会批准自定义状态检查)。
  • 这个 PR展示了 PR 的示例。

我们能够部分解决已知冲突,同时将实际冲突留给用户处理,这是成功的关键。由于我们可以利用之前所有流程的信息正确计算增量,因此不再受回滚问题的困扰。而且,由于我们已经位于目标分支之上,因此也可以纠正任何遗漏的回滚。我们可以通过尝试回滚上次流程的更改,并查看哪些文件回滚失败来检测遗漏的回滚。这种情况只会在文件缺少更改时发生。

我们在去年 12 月推出了这项新功能,到目前为止,使用体验非常好。我们仍在研究一项实验性改进方案:不在目标仓库中创建工作分支,而是直接在目标分支上应用补丁。然而,这种方法在 Git 中无法直接实现,因为某些操作(例如修改 Git 未知的文件,比如文件在一侧被修改,而在另一侧被删除)会导致应用失败。目前来看,先在目标仓库的工作分支中提交所需的更改,然后再将其变基到目标分支上会更容易,因为 Git 在变基时可以利用提交图中的更多信息。但是,如果我们能够解决这些不支持的情况,将大大简化流程,因为创建工作分支本身就是对之前流程的重构。

当前挑战

我们一路走来,历经艰辛,但若认为已经抵达终点,那就太天真了。没错,我们确实利用虚拟仓库(VMR)成功发布了十几个版本,而且它也确实带来了诸多成果,例如让我们能够更早地完成发布构建,同时还能在开发流程的后期接受最后的修复。令人惊讶的是,即使从仓库依赖树切换到扁平拓扑结构,也没有对我们日常的 .NET 10 开发造成实质性的影响。经过周密的计划,我们仅用了几个小时就完成了迁移。尽管如此,我们还是必须承认,这一过程中也遇到了一些小问题。

分支与产品生命周期

.NET 是一个庞大的平台,包含众多不同的产品,这些产品通常以各自的节奏发布。例如,Visual Studio、Aspire、.NET MAUI 和 Entity Framework 等,它们拥有不同的生命周期模型,需要不同的开发节奏,而这种节奏并不总是与 .NET SDK 的节奏一致。简而言之,根据产品生命周期,代码库可以分为几个主要类别:

  • 以SDK 版本为中心的存储库——例如,dotnet/sdk每个 SDK 版本都会提供不同的变体。它们的分支方式与 VMR 本身相同,例如release/10.0.1xx或release/10.0.2xx。
  • 共享组件/运行时——例如dotnet/runtime,dotnet/aspnetcore提供多个 SDK 版本之间共享组件的存储库。它们通常会按主版本进行分支,例如 1.0release/10.0或 2.0 release/11.0。
  • VS 中心型——例如,dotnet/roslyn将组件与 Visual Studio 版本紧密耦合的存储库。它们通常会根据 Visual Studio 版本进行分支,例如 1.v1release/17.14或 1.v2 release/dev18.0。

您可以阅读VMR SDK Bands 文档,详细了解有关我们分支策略的更多信息。

当代码库需要与遵循 SDK 分支中心模型的 VMR 错开分支时间时,生命周期中的这些差异就开始显现。代码流算法的设计初衷仅是为了处理两个分支之间的同步。实际上,这意味着当代码库需要将不同的分支与给定的 VMR 分支同步时,我们必须手动重置 VMR 的内容,使其与代码库保持一致。这是一个复杂的过程,因为我们必须确保在此过程中不会丢失对 VMR 所做的任何更改。

在 .NET 10 产品周期中,类似上述情况已经发生过数十次,因为我们正忙于发布 .NET 10.0.200 版本。我们仍在努力解决这个问题,计划检测代码流配置的变化,并发布一个自动内容重置 PR,以尝试优雅地处理这种过渡。

卡扣式释放分支

另一种问题情况是,当我们为了发布而合并分支时,每个产品仓库的合并时间可能不同。例如,在开发新的 .NET 主要版本(例如 .NET 10)期间,我们每个月都会将main开发分支合并到相应的预览发布分支中release/10.0.1xx-previewN。假设某个仓库在虚拟主分支 (VMR) 合并之前就合并了其分支:

wrong-branch-snap

代码流程图,展示了当分支顺序错误时出现的问题。

注意提交是如何流入虚拟主仓库 (VMR) 的3,5并且根据每个仓库快照其分支的时间,它们之间可能存在父子关系,也可能不存在。在图中,它们在仓库中没有关联,但在虚拟主仓库中却存在父子关系。这显然是错误的,因为两者都可能进行冲突的更改,但在各自分支的历史记录中,这种关联是完全有效的。

我们通过集中管理 snap 包来避免这种情况的发生,首先从 VMR 开始。VMR 会优先处理这些 snap 包,我们会从中找到每个产品仓库中仍然适用的最新提交。然后,我们会在该提交中创建发布分支。

元数据损坏

我们遇到的另一个挑战涉及同步跟踪数据。产品代码库中常见的做法是合并不同的发布分支。例如,对某个分支所做的更改release/10.0.1xx通常可以合并到更高版本release/10.0.2xx分支中。在此过程中,如果跟踪元数据被覆盖,则可能会出现不一致的情况。代码库的 2xx 分支最终会引用一个与 1xx 分支同步的 VMR 提交。

我们目前正在尝试使用Git Notes作为跟踪元数据的替代存储机制。Git Notes 将元数据附加到提交中,而不会修改提交本身,这有助于我们避免将元数据作为工作树的一部分而产生的一些问题。

未涵盖的内容

这篇文章篇幅增长得很快,对此我深表歉意。尽管如此,还有许多相关的有趣方面值得进一步探讨,因为它们对于整体的成功与同步算法本身同样至关重要:

  • 开发者体验——我们在用户体验方面所做的一切,旨在帮助开发者浏览代码流程 PR 并跟踪他们的更改同步到哪里。
  • 监控和可观测性——我们如何跟踪代码流状态和健康状况,检测卡住的流程,或对跨存储库的问题发出警报。
  • 工具– 我们构建了哪些自定义工具来帮助开发人员执行本地代码流程、解决冲突以及在推送之前验证更改。

如果您对这些主题中的任何一个感兴趣,并希望了解更多详细信息,请告诉我们。

结论

如果您读到这里——首先,非常感谢——其次,希望您能从我们的发展历程中有所了解:从基于 tarball 的源代码构建,到完全同步的单体仓库,我们始终保持着数百名开发者在数十个仓库中的高效工作,并确保每月不间断地发布新版本。虚拟单体仓库 (VMR) 已成为 .NET 基础架构的基石,使我们能够统一并简化构建和发布流程,同时保留各个仓库及其社区的灵活性和自主性。然而,这些优势也带来了仓库同步的复杂性。如果您也正在经历类似的转型,或许我们目前的架构只是通往完整单体仓库的垫脚石,我们希望我们的经验和心得能够对您有所帮助。欢迎随时联系我们,这是一个比较小众的问题,我们很乐意与您交流!

资源

原文链接

How We Synchronize .NET’s Virtual Monorepo

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://chuna2.787528.xyz/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 ([email protected])

posted @ 2026-03-18 00:00  郑子铭  阅读(0)  评论(0)    收藏  举报