我日常会同时开好几个 Claude Code / Codex 会话,每个会话在自己的 git worktree 里干活。互不干扰、随时可以丢弃,这本来是个好习惯。
直到这台 MacBook(可用空间 926 GB)在三周里满了四次。
四次满盘#
| 日期 | 清理前可用 | 清理后可用 | 释放 |
|---|---|---|---|
| 9 月 7 日 | 16 GB | 540 GB | 约 524 GB |
| 9 月 25 日 | 2.6 GB | 247 GB | 约 245 GB |
| 9 月 30 日 | 2.4 GB | 108 GB | 约 106 GB |
| 10 月 1 日 | 14 GB | 171 GB | 约 157 GB |
四次清理都是我让 Claude Code 自己做的。每次清出来的空间,绝大部分是同一种东西:Rust 的 target/ 编译目录。第一次清理时,46 个 target/ 加起来有 467 GB,占了整块盘的一半。
为什么会这样#
问题出在 Rust 编译产物和并行 worktree 的组合上:
- 每个 worktree 各有一份
target/。 我的 Rust workspace 中等规模,debug 编译一次就是 10–40 GB。十个 worktree 就是十份。 - 会话结束后,
target/没人删。 会话归档了、代码合并了,但目录还在,几十 GB 就一直躺着。 - agent 不会主动清理。 它的目标是让测试通过,不是替我省磁盘。
第一次“根治”,反而更糟#
第二轮清理后,我让 Claude Code 给其中一个仓库配了一个所有 worktree 共享的编译目录(在 .cargo/config.toml 里设 build.target-dir)。十份变一份,理论上能省下大头。
5 天后盘又满了,还多出一个陌生目录:~/.cache/cargo-target-claude/,里面一个子目录就有 156 GB。
翻会话记录才发现:没有任何规则设置过这个目录,是 agent 自己起的名字。
原因是共享编译目录有个副作用:cargo 会给编译目录加锁,多个 agent 同时编译时,后来的要排队等。agent 不想等,就各自把 CARGO_TARGET_DIR 指到一个新目录,绕开了锁,用完也没删。
给 agent 加了约束,它会想办法绕过去。如果绕过去的那条路没人兜底,问题只是换了个地方出现。
清理时踩过的四个坑#
这些坑都是 Claude Code 在清理过程中踩的。都及时发现、恢复了,但每一个都值得变成规则。
坑 1:按名字删 dist/,删掉了签入仓库的文件#
它按 -name dist 批量删前端构建产物,命中了一个签入 git 的 vendored 依赖里的 dist/,波及 22 个 worktree、约 2300 个文件。好在这些文件都在 git 里,git checkout 就能还原。
更值得警惕的是:上一轮它用的是同一条规则,只抽查了 3 个仓库就判定“安全”。
规则:删任何“构建产物”之前,先用
git ls-files -- <目录>确认里面没有被追踪的文件。目录叫dist、build,不代表它就是产物。
坑 2:删掉了正在运行的开发服务器的依赖#
它特意写了跳过规则,要保留一个正在运行的 vite 开发服务器的 node_modules,但 shell 的模式匹配没生效,还是删了。服务器立刻开始返回 500,npm ci 重装后才恢复。
规则:删除前用
lsof +D <目录>看有没有进程打开里面的文件,不要靠自己写的路径匹配去猜。
坑 3:检查和删除之间的竞态#
确认“没有 cargo 在跑”之后,它开始删那个 156 GB 的目录。删到一半,另一个会话恰好开始编译,往同一个目录里写文件,rm 报了 “Directory not empty”。另一个会话的那次编译只能从头全量重来。
规则:检查和删除之间总有时间窗口。删除要能容忍失败,删完要复核,不能假设一定成功。
坑 4:rm -rf "$DIR/$SUB"#
它写了一句拼接变量的删除命令,被 Claude Code 内置的安全检查拦下来了。道理很简单:变量一旦为空,这句就变成 rm -rf /。
规则:删除时用字面绝对路径,或者写成
"${DIR:?}/${SUB:?}",变量为空就直接报错退出。
回收方案:谁创建,谁回收#
清理只能治标。要根治,得回答两个问题:
- 编译目录建在哪里? 要建在能确定“主人”的地方。
- 谁负责删? 要有一个机制在会话结束时自动删,而不是靠人或 agent 记得。
我的方案分三层。
第一层:会话开始时,分配一个专属目录#
Claude Code 的 SessionStart hook 可以通过 CLAUDE_ENV_FILE 给会话注入环境变量:
export CARGO_TARGET_DIR="$HOME/.cache/cargo-target-claude/<session_id>"
这样每个会话独占一个目录,不用抢锁,agent 也就没理由再自己另起一个;目录名就是会话 ID,主人明确,回收时不用猜。
要注意的是,只对允许的仓库启用。我有些仓库的打包脚本写死了 target/release/... 路径,全局改 CARGO_TARGET_DIR 会让打包找不到产物。
第二层:会话结束时,回收自己的目录#
SessionEnd hook 在会话结束时触发,拿到 session_id,删掉对应的编译目录;如果会话跑在 .claude/worktrees/ 下的某个 worktree 里,顺带回收这个 worktree 里的 target/。
两个细节:
- hook 有超时限制,删上百 GB 可能要几分钟。所以 hook 只负责启动一个脱离的后台进程,自己立刻返回。
- hook 出错不能卡住会话,所有异常只写日志。
第三层:定时兜底#
Claude Code 没有“归档前”这个事件,SessionEnd 也不保证所有情况下都会触发(比如崩溃或强制退出)。所以还要一个定时任务,定期扫描超过 24 小时没动过的编译目录。
删除前的检查清单#
不管哪一层触发,每次删除前都过一遍:
- 路径落在白名单根目录下,并且不是根目录本身
- 确实是 cargo 编译目录(有
CACHEDIR.TAG,或有debug/、release/) -
git ls-files为空,没有被追踪的文件 - 最近 N 小时没有写入(定时兜底时)
-
lsof +D没有进程打开里面的文件 - 没有 cargo / rustc 进程在命令行里引用这个路径
- 会话 ID 符合严格格式,防止
../../这类路径穿越
目前进展#
回收脚本已经写好,按上面的清单做了检查。只读的预演结果符合预期:唯一的候选目录正被另一个会话使用,因为最近有写入而被跳过。
删除逻辑另外在一个隔离的临时目录里测过:闲置的编译目录会被删掉;最近写入过的、有进程占用的、被 git 追踪的、在配置根目录之外的,以及根目录本身,都会被跳过;伪造的 ../ 会话 ID 也会被拒绝。
在我自己的机器上,hook 和定时任务还没有启用。它们会改变之后每个会话的运行环境,Claude Code 的自动模式把这类改动判定为“修改 agent 自身环境”,要我本人逐项批准。我觉得这个拦截是对的。所以这篇写的是方案和踩坑记录,还不能证明它已经解决了问题;跑一段时间后再补实际效果。
让你的 Agent 也用上#
我把这套检查和回收方法整理成了一份公开 Skill:交给 Agent 回收编译产物。
把下面这段话复制给你的 Claude Code、Codex 或其他编程 Agent:
请先阅读 https://owenshen.top/skills/agent-build-gc/SKILL.md,按其中步骤帮我检查本机 AI 编程会话留下的 Rust 编译产物。先只做第 1 步的只读检查并给我报告;下载脚本、删除文件或修改 agent 配置之前,都要先说明会改什么并等我同意。
它会先只读检查,告诉你编译产物占了多少、在哪里。之后每一步(下载脚本、预演、真正删除、安装会话结束自动回收的 hook)都会先征求你的同意。这份 Skill 目前是实验性的,原因见上一节。
带走的四条经验#
- AI 编程助手默认磁盘是无限的。 并行会话开得越多,问题越严重。
- “共享”不一定省空间。 共享带来锁,锁会把 agent 推去绕路,绕出来的产物比共享之前更难追踪。
- 谁创建,谁回收。 让资源的生命周期跟会话绑在一起,比事后大扫除可靠。
- 批量删除要基于事实,不要基于假设。 用
git ls-files判断,而不是看目录名;用lsof判断,而不是靠路径匹配;删完要复核。