复制 DerivedData 根本没用:用 worktree 池让 Xcode 告别冷编译


每个任务新建 worktree 与 worktree 池的对比

TL;DR

  • 每个任务新建一个 worktree,Xcode 每次都要冷编译,DerivedData 也越堆越多。我本机 24 个 worktree 占了 94 GB。
  • 把 DerivedData 放进项目目录:清理方便,但不省编译。
  • 新建 worktree 时复制 DerivedData(APFS clone):几乎不占空间,但缓存用不上,照样全量编译。
  • 有用的是 worktree 池:固定几个 worktree,在里面换分支。在我的项目上,换任务后的编译从 82 秒降到 26 秒,编译任务从 3804 个降到 666 个。

背景

我同时让几个 AI Agent 写同一个 iOS 项目的代码。为了互不干扰,每个 Agent 一个 Git Worktree,用 Worktrunk(命令 wt)创建和清理。

用了一阵子,遇到两个问题:

  1. 磁盘不够用。 我的项目单个目录的构建产物约 4 GB。24 个 worktree 攒下来,光 DerivedData 就有 94 GB。
  2. SSD 写入多。 每个新 worktree 第一次编译都是冷编译,约 4 GB 的产物重新写一遍。Agent 多了,这个量很可观。

下面是我试过的三种做法。

尝试一:把 DerivedData 放进项目目录

Xcode 默认把编译产物放在 ~/Library/Developer/Xcode/DerivedData/。删掉 worktree 后,这里的缓存不会跟着删,时间长了留下一堆没人管的目录。

我把它改到项目目录里:

  • Xcode:Settings → Locations → Derived Data 选 Relative,填 DerivedData。

  • 命令行:

    Terminal window
    xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -derivedDataPath DerivedData
  • .gitignore 加一行 DerivedData/。

效果:删 worktree 时缓存一起删掉。但新 worktree 第一次编译还是从零开始。

尝试二:用 Worktrunk 复制缓存

接着想到:新建 worktree 时,把主目录编好的 DerivedData 复制过去,不就不用从零编了?

Worktrunk 正好支持:wt step copy-ignored 会把 .worktreeinclude 里列出的、被 git 忽略的文件复制到新 worktree,可以挂在创建 worktree 的钩子里自动跑。在 Mac 的 APFS 上它用的是 clone(写时复制,相当于 cp -c),我实测复制 3.8 GB 只多占 18 MB,用了 4 秒。

但复制过去以后编译,还是全量编译,编译任务一个不少。

我查了 Xcode 构建系统的资料才明白原因:

  • Xcode 用绝对路径记录每个编译任务。新 worktree 路径不同,记录全部对不上。
  • 判断文件有没有变时,它看的是 inode、修改时间这些元数据,不看内容。clone 出来的文件 inode 是新的,所以就算路径一样也会被当成改过。

而且重编以后,clone 共享的数据块都被重写了,省下的空间也没了。

这不怪 Worktrunk。复制 node_modules 这类缓存是有用的,只是 Xcode 的增量编译不认。对 Xcode 来说,这一步可以去掉。

尝试三:worktree 池

既然 Xcode 认路径,那就不换路径。我的做法是建一个 worktree 池:

  • 固定开 3 个 worktree(slot-0、slot-1、slot-2),长期保留。
  • 换任务时不新建目录,在原目录里 git switch 切分支。

git 切分支时只改写有差异的文件,没变的文件原样不动,Xcode 只需要重编受影响的部分。

以前:每个任务一个新目录
任务 1 ──> 新建 myapp.feat-A ──> 冷编译(82s) ──> 删除
任务 2 ──> 新建 myapp.feat-B ──> 冷编译(82s) ──> 删除
现在:目录固定
myapp.slot-0 ──> 切到 feat-A ──> 增量编译(26s) ──> 交还 ──> 切到 feat-B ──> 增量编译

我的实测

在同一个目录里依次切到几个不同的提交,每次编译:

场景耗时编译任务数
冷编译82 秒3,804
切到改了 9 个文件的提交26 秒666
切到改了约 37 个文件的提交27 秒833

省下的大头是第三方库。冷编译时它们占了约 2400 个任务,换分支后一个都不用重编。

几点限制

  • 收益看模块结构。 我那次只改了一个底层模块的 9 个文件,依赖它的主 App 模块几乎整个重编了。底层模块的接口一变,下游会跟着重编。
  • 我测的是沿着主干历史切换提交,不是几条兄弟分支之间切换。改动量相近,但我没有直接测。
  • 剩下的 26 秒大多是固定开销。 改 37 个文件只比改 9 个多 1 秒,再往下压就不是编译的事了。
  • 每个 slot 第一次编译还是冷编译,只是之后不用再冷编译。

怎么做

一次性创建 3 个目录:

Terminal window
git worktree add --detach ../myapp.slot-0 origin/main
git worktree add --detach ../myapp.slot-1 origin/main
git worktree add --detach ../myapp.slot-2 origin/main

开始一个任务,进一个空闲的目录:

Terminal window
cd ../myapp.slot-0
git fetch origin
git switch -c feat/my-feature origin/main

做完、代码已经推送后,交还这个目录:

Terminal window
git switch --detach origin/main
git branch -D feat/my-feature

怎么判断目录空闲

我的规则只有一条:处于 detached HEAD,并且 git status --porcelain 没有输出,就是空闲。

Terminal window
if ! git symbolic-ref -q HEAD > /dev/null && [ -z "$(git status --porcelain)" ]; then
echo "空闲"
else
echo "占用"
fi

我没用文件锁或进程锁,因为它们管不住我真正担心的事:

  • Agent 崩溃退出,锁释放了,可目录里还有没提交的代码。下一个任务进来会把它覆盖。
  • Agent 早就推送完了,进程却还挂着,锁一直不放,目录白白被占。

看 git 状态就没这些问题:有没提交的改动,目录就一直算占用,等人来处理。

写成脚本

手敲这些命令容易出错,我把它们写成了一个小脚本 slot.py,只依赖 Python 3 和 git:

Terminal window
python3 slot.py acquire feat/my-feature # 找一个空闲 slot 切到这个分支,打印路径
python3 slot.py release ../myapp.slot-0 # 代码推送后交还
python3 slot.py status # 看每个 slot 在干什么

它做的事就是上面几节说的:

  • acquire:按顺序找空闲的 slot,目录不存在就先创建。分支本地或远端已有就切过去,没有就从 origin/main 新建。都被占了就报错,由调用方决定等还是放弃。
  • release:工作区不干净、或者 HEAD 还没推送到远端,就拒绝交还;通过后 detach 并删掉本地分支。
  • slot 建在主仓库旁边,叫 <仓库名>.slot-0、slot-1……。数量和基线分支可以用环境变量 SLOT_COUNT、SLOT_BASE 改。

我派发 Agent 时,先 acquire 拿到目录,让 Agent 在这个目录里干活;Agent 开完 PR、我核对过之后再 release。认领和交还都由派发的一方做,不交给 Agent 自己,免得它忘了交还。

脚本只管目录,不管 DerivedData 放在哪。DerivedData 的位置还是靠尝试一里的设置:Xcode 的 Relative 设置对所有项目生效,命令行构建记得传 -derivedDataPath。

完整代码:

#!/usr/bin/env python3
"""worktree 池:固定几个 worktree,换任务时在原目录里换分支。
python3 slot.py acquire <分支> 认领一个空闲 slot 并切到该分支,打印路径
python3 slot.py release <路径> 改动已推送后交还 slot
python3 slot.py status 列出每个 slot 的状态
slot 是否空闲只看 git 状态:detached HEAD 且工作区干净。
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
SLOTS = int(os.environ.get("SLOT_COUNT", "3"))
BASE = os.environ.get("SLOT_BASE", "origin/main")
class SlotError(Exception):
pass
def git(cwd, *args, check=True):
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
if check and r.returncode != 0:
raise SlotError(f"git {' '.join(args)} 失败:{(r.stderr or r.stdout).strip()}")
return r
def main_root(cwd):
# 从任何一个 slot 里调用,都能找到主 checkout
common = git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir").stdout.strip()
return Path(common).parent
def slot_paths(root):
return [root.parent / f"{root.name}.slot-{i}" for i in range(SLOTS)]
def branch_of(path):
return git(path, "symbolic-ref", "-q", "--short", "HEAD", check=False).stdout.strip() or None
def dirty(path):
return bool(git(path, "status", "--porcelain").stdout.strip())
def pushed(path):
return bool(git(path, "branch", "-r", "--contains", "HEAD").stdout.strip())
def free(path):
return branch_of(path) is None and not dirty(path)
def acquire(root, branch):
if f"branch refs/heads/{branch}" in git(root, "worktree", "list", "--porcelain").stdout:
raise SlotError(f"分支 {branch} 已经在别的 worktree 里检出")
git(root, "fetch", "--quiet", "origin")
if git(root, "show-ref", "--verify", "-q", f"refs/heads/{branch}", check=False).returncode == 0:
switch = ["switch", branch]
elif git(root, "show-ref", "--verify", "-q", f"refs/remotes/origin/{branch}", check=False).returncode == 0:
switch = ["switch", "-c", branch, "--track", f"origin/{branch}"]
else:
switch = ["switch", "-c", branch, "--no-track", BASE]
for path in slot_paths(root):
if not path.exists():
git(root, "worktree", "add", "--detach", str(path), BASE)
if not free(path):
continue
r = git(path, *switch, check=False)
if r.returncode == 0:
return path
# 两个人同时抢同一个 slot 时,git 的 index.lock 只让一方成功,换下一个
if "index.lock" not in r.stderr:
raise SlotError(r.stderr.strip())
raise SlotError("没有空闲 slot")
def release(root, path, force):
path = path.resolve()
if path not in [p.resolve() for p in slot_paths(root)]:
raise SlotError(f"{path} 不是 slot")
if dirty(path):
raise SlotError("有未提交的改动,先提交推送,或确认丢弃")
if not force and not pushed(path):
raise SlotError("HEAD 还没推送到远端;确认不需要了再加 --force")
branch = branch_of(path)
git(root, "fetch", "--quiet", "origin")
git(path, "switch", "--quiet", "--detach", BASE)
if branch:
# 代码已经在远端,删掉本地分支,下次从远端取最新的
git(root, "branch", "-D", branch)
def status(root):
for path in slot_paths(root):
if not path.exists():
print(f"{path.name} 未创建")
continue
state = "空闲" if free(path) else (branch_of(path) or "detached")
note = " 有未提交改动" if dirty(path) else ""
print(f"{path.name} {state}{note}")
def main():
p = argparse.ArgumentParser()
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("acquire").add_argument("branch")
r = sub.add_parser("release")
r.add_argument("path", type=Path)
r.add_argument("--force", action="store_true")
sub.add_parser("status")
args = p.parse_args()
try:
root = main_root(Path.cwd())
if args.cmd == "acquire":
print(acquire(root, args.branch))
elif args.cmd == "release":
release(root, args.path, args.force)
else:
status(root)
except SlotError as e:
print(f"slot:{e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

踩过的坑

  • 别用 git clean -fdx。 -x 会连被忽略的 DerivedData/ 一起删掉,下次又是冷编译。清理未跟踪文件用 git clean -fd。
  • 同一个分支不能同时在两个 worktree 里检出,git 会报 fatal: 'feat/xxx' is already used by worktree at '...'。所以交还时要 detach 并删掉本地分支,以后要接着改这个分支,在任意空闲目录里从远端拉下来就行。
  • Worktrunk 的默认流程会删目录。 它的思路是「一个分支一个目录,用完就删」:wt merge 合并后自动删 worktree,wt remove 也会删目录。在固定目录里执行一次,缓存就没了。我现在让 Agent 只在固定目录里干活,Worktrunk 留给自己临时开 worktree 用。

顺手:清理旧 worktree

不管用不用固定目录,都可以先看一眼:

Terminal window
git worktree list

我那 24 个里大部分早就合并了,只是没人删。逐个删掉:

Terminal window
# 删除目录并注销;有未提交改动时会拒绝
git worktree remove ../myapp.feat-old

注意 git worktree prune 只清理「目录已经被手动删掉、记录还在」的情况,不会释放空间。

试过但没用的

  • 限制同时编译的数量(排队、信号量):机器不卡了,但总共写进磁盘的数据一样多。
  • Xcode 26 的 Compilation Caching(默认关闭):我测到跨目录能命中约 56%,但编译产物照样写进每个目录自己的 DerivedData,写入量没少;耗时几次测下来波动比差异还大,看不出稳定收益。