跳到正文
Hacker News· cs1996·· 6 天前AI 评分22

Mainbrella 用 Cloudflare D1+R2 为 Git 存储历史并支持迁移到 GitHub

Cloudflare D1+R2 buckets for Git but github.com FTW

AI 导读

Mainbrella 用 Cloudflare D1 和 R2 自行保存用户应用的 Git 历史,用户可在 Projects 中连接 GitHub 仓库,把已保存的提交迁移到指定分支,Builder 后续新提交也会同步过去。

正文 · AI 翻译

← 工程博客

2026年10月11日 · Andrew Arrow

Mainbrella 的新用户可以在还没有 GitHub 账号时,就向 Builder 索要一个记账应用。我们使用 Cloudflare D1 和 R2 自行保存它的 Git 历史。当他们准备把应用放到 GitHub 上时,应该能够把这些提交一并带走,并停止付费让我们存储历史。

我们在 Projects 中加入了该选项。连接一个仓库,我们就会把保存的提交移动到你所选择的分支。Builder 也会把新提交发送到那里。我们最初的对话框要求用户先粘贴仓库 URL,然后才授予我们访问权限。将其与 GitHub 的应用安装界面进行比较后,我们修正了顺序。

构建后保留仓库

Builder 在一台临时 Linux 机器上编写记账应用,安装其依赖并编译它。工作完成后,我们可以关闭那台机器。如果用户明天回来要求导出 CSV,我们需要把文件和历史加载到另一台机器中。

我们在机器内运行 Git,但将用于保存的仓库放在应用程序目录之外。应用程序脚本可以更改自己的 .git,因此我们改为根据 Worker 提供的文件来构建每个保存的提交。该快照包含经过验证的源代码、依赖锁定文件、我们的起始忽略规则,以及源代码中引用的生成图像。它不包含已安装的依赖、编译输出和环境文件。保存代码准备快照,而机器中的辅助程序运行 Git。

R2 存储文件和 Git 历史。我们按 SHA-256 哈希命名源对象,并将它们保存在账户和项目的前缀下。源清单将文件名映射到这些对象,这让我们无需启动机器就能显示源代码。Git 包在同一前缀下有自己的路径:

build-git/<user>/<project>/
  objects/<sha256>       # source, assets, manifests, evidence
  parts/<sha256>         # chunks of native Git bundles
  bundles/<commit>.json  # bundle manifest

D1 记录已保存的版本和当前 head。每个版本指向一个 Git 提交及其 R2 包,以及父版本、保存它的 Builder 轮次和我们的构建检查结果。Mainbrella 版本 ID 标识一个轮次;Git 提交 ID 标识一个 Git 对象。存储路径中的 SHA-256 哈希标识所存储的字节。这些是 D1 schema 中的独立字段。

Worker 在更新 D1 之前上传 R2 对象,并在此过程中检查包各部分的大小和哈希。然后它在一个 D1 批次中保存版本和 head。如果 Builder 轮次不再活跃,或另一次保存已更改 head,数据库触发器会拒绝该保存。D1 在数据库事务中运行这些语句,但 R2 上传是分开的。在 D1 告诉任何人新版本已就绪之前,我们需要文件已在 R2 中。

保存每个提交而不复制整个历史

起初,我们每次保存版本时都会写入整个历史的包。这意味着每次编辑都要上传相同的旧提交。现在我们保存一个初始包,然后保存包含自上一个提交以来新增对象的较小包。如果一次保存没有产生新提交,则复用上一个包。

Commits A, B, and C are saved in order. R2 stores the initial bundle and two increments, each linked to the previous bundle. D1 points to those manifests and records C as the current head. Uploads to R2 finish before the head changes in D1.

Git 包包含 Git 对象和命名引用。有些包包含克隆仓库所需的一切;另一些则要求先前提交已存在。我们在每个新清单中记录所需的提交和上一个包的清单。我们的辅助程序使用与此命令等效的方式创建增量,其中 PARENT_SHA 是上一个保存的提交:

git -c pack.window=0 -c pack.depth=0 \
    -c pack.allowPackReuse=false \
    bundle create --version=2 next.bundle main ^PARENT_SHA

我们将每个 bundle 拆分为至多 1 MiB 的部分,用 SHA-256 对它们进行哈希,并在其 manifest 中列出这些部分。为了将仓库加载到一台全新的机器上,我们沿着 manifest 回溯到初始 bundle,克隆它,并按顺序获取增量。Git 会验证对象,我们的辅助工具会检查结果 head 是否与我们预期的 commit 匹配。

这些打包标志让我们能够在 Worker 中组装下载内容,而无需启动付费沙箱来重新打包仓库。恢复旧版本可能会将同一个对象放入后续的 pack 中,并采用不同的 delta 表示。按原样合并这些 pack 可能会留下冲突的 delta 引用。我们通过将完整对象存储在增量中来避免这一点。它们仍然使用 zlib 压缩,但我们放弃了 delta 打包。

然后,导出器可以拼接对象段,写入带有合并对象计数的 pack 头,并为尾部计算新的 SHA-1 校验和。Git 的pack 格式描述了这些字段。这会损失一些压缩效率,但 Worker 可以直接从存储中生成完整的 bundle。你可以克隆它并查看保存的 commit:

git clone -b main mainbrella-app.bundle expense-tracker
cd expense-tracker
git log --oneline

过早要求提供仓库 URL

用户已经可以自行下载 bundle、克隆它并推送到 GitHub。我们希望他们能够在仍在构建应用时连接 GitHub。我们的第一个对话框要求提供 owner/repository 或 GitHub URL,并填入 main 作为分支。

First Mainbrella Connect GitHub dialog: a repository URL text field, a branch field defaulting to main, and an Authorize GitHub button.

我们有一个连接表单和用于迁移历史记录的后端代码,但该表单在用户授予访问权限之前就要求提供目标位置。URL 无法告诉我们哪个账户应安装该应用、某个组织是否允许安装,或者用户想要共享哪些仓库。

我们将自己的对话框与 GitHub 上的 ChatGPT Codex Connector 安装界面进行了比较。第一个界面列出了我的个人账户以及我可能安装该应用的组织。我可以在那里选择一个账户,或配置现有的安装。

GitHub's installation page for the ChatGPT Codex Connector lists Andrew's personal account and organization accounts as installation targets.

在连接器的设置中,我可以授予对 All repositories 或 Only select repositories 的访问权限。此截图显示了第二个选项,其中包含用于添加仓库的选择器以及已授予权限的仓库列表。GitHub 将这些选择作为其应用安装流程的一部分来处理,包括账户和组织规则。

ChatGPT Codex Connector repository settings offer All repositories or Only select repositories. Only select repositories is enabled, with a selector and a list of granted repositories.

我们将第一个对话框和两张连接器截图交给我们的编码代理,并要求它修复该流程。修订后的Projects UI 会将用户发送到 GitHub 以安装应用、选择账户并授予仓库访问权限。当他们返回时,可以选择一个他们有权写入的仓库。我们会填入其默认分支,他们可以更改。Manage repository access 会将他们送回 GitHub,以添加另一个账户或仓库。

选择一个我们可以写入的仓库

我们复制了账户和仓库选择序列,但 Mainbrella 请求的权限比上面显示的连接器更少。我们现有的 Import 应用只有读取权限。安装它的人并未同意让我们写入他们的仓库,因此我们创建了一个单独的 Mainbrella Build 应用。它的注册清单请求 Contents 写入和 Metadata 读取权限,没有工作流权限。

安装该应用会使其获得对所选仓库的访问权限。用户还授权它代表自己行事。我们使用 GitHub App 用户访问令牌,这些令牌受到应用和用户权限的双重限制。只能读取组织仓库的人不应该通过在 Builder 中打开它而获得写入权限。

我们的仓库选择器会列出用户可以推送且活跃的 Build 应用安装具有 Contents 写入权限的仓库。它会排除已归档和已禁用的仓库。在链接之前,后端会再次检查用户的权限,并通过 Git 的 receive-pack 服务检查应用的写入权限。

我们将凭据保存在 API Worker 中,使用 AES-GCM 加密存储在 D1 中。构建机器获得仓库对象和检出,但没有 GitHub 令牌。生成的应用程序脚本不需要可能将更改发布到客户仓库的凭据。

在本地审查期间,/github/build/repositories 返回了 503。我们的后端缺少 Build 应用的凭据,因此请求在到达 GitHub 之前就停止了。配置端点通过 {"enabled":false} 确认了这一点。Build 需要自己的客户端 ID、客户端密钥、应用 slug 和加密密钥;设置说明涵盖了注册和 D1 迁移。我们仍在处理集成修复。

将提交移动到 GitHub

在用户确认仓库和分支之前,项目会继续使用我们的 Git 存储。如果有构建正在进行,我们会拒绝转移,直到它完成。在移动历史记录时,我们会阻止新的 Builder 轮次,这样 Builder 就无法在转移过程中保存另一个提交。

如果项目已保存 Git 历史记录,目标分支必须为空或已指向我们保存的提交。Worker 导出捆绑链,并通过 Git 的 HTTP receive-pack 协议发送其包。它检查响应,然后读回分支以确认提交 SHA。这些是原始的 Git 对象,因此提交会保留其哈希值和祖先关系。

我们将自上次检查点以来的草稿编辑包含在后续提交中。如果分支包含不相关的历史记录,我们会停止并要求不同的目标。对于尚无保存历史记录或源的项目,我们可以从现有的 GitHub 分支导入支持的源,或在空仓库中开始。迁移代码处理每种情况。

  1. 转移提交并在 GitHub 上检查分支。 在此成功之前,项目会保留其托管的历史记录。
  2. 在 D1 中记录 GitHub 仓库。 保存仓库、分支和提交,然后在同一数据库批次中清除托管头并删除旧的 Git 版本行。
  3. 删除旧的 R2 历史记录。 移除项目的 bundles/ 和 parts/。保存的清理标志可让计划任务在删除中断时完成。

没有覆盖 GitHub、D1 和 R2 的事务。如果转移失败,项目仍保留其托管的 Git 历史记录。如果切换后 R2 删除失败,GitHub 拥有仓库,我们还有一些旧对象需要清理。重试时,我们会检查 GitHub 是否已接受保存的提交。如果转移还创建了草稿提交,我们会在接受之前检查其项目标记、父级和快照。链接处理器在转移确认后记录切换。

下一个 Builder 提交将发送到 github.com

关联后,用户仍然可以让 Builder 向费用跟踪器添加月度总计。Builder 像之前一样编辑文件并运行检查。保存函数看到已关联的仓库,并将提交发送到 GitHub:

// saveBuildGitVersion selects the linked repository first.
const project = await githubProject(env, params.userId, params.appId);
if (project?.github_repo)
  return saveGithubVersion(env, params, runtime, turn, files, verified);

Worker 将保存的文件与远程树进行比较,上传更改的 blob,并创建新的树和提交。它使用旧树作为基础,保留不相关的仓库文件,仅删除 Builder 管理的路径。我们还保留现有的 .gitignore,这样就不会在每次保存时替换用户的规则。分支更新通过 GitHub 的 reference API 使用 force: false 进行。如果有人在我们完成之前推送,更新可能会失败,而不是覆盖他们的提交。

GitHub 可以在 D1 记录提交之前接受提交。我们在提交消息中包含 Builder 轮次 ID,以便重试时可以检查远程 head 的消息和父提交,并识别该轮次的提交。如果此后有其他写入者移动了 head,我们会报告冲突。

用户也可能在 Builder 之外编辑应用。在下一轮次,我们读取关联的分支,并比较每个受支持源文件的最后已知 GitHub 版本、工作草稿和新的远程版本。我们可以合并对不同文件的编辑。如果双方以不同方式更改了同一文件,我们会停止,报告路径,并保留草稿而不更改 GitHub 指针。我们比较整个文件,因此仍然需要有人解决文件内的冲突。导入器最多支持 80 个文本文件,每个文件最多 64 KiB。

浏览版本和恢复早期版本也使用 GitHub,以提交 SHA 作为版本 ID。我们将这些读取限制为从关联分支可达的提交。如果访问被撤销、分支保护阻止写入或 GitHub 不可用,操作将停止。我们不会在用户不知情的情况下恢复托管 Git 存储。

关联后我们使用的内容
Git 历史和新提交GitHub。我们停止写入托管 Git 版本,并删除旧的 R2 捆绑包和部分。
工作文件、资产、对话、Builder 操作记录Mainbrella 存储。这些仍然有存储成本。
模型推理和构建Mainbrella 的模型和计算服务。这些仍然有使用成本。

如果你克隆费用跟踪器的 GitHub 仓库并运行 git log,你应该会看到迁移之前的提交。让 Builder 添加月度总计,再次拉取分支,它的下一次提交也应该在那里。

来源:Hacker News · mainbrella.com