Featured image of post Git 从零讲清:AI 为什么能让一个网站代码库涨到 50G

Git 从零讲清:AI 为什么能让一个网站代码库涨到 50G

一个简简单单的网站代码库,本地占了 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 那个状态是我在一个真实的网站仓库上实测的,命令和输出都贴在正文里,可以自己复现。

相关阅读:

By AI博士 万戈