一个简简单单的网站代码库,本地占了 50G。
遇到这种情况,多数人的第一反应是「AI 又乱搞了」。但真实原因往往更平淡:AI 建了一堆 workspace,而没有任何人删过它们。而这件事之所以会发生,是因为你不懂 Git 的工作模型,于是没法判断「哪些目录是正常的、哪些是多出来的」。
所以这篇从最基础的地方讲起,最后落到那 50G 是怎么来的、以及怎么清掉。文里的磁盘数字都是我在一个真实的网站仓库上实测的,不是估的。
一、Git 的最小模型:三个区域
Git 真正需要你先记住的东西只有三个区域。
| 区域 | 是什么 | 文件在哪 |
|---|---|---|
| 工作区 | 你打开编辑器看到、能改的那些文件 | 你的项目目录 |
| 暂存区 | 一份「我准备把哪些改动提交进去」的清单 | .git/index |
| 仓库 | 已经提交过的历史 | .git/ 里面 |
日常三条命令正好对应这三个区域之间的关系:
git add <文件> # 工作区 → 暂存区:把这次改动放进「准备提交」清单
git commit -m "..." # 暂存区 → 仓库:把清单上的内容做成一个提交,永久记下来
git checkout <分支> # 仓库 → 工作区:把某个历史状态取出来,覆盖到工作区
这张图是理解后面一切的基础。尤其要记住第三行:Git 可以从仓库里重新生成工作区的文件。这意味着工作区在 Git 的设计里是一个可以随时重建的临时产物,而不是数据本体。数据本体在 .git 里。
这个认知后面会用到,因为它直接决定了「删工作区」这件事在 Git 的模型里是不是安全。
为什么要有暂存区
新手最常见的疑问是:为什么不直接提交,中间非要加一层?
因为「改了什么」和「这次提交想包含什么」经常不是一回事。你在一个文件里同时修了一个 bug、顺手改了个错别字,暂存区让你把这两件事拆成两个提交。它把「编辑」和「组织历史」拆成了两个独立动作。
觉得麻烦的话,git commit -a 可以跳过暂存区把已跟踪文件的改动一起提交。但知道它存在比一直用 shortcuts 更重要,因为迟早你会需要只提交文件里的一部分改动(git add -p)。
二、一次提交到底做了什么
这里有个反直觉的事实:Git 存的是快照,不是改动。
很多人以为 Git 记录的是「第 1 行改了什么」,其实每次提交 Git 都存下那一时刻所有文件的内容。听起来很浪费,但它用两个机制把浪费消掉了。
机制一,内容寻址。 Git 里的每个对象都用它自己内容的哈希值命名。两个文件内容完全一样,就只存一份,因为它们算出同一个哈希。所以「存了 100 个版本的快照」实际占用的空间远小于 100 倍。
机制二,对象只有四种。
| 对象 | 存什么 |
|---|---|
| blob | 一个文件的内容 |
| tree | 一个目录的结构(哪些文件名指向哪些 blob/tree) |
| commit | 指向一个 tree,加上作者、时间、提交信息、父提交 |
| tag | 给某个对象起的带注释的名字 |
于是「一次提交」在磁盘上就是:一堆 blob(内容)加一层 tree(目录结构)加一个 commit(指向那层 tree)。没改动的文件不会产生新 blob,新的 tree 只是复用旧的哈希。
分支是什么
这是新手最容易想复杂的地方。分支不是一份代码副本,它是一个文件,里面只有一行:一个提交的哈希值。
cat .git/refs/heads/master
# 输出类似:4a2097c8f3...(40 个字符)
就这样。所以「建一个分支」几乎不占空间,也不用复制代码。你在新分支上提交,Git 只是把这个文件里的哈希改成了新的提交。原来的提交还在,因为它被别的引用指着。
HEAD 是另一个小文件,它通常不直接存哈希,而是写「我现在在哪个分支上」:
cat .git/HEAD
# ref: refs/heads/master
理解这两条之后,「切换分支」为什么快也就清楚了:改一个文件里的一个哈希,然后把工作区的文件换成那个提交对应的内容。
三、.git 里到底有什么
按重要性排:
| 路径 | 作用 |
|---|---|
objects/ |
对象库,所有 blob/tree/commit 都在这。松散对象直接放,打包后进 pack/ |
refs/heads/ |
本地分支(每个分支一个文件,一行哈希) |
refs/tags/ |
标签 |
HEAD |
当前在哪个分支 |
index |
暂存区 |
logs/ |
reflog,记录 HEAD 的移动历史(救命用的) |
config |
仓库配置 |
worktrees/ |
链接工作区的元数据,后面会重点讲 |
看对象库的实际占用,用这条命令:
git count-objects -vH
它输出的是这样(这是我在那个真实仓库上跑出来的):
count: 1621 # 松散对象的个数
size: 8.21 MiB # 松散对象占用
in-pack: 3574 # 已打包对象的个数
packs: 2 # pack 文件个数
size-pack: 71.54 MiB # pack 占用
prune-packable: 17 # 已打包但松散副本还在的,可清
garbage: 0 # 无效文件
size-garbage: 0 bytes
这里的 size 加 size-pack 大约是 80 MiB,正好对应那个仓库 .git 目录的 82M。这个数字是健康的:818 个跟踪文件的网站仓库,历史全加起来 82M。
记住这个量级。等下你就知道 50G 跟它差了几个数量级。
四、worktree:AI 为什么会给你长出 workspace
现在进入正题。
git worktree 是 Git 2015 年就有的功能,官方定义是:一个独立的工作目录,有自己的文件和分支,但与主检出共享同一份仓库历史和远端。
官方术语里,git clone 出来的那个叫「主工作区」(main worktree),git worktree add 出来的是「链接工作区」(linked worktree)。一个仓库有一个主工作区,加上零个或多个链接工作区。
命令不多:
git worktree add <路径> -b <新分支> # 建一个
git worktree list # 列出全部
git worktree remove <路径> # 删一个
git worktree prune # 清理已经不存在的记录
git worktree lock / unlock # 锁定/解锁(防误删)
为什么 AI 会用这个?因为并行。你让 AI 同时干两件事,它需要两份互不干扰的文件,于是给每件事开一个 worktree。Claude Code 的官方文档把这条讲得很直白:每个会话一个 worktree,就有了一份独立的文件副本,一个会话做功能、另一个会话修 bug 不会互相踩。
Claude Code 的默认行为
这是我认为每个用 AI 写代码的人都该知道的一段,因为官方文档自己写了:
- 加
--worktree(或-w)加一个名字,就会建一个隔离的 worktree 并在里面启动 - 默认位置是仓库根目录下的
.claude/worktrees/<名字>/,分支名是worktree-<名字> - 官方还给了一条提示:把
.claude/worktrees/加进.gitignore,否则这些内容会作为未跟踪文件出现在你的主检出里 - 然后是关键的一句:「worktree 是一次全新检出,所以要在里面初始化开发环境:让 Claude 装依赖,或者你自己在 worktree 目录里跑项目的安装命令。」
最后这句就是 50G 的起点。而在清理那一节,官方还写了另一句:
非交互运行(
-p)没有退出提示,所以 Claude 不会清理它们的 worktree,而且 Claude Code 在每个 worktree 上留了一把创建时拿的锁,直到后续某个会话的过期锁清扫才释放。
「没有退出提示,所以不会清理」——这就是「AI 建了不删」的官方解释。这是设计上的取舍,谈不上 bug:无人值守跑的时候没人可问,于是干脆不删。
Codex 也一样。openai/codex 仓库里有一个独立的 codex-rs/worktree/ 模块(带 src/、tests/),说明它的 worktree 支持是内建能力,不是外挂。
现在算账:50G 是怎么来的
我在一个真实的网站仓库上做了实测。这个仓库 818 个跟踪文件:
| 项 | 实测大小 |
|---|---|
工作区(不含 .git) |
265M |
其中 .git |
82M |
其中 public/ |
141M |
其中 resources/ |
52M |
建一个 worktree 后,主 .git |
仍是 82M |
| 新建的 worktree 本身 | 72M |
两个结论值得记住。
第一,worktree 不会让 .git 翻倍。 建了之后主仓库的 .git 还是 82M,因为对象库是共享的。这一点很重要,因为它意味着「worktree 很省」这个印象在 .git 这一层是对的。它省是因为每个链接工作区里的 .git 只是一个 51 字节的文件,内容是:
gitdir: /opt/data/hugo-blog/.git/worktrees/wt-demo
一行指针,指回主仓库的对象库。对象存一份,多个工作区共用。
第二,工作区文件是真复制。 新建的 worktree 是 72M,那是 818 个文件的完整检出。
而真正让数字爆炸的是 public/ 那 141M 和 resources/ 那 52M:这些是构建产物,不是 Git 跟踪的文件。所以新建的 worktree 一开始只有 72M,你一旦在里面跑一次构建或装一次依赖,它就会朝 265M 长。前端项目更极端,一个 node_modules 通常几百 M 到 1G 以上。
于是算一笔账:如果 AI 给你建了 50 个 worktree,每个都在里面装过依赖或跑过构建,就是 50 × 1G ≈ 50G。 而主仓库的 .git 始终只有几十 M。
这就是那个 50G 的完整来源。它跟代码大小无关,也跟 Git 存了多少无关,它是工作区的副本数量乘以每个工作区里那些不受 Git 管理的部分的体积。
五、三步查清楚
别急着删,先看清楚状况。三条命令,从外到内:
第一步是看目录体积的分布,目的是分清「Git 管的」和「你自己装出来的」:
du -sh --exclude=.git . # 工作区总共多大
du -sh .git # 历史占多少
du -sh * .[!.]* 2>/dev/null | sort -hr | head -15 # 谁最大
如果最大的几个是 node_modules、public、dist、build、target、.venv 这类名字,那基本可以确定问题不在 Git,在重复的构建产物。
第二步是看有哪些 worktree:
git worktree list
正常输出是一行一个工作区,带路径、提交和分支。如果有十几行、路径在 .claude/worktrees/ 下面,那就是了。
第三步是看对象库有没有异常膨胀:
git count-objects -vH
对照前面那张字段表。重点看两个:garbage 和 size-garbage 应该接近 0;prune-packable 有点数是正常的。如果 size(松散对象)很大,说明很久没做过垃圾回收。
六、安全清理的顺序
按这个顺序做,每一步都可以先看再做。
第 1 步,先看,再决定删哪个:
git worktree list
第 2 步,目录已经手动删掉的,用 prune 清记录。先 dry-run:
git worktree prune -n -v # -n 是 dry-run,只看会删什么
git worktree prune -v # 确认后再真删
这一步值得单独说,因为它对应一个很常见的状态。我实测过:直接 rm -rf 掉 worktree 目录之后,git worktree list 仍然会列出它,并且标注 prunable:
/opt/data/hugo-blog 4a2097c [master]
/tmp/wt-demo 4a2097c [worktree-demo] prunable
prune -n 会告诉你它准备删什么:
Removing worktrees/wt-demo: gitdir file points to non-existent location
也就是说:手删目录之后 Git 不知道,那些元数据会一直留着,让 git worktree list 的输出越来越长,你也越来越看不清哪个是活的。
第 3 步,正常的删除方式(推荐):
git worktree remove <路径>
如果它报错说 worktree 被锁了(无人值守的 AI 会话留下的锁),先解锁:
git worktree unlock <路径>
git worktree remove <路径>
第 4 步,清完之后做一次垃圾回收:
git gc
第 5 步,关于 git clean,必须加一句警告。 它会删掉未跟踪的文件,而 node_modules、.env、你还没提交的新文件全都属于「未跟踪」。所以永远先跑 dry-run:
git clean -nd # -n = dry run,只列出会被删的东西
git clean -fd # 确认清单没问题之后再执行
git clean -fdx 里的 -x 更狠,连 .gitignore 里忽略的文件也一起删。它是清 node_modules 最快的办法,也是误删本地配置最快的办法。
第 6 步,顺手做一件预防的事。按官方提示,把 AI 的 worktree 目录加进 .gitignore:
echo ".claude/worktrees/" >> .gitignore
这样它们至少不会污染 git status。
七、为什么这件事本来不该发生
最后说一个判断,因为我觉得它比上面所有命令都重要。
在 Git 的模型里,工作区是可以随时重建的。第一节那张表里第三行 git checkout 就是这件事:从仓库里重新生成文件。这个设计前提意味着,重建一个工作区的成本应该很低,低到「用完就扔」是自然的做法。
而 AI 把「建」变得极便宜:一条命令。但它没有把「清」变得更便宜,因为清理需要知道这个工作区里有没有还没提交的东西,那是需要判断的,不能自动化。于是建和清的成本出现了不对称,结果就是只增不减。
所以新手真正需要跨过的那道坎,在于知道那条分界线在哪:哪些目录是 Git 管的,哪些是你自己装出来的。命令本身不重要。
这条线就是 .gitignore 的边界。线的里面,删了能从仓库重建,随便删;线的外面(node_modules、构建产物、.env),删了要重新装、重新配,Git 帮不了你。50G 全部来自线的外面。 而 du -sh * | sort -hr | head 这条命令,就是把这条线画出来的工具。
从这个角度回头看第一节那张三个区域的表,你会发现它不只是为了应付面试。它决定了你面对一个陌生的项目目录时,能不能在三秒内说出「这一半是代码,那一半是产物」。
写在最后
这篇的起点是一个很具体的麻烦,但落点是一条分界线。Git 的基础知识之所以值得学,不是因为你得手敲命令(AI 可以替你敲),而是因为没有它,你就没有能力验收 AI 做的事。AI 建了 50 个 worktree 不删,这个行为本身在 Git 的模型里是合理的,不合理的是没人能判断出该删哪些。
如果只想记一件事,就记这条:出问题先跑 du -sh * | sort -hr | head,看最大的那几个是不是构建产物。 是的话,问题跟 Git 无关,跟没人清理有关。
最后交代证据边界:文中 Claude Code 和 Git 的行为描述来自两边官方文档;磁盘数字和 prunable 那个状态是我在一个真实的网站仓库上实测的,命令和输出都贴在正文里,可以自己复现。
相关阅读:
- 《AI 一天合 3 万次代码,把 Git 逼到了极限》:那篇讲的是 Git 在 Agent 高并发下的基础设施压力,这篇讲的是同一套工具在你本机上留下的痕迹,两个方向不重叠。
- 《为什么是 Omarchy 而不是别的 Linux》:如果准备给 agent 配一台干净的开发机,那篇讲了系统和三个 agent 的完整安装路径。
- 《Claude Code 团队的代码审查方法》:同一个工具的另一面,那篇讲的是团队层面怎么让 AI 写的代码可验收,这篇讲的是个人层面怎么让自己看得懂 AI 做了什么。