createAndUploadGitBundle 机制

这页回答什么

createAndUploadGitBundle() 到底怎么把本地仓库状态打包给 remote session?它如何处理未提交修改、为什么要写 refs/seed/stash、以及 bundle 过大时怎么降级?

关键文件:

  • src/utils/teleport/gitBundle.ts
  • src/utils/teleport.tsx
  • src/utils/background/remote/remoteSession.ts

一句话结论

createAndUploadGitBundle() 的本质是一个 seed bundle builder。它不是简单把 .git 压缩上传,而是显式把“当前仓库可恢复状态”收束成一个可被远端 clone 的 bundle,并尽量把 tracked WIP 也塞进去。

最关键的技巧是:

  • git stash create 先生成一个悬空提交
  • update-ref refs/seed/stash 把它变成可达对象
  • 这样 git bundle create 才能把这份 WIP 一起打进去

1. 文件头注释已经把主流程说透了

源码顶部直接写了 5 步:

1. git stash create → update-ref refs/seed/stash
2. git bundle create --all
3. Upload to /v1/files
4. Cleanup refs/seed/stash
5. Caller sets seed_bundle_file_id on SessionContext

这几步合起来说明:

bundle 不是 session 本身,而是 teleportToRemote() 用来填 seed_bundle_file_id 的输入材料。

也就是:

  • gitBundle.ts 负责造 seed
  • teleportToRemote() 负责把 seed 接到 session_context 上

2. 它要解决的核心难题不是“打包 repo”,而是“把 WIP 也安全带过去”

普通 git bundle 好解决的是:

  • 已提交历史
  • 可达 refs

难解决的是:

  • 当前工作区里 还没 commit 的 tracked changes

因为这些修改默认并不挂在正式 ref 上,直接 bundle 未必能被远端拿到。

Claude Code 的做法很巧:

git stash create

它会:

  • 生成一个 stash commit
  • 不写入 refs/stash
  • 不改 working tree

也就是得到一个“悬空但包含 WIP 的 commit SHA”。

update-ref refs/seed/stash <sha>

把这个原本悬空的 stash commit 挂到临时 ref 上。

这样它就从“可能被 GC 的悬空对象”变成了“bundle 可见的可达对象”。

这就是整个机制最值钱的点。


3. 为什么不用真的 git stash push

因为这里的目标不是改用户仓库状态,而是 无副作用提取 WIP 快照

git stash create 的优点:

  • 不污染 refs/stash
  • 不改 index / working tree
  • 只生成一个 commit SHA
  • 非交互、很适合后台流程

所以它比真正 stash 更适合 teleport seed 场景。

换句话说:

这里要的不是“帮用户收拾工作区”,而是“偷偷复制一份当前 tracked WIP 快照”。


4. refs/seed/stash 是临时桥,不是用户仓库的一部分

代码里会在开头和 finally 都清理:

  • refs/seed/stash
  • refs/seed/root

甚至一开始先 sweep stale refs:

for (const ref of ['refs/seed/stash', 'refs/seed/root']) {
  update-ref -d ref
}

这说明设计意图非常明确:

refs/seed/* 只是 bundle 构建期的临时内部 ref,绝不应该长期留在用户仓库里。

这既避免:

  • 污染用户 refs
  • 下次 --all 把旧 seed 也打进去

也兼顾了 crash 后的脏状态清扫。


5. 它只保证 tracked WIP,不保证 untracked files

源码注释写得很直:

// stash create writes a dangling commit ... Untracked files intentionally excluded.

以及:

// Tracked WIP via stash create → refs/seed/stash ... untracked not captured.

所以这里必须记住:

bundle seed 会尽量保住 tracked 未提交修改,但不会把 untracked 文件 一起带走。

这点很关键,不然容易高估 remote teleport 的精确度。


6. 它不是只有一种 bundle,而是三层降级链

_bundleWithFallback() 的策略非常清楚:

第一层:--all

git bundle create <bundlePath> --all [refs/seed/stash]

特点:

  • 范围最大
  • 尽量保留完整 refs / 分支 / tag
  • --all 会把 refs/seed/stash 一并带上

如果体积没超阈值,最好。


第二层:HEAD

如果 --all 太大,就退到:

git bundle create <bundlePath> HEAD [refs/seed/stash]

特点:

  • 丢掉 side branches / tags
  • 但保留当前分支历史
  • 仍然显式带 refs/seed/stash

这是一个很工程化的折中:

不再追求“整个 repo 全历史都过去”,先保证“当前工作主线能过去”。


第三层:squashed-root

如果 HEAD 还太大,再退到最狠的模式:

  1. 取 tree:
    • 有 WIP 时:refs/seed/stash^{tree}
    • 否则:HEAD^{tree}
  2. git commit-tree 造一个无父提交
  3. update-ref refs/seed/root <sha>
  4. git bundle create <bundlePath> refs/seed/root

这条路的语义是:

彻底不要历史,只保留“当前文件树快照”。

如果有 WIP,还会优先用 stash tree,把未提交 tracked 改动 bake 进去。

所以三层 fallback 的本质是:

  • all 保历史最完整
  • head 保当前主线历史
  • squashed 只保当前快照

7. squashed-root 为什么特别重要

因为它说明 remote teleport 的真正目标不是 git 考古,而是:

只要能让远端拿到“足够可执行的当前代码状态”,历史可以不断牺牲。

这非常符合 remote agent 实际需求。

agent 真正常用的是:

  • 当前代码树
  • 当前上下文
  • 可继续改、跑、测

它未必需要整个 repo 所有历史都完整到位。

所以 squashed-root 不是妥协得很糟,而是一个很实用的最后兜底。


8. 空仓库为什么直接失败

函数开头先做:

git for-each-ref --count=1 refs/

如果没有任何 refs,就判定:

  • empty_repo
  • Repository has no commits yet

原因注释也写了:

  • git bundle create 不能创建空 bundle
  • git stash create 在没有 initial commit 时也会失败

所以空仓库不是一个可恢复的小异常,而是 bundle 协议本身没法成立。


9. 最大体积阈值是 feature gate 控的

默认上限:

  • 100 * 1024 * 1024

还能被 GrowthBook 特性值覆盖:

  • tengu_ccr_bundle_max_bytes

所以 bundle fallback 不是固定死规则,而是可运营、可调参的产品路径。

这也说明团队显然在持续观察:

  • 多大 bundle 还能接受
  • 什么时候该切 head
  • 什么时候干脆拒绝

10. 上传阶段只是把 bundle 变成 file_id

bundle 造出来后,会:

  • uploadFile(bundlePath, '_source_seed.bundle', ...)
  • 成功则返回 fileId

再由调用方 teleportToRemote() 写到:

  • session_context.seed_bundle_file_id

所以 gitBundle.ts 的输出不是 session,而是:

一个可供 CCR 远端拉取的 seed bundle 文件引用。


11. 返回值里的 scopehasWip 很有信息量

成功返回:

{
  success: true,
  fileId,
  bundleSizeBytes,
  scope,
  hasWip
}

这里:

  • scope = all | head | squashed
  • hasWip = 是否捕获到 tracked 未提交修改

这两个字段随后还会被 telemetry 记录。

说明产品上他们关心:

  • 用户到底常走哪层 bundle 降级
  • WIP 带入远端的概率有多高

12. remote 资格检查也已经被 bundle 逻辑改写了

src/utils/background/remote/remoteSession.ts 里有个很关键的变化:

当 bundle seed gate 开着时:

  • 只要 在 git repo 里 就够了
  • 不再强制要求 git remote
  • 不再强制要求 GitHub App

也就是说:

bundle fallback 不只是 teleport 内部兜底,它已经反过来改变了 remote session 的 eligibility 规则。

这说明 bundle 现在不是边角功能,而是正式一级路径。


13. 这套机制真实保住了什么,没保住什么

保住的

  • 已提交历史(取决于 scope)
  • 当前分支可执行代码
  • tracked 未提交修改(如果 stash create 成功)

不保证的

  • untracked 文件
  • 完整 refs(在 head / squashed 降级下会损失)
  • 完整历史(squashed 只剩快照)

所以更准确的说法是:

它保的是“remote agent 继续工作的最小充分代码状态”,不是“本地仓库无损镜像”。


14. 最简时序

createAndUploadGitBundle()
  → find git root
  → delete stale refs/seed/stash + refs/seed/root
  → reject empty repo
  → git stash create
  → if got sha: update-ref refs/seed/stash <sha>
  → try bundle --all
    → too large ? try HEAD
    → still too large ? create squashed root
  → upload bundle to Files API
  → return fileId + scope + hasWip
  → finally cleanup refs/seed/*

当前结论

createAndUploadGitBundle() 的关键设计可以压成一句:

先把本地 tracked WIP 伪装成一个临时可达 ref,再把 repo 以“全历史 主线历史 单快照”三级降级方式打成 seed bundle,交给远端 session 恢复。

这就是 Claude Code remote 能在 GitHub 不通时还维持“看起来像把本地仓库带过去了”的核心工程技巧。


下一步最值得继续追

  1. task-notification -> queued command -> attachment 精细序列图
  2. 单独拆解 foreground -> background 热切换为什么要重启 async lifecycle
  3. 继续找 env-runner 端如何消费 refs/seed/stash / seed_bundle_file_id