GoSuda

重新思考 Git 单体仓库:面向人类与编码智能体的 CAS 原生架构

By Lemon Mint views ...

每个不断成长的工程组织最终都要在“多仓库”与“单仓库”之间做出选择。起初只有几十个小型仓库时,一切看起来都很简单。每个库都有自己的问题跟踪器、自己的 CI 流水线,以及一个几秒钟就能完成的 git clone。然而,当你修改核心网络包中的一个结构体时,接下来的半天可能都要花在六个下游仓库中提交版本号更新的 pull request 上,并按拓扑顺序等待 CI 队列逐一清空。

若将所有内容迁移到传统的 Git 单体仓库,你便是用跨仓库协调的成本换取了仓库体积的增长。随着提交历史不断加深,历史修订中累积的二进制资产使 git fetch 越来越慢。工作树膨胀至数吉字节。不久之后,便有人提议部署一个定制的虚拟文件系统守护进程、一套专用的代码审查服务器,或一组远程构建工作节点,只为让日常开发保持可接受的速度。

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

受 google3 架构启发,我们构建了单主干原子变更、亚秒级稀疏入职、层级化的 OWNERS 治理机制,以及基于标准 Git 底层机制和通用对象存储的独立开源镜像。

该设计既不需要定制的文件系统守护进程,也不需要第三方评审设备,因为它将 Git 视为基于默克尔树的内容寻址存储(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 仍挂载在某个可从 refs/heads/main 到达的历史 tree 之下。

分离引用命名空间

在标准克隆过程中,Git 的传输协议只公告 refs/heads/* 和 refs/tags/*。refs/trunk/* 或 refs/ws/* 等自定义命名空间下的引用同样存放在服务器上完全相同的对象存储中,享有相同的 packfile 增量压缩,可以被显式地获取或推送,同时对默认克隆不产生任何额外开销。

将这些职责分散到单个仓库中的不同引用上,便消除了这一瓶颈:

1refs/heads/main               仅限控制平面(约 20 KB:规则、.gitconfig、同步配置、脚本)
2refs/trunk/main               指向最新统一根 tree 的无父快照提交
3refs/trunk/history            共享完全相同根 tree SHA-1 的线性父链式提交链
4refs/ws/<actor>/<id>          每位开发者或代理独立的工作区快照(变更集)
5refs/ws/cl/<change-id>/<tip>  不可变补丁集:提交评审的变更的每个 tip
6refs/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/*、//storage/*、//crypto/*、//ai/*、//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 在内容寻址存储中本身就是纯粹的默克尔树,该架构还可为可选的用户空间虚拟文件系统(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 变更集(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/*Public ProductionPublic Production、//third_party/*//corp/*、//experimental/*强制执行
//third_party/*External Vendor自包含或 //third_party/*所有非 third_party 领域强制执行
//corp/*Internal PrivatePublic Production、//corp/*、//third_party/*//experimental/*强制执行
//experimental/<actor>/*Sandbox任何非 corp 包//corp/*豁免(自动批准)

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

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

验证器通过继承规则强制执行目录级的 OWNERS 文件:

  • 对于 $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 推送工作区后,refs/ws/<actor>/<id> 会通过轻量级边缘 worker 对外暴露。该 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>,并将工作区的 tip 推送到 refs/heads/review/<id>。随后它会发起一个比较 review/<id> 与 base/<id> 的 GitHub Pull Request。由于基础分支即是工作区所基于的快照,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。

变更、补丁集与批准结转

分支名称无法标识一项变更。代理可能会在合入前多次重命名分支、修改 tip 提交或 rebase 到更新的主干。与 Gerrit 一样,本工作流通过 tip 提交消息中的 Change-Id: I<40 个十六进制字符> 尾部标记来标识变更。git ws-save、git ws-pr 和 git ws-land 会在 tip 缺少该标记时添加它,若已存在则予以保留,从而使该标识在 amend、rebase 和重命名之后依然有效。已合入的主干提交保留该尾部标记,并记录其评审 pull request 的编号,因此 git trunk-cl <number|Change-Id> 可在 refs/trunk/history 中找到它。

每个送审的 tip 都会成为一个补丁集。git ws-pr 会将其推送到不可变引用 refs/ws/cl/<change-id>/<tip>,与评审分支并列存放。在 amend 或 git ws-sync 之后再次运行 git ws-pr,会向同一个 pull request 追加一个补丁集。git ws-ps 列出当前变更的补丁集,git ws-psdiff <a> <b> 输出两个补丁集之间的互差(interdiff),从而使审查者只需阅读自上一轮以来发生变化的内容。

GitHub 将批准绑定到单个提交上,因此每次 rebase 通常都需要重新评审。合入流水线同样接受针对同一变更的另一补丁集所给出的批准,前提是两个补丁集对各自的基础快照做出了相同的修改。它通过比较 git patch-id --verbatim 对完整二进制差异的计算结果来判定。干净地 rebase 到更新的主干可以无需再次评审即可合入,而任何内容编辑(包括空白字符或文件模式的更改)都需要新的批准。

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 的对象存储视为内容寻址存储,将 refs/heads/main 与 refs/trunk/main 及 refs/trunk/history 分离,通过带有不可变补丁集的 Change-Id 标识每项变更,并借助 git merge-tree --write-tree 在内存中合并树。入职成本随任务所需目录的数量增长,而合入与镜像同步的成本则随变更的规模增长,而非随仓库的规模增长。