createAndUploadGitBundle 机制
这页回答什么
createAndUploadGitBundle()到底怎么把本地仓库状态打包给 remote session?它如何处理未提交修改、为什么要写refs/seed/stash、以及 bundle 过大时怎么降级?
关键文件:
src/utils/teleport/gitBundle.tssrc/utils/teleport.tsxsrc/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负责造 seedteleportToRemote()负责把 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/stashrefs/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 还太大,再退到最狠的模式:
- 取 tree:
- 有 WIP 时:
refs/seed/stash^{tree} - 否则:
HEAD^{tree}
- 有 WIP 时:
git commit-tree造一个无父提交update-ref refs/seed/root <sha>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_repoRepository has no commits yet
原因注释也写了:
git bundle create不能创建空 bundlegit 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. 返回值里的 scope 和 hasWip 很有信息量
成功返回:
{
success: true,
fileId,
bundleSizeBytes,
scope,
hasWip
}这里:
scope=all | head | squashedhasWip= 是否捕获到 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 不通时还维持“看起来像把本地仓库带过去了”的核心工程技巧。
下一步最值得继续追
- 画
task-notification -> queued command -> attachment精细序列图 - 单独拆解
foreground -> background热切换为什么要重启 async lifecycle - 继续找 env-runner 端如何消费
refs/seed/stash/seed_bundle_file_id