GoSuda

重新思考 Git Monorepo:面向人类与编码代理的 CAS 原生架构

By Lemon Mint views ...

每个不断成长的工程组织,最终都会遇到同一个岔路口。起初只有几十个小型仓库,生活看起来很简单。每个库都有自己的问题跟踪器、自己的 CI 流水线,以及一条在你喝上一口咖啡之前就能完成的 git clone。然后,代价开始显现。你修改了核心网络包中的一个结构体,却不得不用剩下的整个下午去下游六个仓库中提交版本升级的 pull request,并等待 CI 队列按拓扑顺序逐一清空。

如果把所有内容迁移到传统的 Git 单体仓库中,你就是用协调上的痛苦换取了物理上的痛苦。随着提交历史不断加深,历史版本中累积的二进制资产越来越多,git fetch 变得越来越慢。工作树膨胀到数 GB。不久之后,就有人提议部署一个定制的虚拟文件系统守护进程、一台专用的代码评审服务器,或者一组远程构建工作节点,只为了让日常开发还能勉强维持。

当自主编码智能体(autonomous coding agents)加入团队后,这两种失败模式都会进一步恶化。多仓库结构使智能体无法看到跨项目的调用方,并迫使它在多个仓库之间切换凭据。传统单体仓库则会在每次任务初始化时耗尽智能体容器的磁盘空间和网络带宽,而多个并发智能体若在同一分支上相互干扰,则会引发无休止的 rebase 竞争。

我们希望探索第三种方案。受 google3 架构的启发,我们提出了一个简单的问题:能否仅依靠标准的 Git 底层机制和通用对象存储,同时实现单主干原子变更、亚秒级稀疏入职、分层的 OWNERS 治理,以及可独立发布的开源镜像?

答案是肯定的,而且既不需要自定义文件系统守护进程,也不需要第三方评审设备。我们只需要把 Git 用于它的本质:一个基于 Merkle 树的内容寻址存储(Content-Addressable Store,CAS)。

慢速 Git 单体仓库的核心误解

当开发者抱怨 Git 无法扩展到大型单体仓库时,他们很少是触及了 Git 对象数据库的限制。在 .git/objects 内部,Git 以 SHA-1(或 SHA-256)内容哈希为键,存储三种不可变的基本对象:

  1. blob:单个文件的原始字节内容。
  2. tree:目录列表,将文件名和权限映射到 blob 或子 tree 的哈希。
  3. commit:一个很小的文本头部(通常为 200 至 300 字节),指向单个根 tree 的哈希、零个或多个父 commit 的哈希,以及作者元数据。

作为一种内容寻址存储,这种设计效率极高。如果单体仓库包含 50,000 个文件,而你只修改了其中一个文件的一行,那么新提交会复用其余 49,999 个文件的确切 blob 和 tree 哈希。

那么,为什么传统的 Git 单体仓库会感觉如此笨重呢?因为惯例让单个引用 refs/heads/main 承担了三项相互冲突的职责:

  • 它持有所有项目的完整统一目录树。
  • 它牵连着一直回溯到第一天的完整父链式历史提交链。
  • 它位于 refs/heads/* 命名空间内,而 Git 服务器在每次 git clone 和 git fetch 时都会默认公布并协商该命名空间。

每当开发者或 CI 运行器获取 refs/heads/main 时,客户端与服务器都需要就数千个历史版本之间的提交图可达性进行协商。如果有人在三年前不慎提交了一个 100 MB 的归档文件,并在次日将其删除,那么它的 blob 仍然挂在某个历史 tree 上,而该 tree 可以从 refs/heads/main 到达。

将引用命名空间划分为四层

Git 的线协议在标准克隆期间只公布 refs/heads/* 和 refs/tags/*。refs/trunk/* 或 refs/ws/* 等自定义命名空间下的引用,同样存放在服务器上的同一对象存储中,同样受益于 packfile 的增量压缩,并且可以被显式地获取或推送,但它们对默认克隆不产生任何额外开销。

通过将职责划分到单个仓库内的四个引用命名空间中,瓶颈得以消除:

1refs/heads/main          仅作为控制平面(约 20 KB:规则、.gitconfig、同步配置、脚本)
2refs/trunk/main          指向最新统一根树的无父快照提交
3refs/trunk/history       共享完全相同根树 SHA-1 的线性父链式提交链
4refs/ws/<actor>/<id>     每个开发者或智能体的独立工作区快照(变更集)
5refs/heads/land/<id>     临时的合并队列触发器,落地后立即删除

1. 控制平面(refs/heads/main)

默认分支不包含任何应用源代码。它仅记录仓库的运行规则(AGENTS.md、README.md)、共享的工作流别名 .gitconfig、Starlark 同步配置(sync.star)以及服务端合并脚本。克隆默认分支只需传输约 20 KB 数据,并可在毫秒级完成。

2. 无父主干快照(refs/trunk/main)

refs/trunk/main 指向整个组织完整的统一源代码树(//web/*、//net/*、//ipc/*、//devtools/*、//corp/*、//third_party/*、//experimental/*),但其提交对象是在创建时不带 -p 父提交参数的。

由于 refs/trunk/main 没有父提交(parents = []),获取它无需进行任何提交图协商。Git 服务器不需要遍历历史,只需发送单个头部提交及其当前的目录 tree 对象。上个月的历史二进制文件或已删除文件都无法从 refs/trunk/main 到达,因此永远不会被传输。

3. 线性审计历史(refs/trunk/history)

开发者在排查回归问题时仍然需要使用 git blame 和 git log。每当一项变更落地时,合并脚本都会创建两个提交对象,它们指向完全相同的根 $NEW_TREE SHA-1 哈希:

 1# 用于即时深度为 1 的工作区检出的无父快照提交
 2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
 3
 4# 用于线性 git log 和 git blame 的带父提交
 5HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")
 6
 7git push --atomic origin \
 8  --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
 9  --force-with-lease="refs/trunk/history:${OLD_HIST}" \
10  "${SNAP}:refs/trunk/main" \
11  "${HIST}:refs/trunk/history"

同时维护 refs/trunk/main 和 refs/trunk/history 是否会使仓库的存储占用翻倍?完全不会。 由于两个提交都引用相同的 $NEW_TREE 哈希,Git 的内容寻址存储会对 tree 和 blob 对象进行 100% 的去重。无父快照的额外存储成本仅为一个 250 字节的提交头部。

4. 隔离的工作区(refs/ws/<actor>/<id>)

进行中的工作分支被推送到 refs/ws/<actor>/<id>,而不是 refs/heads/*。数百个编码智能体可以每分钟保存一次检查点,而不会污染分支下拉列表,也不会拖慢人类工程师的 git fetch。

五条命令完成入职:稀疏加无 blob 检出的工作原理

基于这种引用布局,新开发者或临时的编码智能体容器无需安装定制 CLI 或 FUSE 驱动。入职只需五条标准 Git 命令:

 1# 1. 以 blob 过滤和稀疏模式克隆 20 KB 的控制平面
 2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
 3cd monorepo
 4
 5# 2. 加载仓库别名并挂载 refs/trunk/*
 6git config include.path ../.gitconfig
 7git trunk-init
 8
 9# 3. 声明本任务所需的领域目录
10git sparse-checkout set net/relay ipc/codec
11
12# 4. 基于最新主干快照创建隔离的工作区
13git ws-new alice/optimize-framing

在底层,每一步只执行所需的最少工作:

  1. git clone --filter=blob:none --sparse 在 refs/heads/main 上下载 20 KB 的控制平面。
  2. git trunk-init 配置 +refs/trunk/*:refs/remotes/origin/trunk/* 并执行一次无 blob 的获取。Git 下载 refs/trunk/main 的 tree 元数据(通常为几十 KB),而零个文件 blob。
  3. git sparse-checkout set net/relay ipc/codec 指示 Git 的锥形模式索引过滤器,仅物化根目录以及这两个子树。
  4. git ws-new 切换到一个以 origin/trunk/main 为根的新分支。就在此刻,Git 仅按需请求 net/relay 和 ipc/codec 中的 blob 对象。单体仓库中其余 95% 的内容永远不会被下载到磁盘。

未来展望:将 refs/trunk/main 挂载为虚拟文件系统(VFS)

稀疏检出配合无 blob 克隆已经可以覆盖日常工作流,而无需额外的客户端工具。由于 refs/trunk/main 本身已是内容寻址存储中的纯 Merkle 树,该架构也为可选的用户态虚拟文件系统(VFS)提供了直接基础,可分别通过 Linux 上的 FUSE、macOS 上的 FileProvider 或 Windows 上的 ProjFS 实现:

  1. 即时完整树挂载:一个轻量级的本地挂载守护进程读取 refs/trunk/main 的根 tree 对象,并在毫秒内将整个单体仓库的目录层级投影到工作目录中,从而无需运行 git sparse-checkout set。
  2. 按需 blob 加载:在编译器、IDE 或智能体发出 open(2) 或 read(2) 系统调用之前,文件内容始终保持为虚拟状态。随后,守护进程从 CAS 端点获取对应的 SHA-1 blob,并将其缓存到本地 NVMe 存储中。
  3. 工作区的写时复制覆盖层:本地编辑写入一个轻量级的上层覆盖层。运行 git ws-save 时,仅对该覆盖层中的脏路径进行哈希计算,写入更新后的 blob 和 tree 对象,并将生成的提交推送到 refs/ws/<actor>/<id>,而无需扫描未修改的目录。

纯 Git 上的变更集模型:服务端内存中落地

在传统 Git 工作流中,合并功能分支要么会生成一个双父合并提交(使历史变成纠缠的铁路编组站),要么要求开发者手动 rebase,并与其他推送到 main 的工程师展开竞争。

受 google3 变更集(Changelist,CL)模型的启发,我们架构中的每个工作区都代表对主干快照的单个原子变更。在本地 ws/alice/optimize-framing 中工作时,开发者或智能体可能会进行十次增量提交(wip、fix tests、lint)。当变更准备落地时,服务器会将工作区的差异压缩为单个线性主干修订版本,而无需在 CI 运行器上检出任何工作目录。

以下是落地脚本(land.sh)如何借助 Git 2.40+ 底层命令在内存中执行:

 1# 1. 找到工作区分支所基于的根快照提交
 2BASE=$(git rev-list --max-parents=0 "$WORK_SHA")
 3
 4BASE_TREE=$(git rev-parse "${BASE}^{tree}")
 5MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
 6WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")
 7
 8# 2. 在内存中计算合并后的根树,无需检出工作树
 9if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
10  NEW_TREE="$WORK_TREE"
11elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
12  NEW_TREE=$(head -n1 <<<"$OUT")
13else
14  echo "Merge conflict detected; run git ws-sync to rebase." >&2
15  exit 1
16fi

仔细观察第 2 步:

  • 无论工作区分支上进行了多少次本地提交,脚本只比较三个树哈希:原始基础快照($BASE_TREE)、当前主干快照($MAIN_TREE)以及最终的工作区树($WORK_TREE)。
  • 如果 Alice 在编辑 //net/relay 的同时,refs/trunk/main 在 //web/blog 中有了推进,那么 git merge-tree --write-tree 会直接在 Git 对象存储内部,于毫秒间计算出合并后的根树 $NEW_TREE。运行器的文件系统上不会写入任何文件。
  • 最后,带有 --force-with-lease 的 git push --atomic 在一次事务中更新 refs/trunk/main,向 refs/trunk/history 追加一个线性提交,并删除落地触发器 refs/heads/land/<id>。

强制执行架构边界:四级可见性与分层 OWNERS

只有在提交落地之前以机械方式强制执行领域之间的边界,将代码整合到一个仓库中才行得通。在 git merge-tree --write-tree 生成 $NEW_TREE 之后,以及 git push --atomic 更新主干之前,我们的策略引擎会对 git diff-tree "$MAIN_TREE" "$NEW_TREE" 的结果运行两项 O(delta) 检查。

1. 四级领域可见性

根树被划分为四个可见性层级:

目录前缀层级允许导入禁止导入OWNERS 关卡
//web/*、//net/*、//ipc/*、//storage/*、//crypto/*、//ai/*、//devtools/*公共生产公共生产、//third_party/*//corp/*、//experimental/*强制执行
//third_party/*外部供应商自包含或 //third_party/*所有非 third_party 领域强制执行
//corp/*内部私有公共生产、//corp/*、//third_party/*//experimental/*强制执行
//experimental/<actor>/*沙箱任何非 corp 包//corp/*豁免(自动批准)

如果 //web/* 中的公共包尝试导入 //corp/* 中的内部包或 //experimental/* 中尚未毕业的原型,落地任务会在触及 refs/trunk/main 之前中止。

2. 无需 Gerrit 服务器的分层 OWNERS 验证

若要强制执行带有继承规则的目录级 OWNERS 文件,你并不需要专用的 Gerrit 实例:

  • 对于 $MAIN_TREE 与 $NEW_TREE 之间每个被修改的路径,验证器会从文件所在目录向上遍历,直至 //OWNERS。
  • 任何 OWNERS 文件都可以声明 set noparent,以阻止从父目录继承审批;也可以声明 per-file *.sql=@dba-team,以按文件名通配符划定审批范围。
  • 防篡改不变式:现有的 OWNERS 文件始终从 $MAIN_TREE(当前主干树)读取,而从不从 $NEW_TREE 读取。未经批准的作者无法通过在同一工作区提交中编辑 OWNERS 来授予自己权限。
  • 沙箱豁免://experimental/<actor>/* 下的任何路径都会绕过 OWNERS 检查,使工程师和智能体能够借助 git ws-exp <actor> <project> 自由地进行原型开发,并在之后通过 git ws-graduate 将包升级到生产领域。

无摩擦的代码评审:临时 PR 与快照差异

由于 refs/heads/main 仅保存控制平面,而 refs/trunk/main 是一个无父提交,若直接从工作区分支向 main 发起标准的 GitHub Pull Request,就会尝试比较不相关的历史。针对不同类型的变更,我们设计了三种评审工作流:

  1. 即时两点式工作区差异(git ws-save 加边缘差异路由器) 使用 git ws-save 推送工作区后,会通过轻量级边缘 worker 公开 refs/ws/<actor>/<id>。该 worker 将 refs/trunk/main 和工作区引用解析为各自的提交 SHA,并重定向到 GitHub 的两点式比较视图(/compare/<trunk-sha>..<ws-sha>)。对于小型补丁或智能体生成的变更,评审者可以检查差异,并直接在 Web 界面中点击 Land。
  2. 临时 GitHub Pull Request(git ws-pr) 对于需要在 GitHub 上逐行评审的场景,git ws-pr 会将根快照提交($(git rev-list --max-parents=0 HEAD))推送到 refs/heads/base/<id>,并将工作区末端推送到 refs/heads/review/<id>。随后,它会发起一个 GitHub Pull Request,将 review/<id> 与 base/<id> 进行比较。由于基础分支正是工作区分支所基于的确切快照,GitHub 的 Files changed 标签页只会显示工作区中实际修改的行。当评审者点击 Merge pull request 时,一个 webhook 会将头部提交提升到 refs/heads/land/<id>,运行内存中的落地流水线,并自动删除 refs/heads/base/<id> 和 refs/heads/review/<id>。
  3. 外部镜像 Pull Request 开源贡献者无需学习单体仓库的工作流。他们像往常一样 fork 独立的镜像仓库,并针对它们提交 pull request。

O(delta) 双向同步与独立的生产分支

许多组织会将各个包作为独立的 GitHub 仓库发布,以便外部用户可以 go get、cargo install,或在不接触单体仓库的情况下 fork 单个项目。一些项目,例如静态博客或受发布门控的 Web 应用,还会在 main 之外单独维护一个 production 分支。

我们的 Starlark 同步引擎以 O(delta) 时间处理出站导出和入站贡献,其耗时与提交中变更的文件数成正比,而不取决于单体仓库的规模:

  • 无 blob 索引拼接:在将镜像仓库的子树拼接到单体仓库根树中或从中拆出时,引擎会设置 GIT_NO_LAZY_FETCH=1,并依次运行 git update-index --index-info 和 git write-tree --missing-ok。未修改的子树和 blob 仅通过其 SHA-1 哈希被引用,而从不下载到 CI 运行器。
  • 仅针对差异的 blob 预取:在将导出的提交推送到独立镜像之前,脚本会运行 git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree",并通过 git cat-file --batch 仅预取被修改文件的旧 blob 和新 blob。随后 Git 即可计算出精简的增量 pack,并在数秒内推送到镜像仓库。
  • 工作流与发布分支的保留:在出站导出期间,同步器会保留每个独立仓库自身的 .github/workflows/ 目录,使公共 CI 运行器能够在镜像上执行项目测试。Starlark 配置中的每个工作流都指向明确的分支(例如 main),从而为从独立 production 分支部署的仓库保留发布门控。单体仓库的落地与镜像的 main 分支保持同步,而 main 上自动生成的元数据或翻译提交则通过 webhook 回同步到 refs/trunk/main。将 main 提升为 production 仍然是一个明确的发布步骤。

结语

构建高速度的单体仓库,并不需要放弃标准 Git,也不需要运营一支复杂的定制基础设施服务器集群。该架构将 Git 的对象存储视为内容寻址存储,将 refs/heads/main 与 refs/trunk/main 和 refs/trunk/history 分离,并通过 git merge-tree --write-tree 在内存中合并树。因此,人类工程师和自主编码智能体都可以在一秒之内克隆工作区,并能够在数百个项目之间平滑扩展。