本文基于 Rust 官方 Inside Rust 博客于 2026 年 6 月 4 日发布的《How Josh helps Rust manage code across multiple repositories》,作者为 Jakub Beránek 和 Ralf Jung。
内容结构概览
- 背景:为什么需要跨仓库代码管理
- 问题定义:父仓库与子项目的依赖关系
- 方案一:Monorepo(单一大仓库)
- 方案二:git submodules(子模块)
- 方案三:git subtrees(子树)
- 救星出现:Josh 工具介绍
- josh-sync:Rust 基于 Josh 构建的上层工具
- 现状与展望
一、背景:为什么需要跨仓库代码管理
Rust 项目不仅维护着语言本身,还开发了一系列面向开发者的工具,包括 Cargo、Clippy、rustfmt、Rust Analyzer 和 Miri。这些工具各自拥有独立的 git 仓库,每个仓库可以根据维护团队的具体需求,灵活定制 issue 管理、PR 流程、CI 工作流和开发规范。
然而,这些工具最终都需要以某种方式集成进主仓库 rust-lang/rust,原因主要有两点:
其一,主仓库的 CI 负责构建并分发包含这些工具的 Rustup 组件,最终将它们交付到每一位 Rust 开发者手中。
其二,当主仓库对(不稳定的)编译器内部 API 做出破坏性修改时,依赖这些 API 的工具有时需要在同一个 PR 中同步修复,以确保它们在 nightly 工具链上持续可用。
这就引出了一个跨仓库代码共享的经典难题:多个相互依赖的仓库,该如何协同演进?
二、问题定义:父仓库与子项目的依赖关系
具体来说,问题可以这样描述:有一个"父仓库"(即 rust-lang/rust),它依赖若干"子项目"(如 rust-lang/miri 或 rust-lang/rust-analyzer)。父仓库与各子项目独立开发,但父仓库必须能访问子项目源码的某个特定快照。此外,子项目的代码需要被定期同步到更新的版本。理想情况下,父仓库还应该能够在一次原子操作中同时修改自身代码和子项目代码。
要满足上述所有条件,并非易事。传统的几种方案各有局限,下面逐一分析。
三、方案一:Monorepo(单一大仓库)
如果父仓库和所有子项目都放在同一个仓库中,上述问题在理论上迎刃而解:父仓库始终能看到子项目的最新代码,也可以在一个 PR 中原子地修改双方。
rust-lang/rust 本身在概念上确实是个 monorepo,它已经包含了编译器、标准库、rustdoc 等大量内容。但这种方式也带来了摩擦:主仓库体量庞大,对新贡献者颇具压迫感;CI 流程复杂且耗时;开发规范主要面向编译器团队的需求,未必适合 Miri 或 Rust Analyzer 等工具的维护团队。
保持某些子项目(如 Miri 或 Rust Analyzer)拥有独立仓库,可以让它们的 CI 更轻快,支持独立于主工具链单独发布,也便于新维护者的上手------他们只需获得针对该子项目的合并权限,而不必掌控"整个 Rust"。因此,完全 monorepo 并非最优解。
四、方案二:git submodules(子模块)
将一个 git 仓库嵌入另一个仓库,最直接的方式是使用 git submodules。目前,Rust 项目对 Cargo 和 LLVM 等部分依赖就采用了这一方式。子模块的优点是配置简单:只需将某个目录指向外部仓库的一个具体 commit SHA,即可完成设置。
但实践中,子模块用起来相当令人头疼。
开发者必须使用 git clone --recursive 或 git submodule update 正确检出子模块,否则相应目录会是空的或停留在错误的 commit 上。一个相当常见的失误是:在切换分支时,git 有时会将子模块置于"脏(dirty)"状态,导致开发者不小心将无关的子模块变更提交并推送到自己的 PR 分支。为了缓解这一问题,Rust 的构建工具 bootstrap 中加入了自定义逻辑,以确保在构建特定产物时将对应子模块检出到正确的 commit,但这套方案并不完美。
子模块更大的问题在于:它无法在单个 PR 中原子地修改子项目和父仓库的代码,因为子模块本质上只是指向外部仓库的一个链接。当 rust-lang/rust 做出内部破坏性变更时,完整的流程将是:
- 合并
rust-lang/rust中包含破坏性变更的 PR - 在子项目仓库中单独提一个 PR 进行修复
- 再合并一个
rust-lang/rust的 PR,将子模块指针更新到步骤 2 产生的新 commit
这套流程繁琐不堪,使得同时涉及父仓库和子项目的改动落地变得极为困难。很多时候,要在不暂时破坏某个仓库 CI 的情况下完成这类变更几乎是不可能的。Rust 项目此前曾对 Clippy、Miri 等工具采用过类似机制,无论是用户体验还是开发体验,都极为糟糕。
五、方案三:git subtrees(子树)
正因为 git submodules 不适合与编译器深度集成的工具(如 Miri 或 Rust Analyzer),Rust 项目历史上转而采用 git subtrees。git subtree 会将子项目的整个仓库------包括完整的源码和 git 历史------直接内嵌到父仓库中,而不仅仅是一个链接。这样,就可以在单个 PR 中同时修改父仓库和子项目的代码,例如在编译器发生变化后直接修复子项目的测试,非常实用。
代价是需要定期在父仓库和各子项目之间执行双向同步,分为两种操作:
- pull:将父仓库中对子项目代码的修改同步回子项目的独立仓库
- push:将子项目独立仓库的最新 commit 同步到父仓库中的对应代码
每次同步,都需要有人手动运行脚本、解决可能出现的合并冲突或测试失败,并向对应仓库提交 PR。
这套工作流在概念上运转良好,但实现层面有一个致命缺陷:git subtree 慢得令人绝望。
官方上游版本对于 Rust 这种体量的仓库完全无法使用。社区中存在一个从未被合并到上游的补丁,将 git subtree 的速度提升到了 Clippy 等工具勉强可用的程度。然而在 Miri 上,可能由于其复杂的历史(数年前曾以保留历史记录的方式将大量代码从 Miri 移入 rustc),git subtree 完全失效:即便等待数小时,同步操作依然无法完成。
除性能问题之外,subtrees 还存在其他不足:git blame 无法正常显示正确的提交归属,以及在父仓库修改子项目代码后提交会被重复等问题。
git subtree 已经为 Rust 项目服务多年,但 Rust 已经成长到超出它所能处理的规模。好在,团队找到了一个更好的替代方案。
六、救星出现:Josh 工具介绍
Josh(Just One Single History)是一款用 Rust 编写的工具,专门为 git 仓库提供高性能的过滤操作。它提供了一套可逆的 git 代数过滤器(algebraic filters),能够对 git 仓库的历史记录进行灵活操作,从而支持多种实用场景:
- 透明地将一个仓库按需拆分为多个子仓库(作为 git 代理运行)
- 重排、排除或提取特定目录的 git 历史
- 将使用了 merge commit 的仓库历史线性化
对 Rust 项目而言,Josh 的用法与 git subtree 非常相似,最核心的区别有两点:同步操作的速度快了一个数量级 ,以及生成的历史记录更加整洁。
换句话说,Josh 本质上是"加了类固醇的 git subtree"。
七、josh-sync:Rust 基于 Josh 构建的上层工具
为了让子项目管理更加顺畅,Rust 团队在 Josh 之上构建了一个轻量级封装工具,名为 josh-sync。这个工具为 Josh 强大的引擎提供了统一的操作界面,使所有使用 Josh 的子项目都可以以一致的方式执行 pull 和 push 操作。
此外,团队还准备了一个可复用的 GitHub Actions 工作流,已在多个子项目仓库中投入使用。该工作流负责定期自动执行 pull 同步操作,直接从 CI 发起 PR,并在同步无法无人工干预完成时通过 Zulip 发出通知。
目前,Josh 已经用于以下子项目的子树同步:
八、现状与展望
目前仍有少数子项目(如 Clippy)还在使用 git subtrees,团队的计划是最终将这些剩余子项目全部迁移到 Josh,并持续完善工具链,让双向同步对各子项目维护者而言更加轻松高效。
使用 Josh 过程中也遇到了一个主要问题:pull 同步会在子项目仓库中产生大量 merge commit。对此,Josh 的开发者改进了避免冗余 merge 的逻辑,目前正在协助 Rust 团队完成迁移到这套更优过滤器的工作(这本身是一次不简单的迁移)。与此同时,Rust 项目的使用场景也不断帮助 Josh 发现边缘情况,实际上扮演着 Josh 性能压力测试的角色。
团队使用 Josh 已有数年,对于这样一个工具的存在深感庆幸------否则,面对 Rust 仓库的规模,他们大概只能自己动手造轮子了。Josh 的维护者,尤其是 Christian Schilling,配合度极高,持续根据 Rust 团队(有时颇具挑战性的)需求对 Josh 进行改进。
如果跨仓库的 git 工作流扩展问题也让你头疼,不妨去 Josh 看看,或许它也能解决你的版本管理难题。
原文地址:blog.rust-lang.org/inside-rust... 作者:Jakub Beránek、Ralf Jung