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

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)创建和清理。
用了一阵子,遇到两个问题:
- 磁盘不够用。 我的项目单个目录的构建产物约 4 GB。24 个 worktree 攒下来,光 DerivedData 就有 94 GB。
- 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 个目录:
git worktree add --detach ../myapp.slot-0 origin/maingit worktree add --detach ../myapp.slot-1 origin/maingit worktree add --detach ../myapp.slot-2 origin/main开始一个任务,进一个空闲的目录:
cd ../myapp.slot-0git fetch origingit switch -c feat/my-feature origin/main做完、代码已经推送后,交还这个目录:
git switch --detach origin/maingit branch -D feat/my-feature怎么判断目录空闲
我的规则只有一条:处于 detached HEAD,并且 git status --porcelain 没有输出,就是空闲。
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:
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 argparseimport osimport subprocessimport sysfrom 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
不管用不用固定目录,都可以先看一眼:
git worktree list我那 24 个里大部分早就合并了,只是没人删。逐个删掉:
# 删除目录并注销;有未提交改动时会拒绝git worktree remove ../myapp.feat-old注意 git worktree prune 只清理「目录已经被手动删掉、记录还在」的情况,不会释放空间。
试过但没用的
- 限制同时编译的数量(排队、信号量):机器不卡了,但总共写进磁盘的数据一样多。
- Xcode 26 的 Compilation Caching(默认关闭):我测到跨目录能命中约 56%,但编译产物照样写进每个目录自己的 DerivedData,写入量没少;耗时几次测下来波动比差异还大,看不出稳定收益。