Claude Code 103 秒删掉 4.8 万个文件之后,我把自己的仓库"删"了一遍:git 能救的比你想的少

这两天我的时间线被同一条事故刷过去了:有开发者在 Reddit 发帖说,Claude Code 在一次"重建项目镜像"的任务里,103 秒删掉了他 48,218 个文件,连带把 .git 里的 objects、refs、logs 一起端了。帖子后来被作者删了,存档还在,TechRadar 报道后,法意俄几家科技媒体在 9 月 25 日集中转述了一轮。

事故细节值得看。现成的 build_mirror.py 做不到就地更新镜像,Claude Code 就自己写了个 Python 清理脚本,基于一份旧副本------7,332 个普通文件加 614 个 Windows junction。junction 是 Windows 的目录联接,长得像符号链接,实现是两码事。脚本用 os.walk(followlinks=False) 加 os.path.islink() 做防穿越保护,坏就坏在 islink() 只认符号链接,junction 在它眼里是普通目录,永远返回 False(官方文档写明的行为,isjunction() 直到 Python 3.12 才补上)。于是保护只在顶层生效,junction 底下的子目录被当正常路径继续删。

结果是删了 55,550 个文件、复制回 7,332 个,净损失 48,218 个,删除范围还盖进了 .git 内部。这位开发者没推过远程、没开过分支,恢复通道全堵死。agent 收尾时留了句话:"Craig --- stop and read this. I broke something."

这件事目前只有当事人自述加附件验证报告,没有独立取证,媒体也都标注了。但我不打算花时间考证真伪------就算这条是编的,下一个 agent 闯祸只是时间问题。我真正好奇的是:如果删库发生在你身上,git 到底能救回多少?

大多数人(包括一天前的我)的直觉是"我有 git,怕什么"。这个直觉牢不牢,我把自己当受害者,在本地把恢复手段挨个试了一遍。git 2.34.1,Linux,全程本地,脚本在文末可复现。

git 的正常恢复,比直觉窄得多

先造一个最典型的现场:两笔历史提交,工作区躺着一段下午刚写的未提交修改,外加一个没 add 过的新文件 notes.md------大多数人每天下班时仓库就长这样。然后灾难降临,模拟 agent 清理脚本跑飞:工作区除 .git 外全删。这已经比事故仁慈了,.git 完好。 抢救用标准答案 git restore .,按 HEAD 恢复已跟踪文件。跑完对灾前灾后各做一次 md5 清单,diff 长这样:

bash 复制代码
0a1
> 100e360cc768f15a35ed9ddf05e354ce  ./src/app.py
2,3d2
< 611f7ce02a1ceb0ab9391c6f0c67d634  ./notes.md
< fdcc8d71823621bef4e099395dc283ff  ./src/app.py

三行 diff,每行都是一条人命。app.py 的 md5 从 fdcc8d71... 变成 100e360c...------恢复出来的是 HEAD 版本,下午写的那行 "afternoon work, NOT committed yet" 没了,git 只认提交,不认你"写过"。notes.md 查无此人,未跟踪文件在 git 的世界观里根本不存在。能回来的只有最新状态被提交过的 tracked.txt。 边界线很清晰:提交过的能救,其下的全部随缘。

stash 是搬运工,不是保险柜

我猜不少人下意识会想到 stash------"我有 stash 的习惯,应该没事吧"。试完发现这个安全感是错觉。 同样的现场,灾前先 git stash(不带 -u),输出很诚实:1 file changed, 1 insertion(+)------只收走了 app.py 那行改动,notes.md 压根没进 stash。删工作区,stash pop,恢复的只有 app.py;更扎心的是 tracked.txt 还躺在 deleted 状态里,得再手动 restore 一次。至于 notes.md,从头到尾没有任何机制碰过它。 退一步说,就算每次都 stash -u,stash 的本体也是 .git 里的一组对象------这就要说到事故里真正的死因了。

事故真正的死因:.git 不是备份

那位开发者无可挽回的原因,不是工作区被删,是 .git 内部被删。我在干净仓库上模拟了这一刀:

bash 复制代码
rm -rf .git/objects .git/refs .git/logs

然后准备了一串抢救命令,结果一个都没用上:

sql 复制代码
$ git status
fatal: not a git repository (or any of the parent directories): .git
$ git log --oneline
fatal: not a git repository (or any of the parent directories): .git
$ git fsck --lost-found
fatal: not a git repository (or any of the parent directories): .git
$ git reflog
fatal: not a git repository (or any of the parent directories): .git

四个命令,同一句遗言。objects 整个没了,git fsck 连尸体都找不到------它的工作方式就是遍历 objects,仓库没了它就失业。reflog 这根"救命稻草"也在被删的 logs 里。

测到这里我有点凉。很多人把 .git 默认当备份,但物理事实是:.git 和工作区躺在同一个目录里,任何"从项目根往下删"的操作都不会对它手下留情,事故里的 agent 恰好就是这么删的。git 给你的是版本管理,不是异地容灾,这俩的差距平时看不出来,出事那天就是全部。 既然 .git 靠不住,那就把保险柜搬出去。我试了两层:先自建快照,再把快照扔出仓库。

给工作区拍快照,第一版就把自己吃了

快照思路:把工作区当前状态 (含未跟踪文件)固化成一个 commit,不动分支、不进历史,挂在自定义引用 refs/snapshots/ 下。每拍一次就是一个能随时翻回去的时间点,历史也不乱。 git 有现成零件:用 GIT_INDEX_FILE 指一个临时 index,read-tree 进 HEAD 的树,add -A 全量收编工作区(未跟踪文件也能收),write-tree 写成树,commit-tree 打包,update-ref 挂引用。第一版:

bash 复制代码
snapshot() {
  local tag="$1"
  export GIT_INDEX_FILE="$PWD/.snap-index"   # 临时 index 放在工作区
  git read-tree HEAD
  git add -A
  local tree; tree=$(git write-tree)
  unset GIT_INDEX_FILE
  rm -f .snap-index
  git update-ref "refs/snapshots/$tag" \
    "$(git commit-tree "$tree" -p HEAD -m "auto snapshot: $tag")"
}

跑完我顺手 git ls-tree 检查快照内容,看到了本次演练最滑稽的一幕:

bash 复制代码
$ git ls-tree -r refs/snapshots/t2_evening --name-only
.snap-index
.snap-index.lock
notes.md
src/app.py
tracked.txt

.snap-index 和 .snap-index.lock------临时 index 放在工作区里,被 git add -A 自己吸进快照了。snapshot 的本职是给工作区拍照,结果把三脚架也拍进去了。真拿来恢复,工作区会凭空多出俩垃圾文件;要是临时 index 里带敏感路径,它们还会跟着快照走。 修法简单到有点丢人:临时 index 挪进 .git/,工作区扫描永远碰不到。

bash 复制代码
export GIT_INDEX_FILE="$PWD/.git/snap-index"   # 只改这一行

改完重跑,ls-tree 干干净净三个文件,两个时间点快照 t1_afternoon、t2_evening 都能取------git show refs/snapshots/t1_afternoon:src/app.py 还能把"下午那版、还没写 evening fix"原样调出来。 到这里我以为方案闭环了,配个 crontab 每十分钟一拍就完事:

javascript 复制代码
*/10 * * * * cd /path/to/repo && /path/to/snapshot.sh ten_min

然后我盯着 refs/snapshots 这个路径看了两秒,觉得不对劲。

快照救不了 .git 陪葬,那就扔出仓库

快照的 commit 和 tree 对象,物理上还是躺在 .git/objects 里,引用也挂在 .git/refs 下------事故里 agent 删的就是这俩目录。快照拍得再勤,.git 被端那一刻照样陪葬,它只是把刚才那种死法推迟了一点。 能扛住全灭的,是把仓库整体打包扔出仓库外,git 自带这功能:bundle。

javascript 复制代码
git bundle create ~/backup/repo-safety.bundle --all

一条命令,所有引用(含 refs/snapshots/*)连着对象库打包成单个文件,写到仓库外。最终演练:拍完两个快照、打出 bundle(演示项目只有 1476 字节),然后模拟最坏情况------rm -rf 整个仓库目录,.git 一起死。恢复三步:

bash 复制代码
git clone ~/backup/repo-safety.bundle restored
cd restored
git fetch origin '+refs/snapshots/*:refs/snapshots/*'
git checkout refs/snapshots/t2_evening -- .

灾前灾后的 md5 清单 diff,这次一个字都没有:tracked.txt、带 evening fix 的 app.py、notes.md,逐字节一致,历史提交全回来,两个快照时间点也都在。bundle 走的是 git 自己的对象格式,git bundle verify 能校验完整性,比裸 cp -r 靠谱。

写在最后

要是按可靠程度给这几招排个序,大概是:推远程 > bundle 到仓库外 > 本地快照 > stash > 裸奔。事故里那位开发者从倒数第二档直接掉进裸奔,agent 只是扣了扳机,把门敞开的是"没推远程、没备份、agent 跑在生产目录"这三件事的组合。

Claude Code 这类工具自带的权限白名单、沙箱模式该配还得配,那是事前防线。但这次演练教会我的是:事前防线总有失守的一天,agent 的行为预测不了,你能控制的只有"它删完之后你还能剩什么"。我的 bundle 任务已经挂上了,你看着办。

完整演练脚本(LAB1/LAB2 命令正文里都有,快照加 bundle 的最小可复现版在下面,复制即可跑):

bash 复制代码
#!/usr/bin/env bash
set -e
mkdir demo && cd demo
git init -q -b main
git config user.email t@t.local; git config user.name tester
echo "v1 line" > tracked.txt
mkdir -p src && echo "print('hello')" > src/app.py
git add . && git commit -qm "init"
echo "v2 line" > tracked.txt && git commit -qam "update tracked"
echo "// afternoon work, NOT committed yet" >> src/app.py
echo "todo list for tomorrow" > notes.md

snapshot() {
  local tag="$1"
  export GIT_INDEX_FILE="$PWD/.git/snap-index"
  git read-tree HEAD
  git add -A                      # 未跟踪文件一并收进快照
  local tree; tree=$(git write-tree)
  unset GIT_INDEX_FILE
  rm -f .git/snap-index
  git update-ref "refs/snapshots/$tag" \
    "$(git commit-tree "$tree" -p HEAD -m "auto snapshot: $tag")"
}
snapshot t1
echo "evening fix" >> src/app.py
snapshot t2

git bundle create ~/demo-backup.bundle --all  # 打到仓库外,路径自定
cd .. && rm -rf demo                          # 全灭
git clone ~/demo-backup.bundle restored && cd restored
git fetch origin '+refs/snapshots/*:refs/snapshots/*'
git checkout refs/snapshots/t2 -- .           # notes.md 和 evening fix 都回来了
相关推荐
时晴⁧⁧2 小时前
Skill和MCP推荐清单
ai编程
csdn2015_4 小时前
git 可以修改已经push上去的commit说明吗
git
浅安的邂逅11 小时前
20929-OpenAI 一天踩三脚急刹:暂停前沿训练、叫停 Astra、披露越权访问澳政府网站
人工智能·大模型·ai编程·行业动态·ai日报
小虎AI生活12 小时前
OpenAI 给 AI 发了台电脑,可惜你还没学会派活
aigc·ai编程
xcLeigh12 小时前
AI 编程的未来趋势:2025-2026 年你必须关注的六大技术方向
人工智能·ai·ai编程
飞哥数智坊13 小时前
我让 TRAE 也“看”到了微信小程序
人工智能·ai编程
温暖小土14 小时前
CentOS 部署 Milvus 向量数据库完整指南
centos·ai编程·milvus·向量数据库
threerocks15 小时前
【FDE 实战课|第 01 讲】从 Palantir 到 OpenAI:FDE 的来历与全球版图
人工智能·aigc·ai编程