暗无天日

=============>DarkSun的个人博客

git推送失败后恢复仓库损坏的完整记录

症状

电脑断电后,在仓库提交代码发现推送时失败:

$ git push -u origin main
致命错误:bad tree object 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6
remote: fatal: early EOF
错误:远程解包失败:index-pack failed
To git.zhlh6.cn:lujun9972/CodeQuest.git
 ! [remote rejected] main -> main (failed)
错误:无法推送一些引用到 'git.zhlh6.cn:lujun9972/CodeQuest.git'

满屏的 "bad tree object"、"early EOF"、"remote rejected" 让人头皮发麻。之前就发生过仓库索引损坏的问题,这次又来了。

分析原因

先用 git fsck--full 检查仓库完整性:

$ git fsck --full
损坏的链接来自于    tree d0c6a58cd6ccac7e967a1ec1923b86b6098c37cf
              到    tree 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6
损坏的链接来自于    tree 8043d4e27bf8f8f91f30f9519651dd0dfcc2b1af
              到    blob d7f34b8f17b66827d315980d4cfcc0b17530a327
缺失 tree 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6
缺失 blob d7f34b8f17b66827d315980d4cfcc0b17530a327

可以看到有两个对象找不到了(缺失 missing):

  • tree 对象 =4de66b3=:代表一个目录
  • blob 对象 =d7f34b8=:代表一个文件的内容

而另外两个 tree 则引用了这些丢失的对象(损坏的链接 broken link)。

虽然被引用的对象丢了,但引用它们的 tree 还在。用 git cat-file -p 读一下,就能知道缺失的对象本来对应哪个文件:

$ git cat-file -p 8043d4e
100644 blob d7f34b8f17b66827d315980d4cfcc0b17530a327	2026-04-26-achievement-wall-design.md

→ tree 8043d4e 说:"我引用了一个文件,叫 2026-04-26-achievement-wall-design.md=,它的内容哈希是 =d7f34b8"。

$ git cat-file -p d0c6a58
040000 tree 49ae87423ab81f7889dab9e24af935a5f0bfc5af	plans
040000 tree 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6	specs

→ tree d0c6a58 说:"我有一个 plans 子目录(哈希 49ae874=,还能读)和一个 =specs 子目录(哈希 =4de66b3=,丢了)"。

结合两条信息:缺失的 blob d7f34b8 对应文件 2026-04-26-achievement-wall-design.md=;缺失的 tree =4de66b3 是 specs 目录,里面应该只有这一个文件。

这就是 git 内容寻址的妙处:对象之间通过哈希互相引用,只要引用关系的某一段还在,就能顺着找出缺失的是什么。

怎么理解 git 的这些对象呢?

git 把仓库当作一个"内容寻址文件系统"。每次提交都对应一个 tree(目录树),
tree 里可以包含 subtree(子目录)或 blob(文件)。
如果某个 tree 引用的子树或文件找不到了,就出现上面的"损坏的链接"。

这个损坏发生在很久之前的一次 "fix: restore repository after index corruption" 提交中。当时为了恢复仓库用了某些激进操作,把一些对象搞丢了,但当时没发现。直到现在推送时,远程服务器验证完整性才发现问题。

尝试修复的过程(走弯路)

尝试一:直接 git gc

最简单直接的想法——让 git 自己清理垃圾:

$ git gc --prune=now
致命错误:bad tree object 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6
致命错误:failed to run repack

失败了。因为包文件(pack)里含有损坏对象的引用,gc 无法重新打包。

这里需要简单说明一下 git 的存储机制。git 把对象存在 .git/objects/ 目录里,有两种形态:

  • **松散对象(loose object)**:刚产生的对象存成单个文件,比如 .git/objects/d0/c6a58...
  • **包文件(pack file)**:积累到一定程度,git 会把一堆松散对象打包成一个 .pack 文件 + 一个 .idx 索引,既省空间又加快访问速度

git gc 干的事就是"把所有松散对象重新打包"。但如果包文件里有一个条目引用了不存在的对象,git 就没法完成解包→重打包的过程,导致 gc 失败。这里就是这种情况。

尝试二:本地克隆一份

绕过损坏对象的一个经典方法是重新克隆仓库。但这里没有远程仓库可用(远程就是坏的),所以试试本地克隆:

$ git clone --local . /tmp/codequest-clean
致命错误:无法创建链接 '...pack': 无效的跨设备链接

加上 --no-hardlinks 参数后克隆成功了:

$ git clone --no-hardlinks --local . /tmp/codequest-clean
完成。

但遗憾的是检查发现损坏的对象也被克隆过来了。因为那些提交历史里仍然引用着损坏的对象。治标不治本。

尝试三:git filter-branch 重写历史

既然损坏的对象在历史提交中,那就用 git filter-branch 重写历史,把引用损坏对象的目录从历史中移除:

$ FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --index-filter '
  git rm --cached -r -q docs/superpowers 2>/dev/null || true
  if [ -d docs/superpowers ]; then
    git add docs/superpowers 2>/dev/null || true
  fi
' 0c12047..HEAD

先拆解一下这条命令在干什么:

FILTER_BRANCH_SQUELCH_WARNING=1 ← 静默模式,让 git 别打印吓人的警告
git filter-branch -f ← 强制重写历史。-f 是 --force,覆盖上一次运行的备份
--index-filter '...' ← 核心。对每个提交,直接在暂存区(index)执行脚本,
                        而不是把文件检出到磁盘再操作。速度快但只能用 git 命令
0c12047..HEAD ← 重写范围,从 0c12047 之后到 HEAD 的所有提交

脚本部分:

git rm --cached -r -q docs/superpowers 2>/dev/null || true
  --cached: 只删除暂存区,不碰磁盘文件
  -r: 递归删除整个目录
  -q: 安静模式
  2>/dev/null: 把错误信息丢掉(如果这个提交里本来就没有那个目录)
  || true: 保证即使删除失败脚本也不退出

if [ -d docs/superpowers ]; then
  git add docs/superpowers 2>/dev/null || true
fi
  先检查 docs/superpowers 目录是否存在,如果存在就重新加到暂存区

然而这个脚本实际有 bug:=–index-filter= 模式下,git 不会把文件内容检出到磁盘,所以工作目录是空的。=if [ -d docs/superpowers ]= 永远为假,后面的 git add 永远不会执行。结果只删不增,每个提交里的 docs/superpowers 目录都被删掉了。

所以结果是 "Ref 'refs/heads/main' was rewritten"。看起来成功了,但 git fsck 仍然报错。

原因有两个:

  1. filter-branch 的备份引用(=refs/original/=)仍然指向旧提交,这些旧对象还在仓库中
  2. reflog 里也保留着旧提交的记录

reflog 是 git 的"操作日志",记录了 HEAD 和各个分支曾经指向过的每一次提交。你可以把它理解成浏览器的历史记录——即使你书签(分支)指向了新页面,历史记录里还存着旧地址。垃圾回收时,git 会检查 reflog,只要 reflog 里还有记录,旧对象就不会被清理。

删除 refs/original/ 和清空 reflog 后:

$ rm -rf .git/refs/original/
$ git reflog expire --expire=now --all
$ git gc --prune=now
致命错误:bad tree object 8043d4e...

各种尝试都卡在 git gc 这一步,死循环了。

弯路总结

走弯路的教训是:在一棵已经坏掉的树上修修补补非常困难。filter-branch 能创造新的好提交,但无法清理包文件中已经存在的损坏对象引用。而 git gc 又因为包文件有问题而无法执行。

最终的解决方案

核心思路

绕了一大圈,最直接的方案反而是:==把缺失的对象补上==。

git 的对象存储很简单——每个对象就是一个文件,路径是哈希值的前两位作为目录名,后38位作为文件名,内容经过 zlib 压缩。

既然知道缺的是啥,那就把它造出来。

步骤 1:找到缺失对象的实际内容

先看看需要什么:

缺失的 blob d7f34b8 → 文件 docs/superpowers/specs/2026-04-26-achievement-wall-design.md
缺失的 tree 4de66b3 → 目录 docs/superpowers/specs/(只包含上面那个文件)

检查这个文件的内容,发现它仍然存在于工作目录中。那就简单了:

$ git hash-object -w docs/superpowers/specs/2026-04-26-achievement-wall-design.md
d7f34b8f17b66827d315980d4cfcc0b17530a327

hash-object 计算文件的 SHA-1 哈希,= -w= 参数把它写入对象存储。输出的哈希正好是 =d7f34b8=——这就是我们要找的缺失 blob!

步骤 2:重建 tree 对象

有了 blob,就可以重建引用它的 tree 了:

$ echo "100644 blob d7f34b8f17b66827d315980d4cfcc0b17530a327	2026-04-26-achievement-wall-design.md" | git mktree
8043d4e27bf8f8f91f30f9519651dd0dfcc2b1af

mktree 从标准输入创建 tree 对象。但输出的哈希是 8043d4e 而非期望的 =4de66b3=。

这表明 4de66b3 这个哈希本身就是错的(估计是当年仓库损坏时留下的垃圾数据),而 8043d4e 才是实际内容对应的正确哈希。

步骤 3:删除孤立的损坏对象

虽然 filter-branch 已经重写了历史,但旧的损坏对象仍然以"松散对象"(loose object)的形式留在硬盘上。这些对象不被任何分支引用,但 git 仍能看到它们并报错。

具体来说,补齐缺失对象后 git gc 仍然报错。运行 git fsck 检查,报错的内容变了——不再是缺少 =4de66b3=,而是:

损坏的链接来自于 tree ab43ad01 → tree d0c6a58
缺失 tree d0c6a58

这说明 d0c6a58 这个 tree 仍在硬盘上,且它引用的子 tree 本来应该是 4de66b3=(就是之前缺失的那个),但现在这个引用关系断裂了。由于 =d0c6a58 本身还是一个可读的对象,它就成了新的"损坏源头"。

检查它是不是松散对象:

$ ls .git/objects/d0/c6a58cd6ccac7e967a1ec1923b86b6098c37cf
.git/objects/d0/c6a58cd6ccac7e967a1ec1923b86b6098c37cf

文件存在,说明它是松散对象。注意路径的规律:git 取对象哈希的前两个字符 d0 作为目录名,后 38 个字符作为文件名。这样每个目录下最多只有 256 个文件(00~ff),避免单个目录的文件数量爆炸。再检查它是否还被当前分支引用:

$ git rev-list --objects main | grep d0c6a58
# (无输出)

无输出,说明 main 分支已经不需要它了。=rev-list –objects main= 的作用是遍历 main 分支能到达的所有对象(包括提交、目录树、文件),逐一列出它们的哈希。如果目标哈希出现在这个列表里,说明某个提交还用着它,删了会破坏历史。没输出就说明安全,可以删。

确认安全后删除:

$ rm -f .git/objects/d0/c6a58cd6ccac7e967a1ec1923b86b6098c37cf

然后重复这套流程——再跑 =git fsck=,看下一层是谁:

损坏的链接来自于 tree b46bf016 → tree ab43ad01
缺失 tree ab43ad01

这次 ab43ad01 浮出来了。它被另一个 tree 引用,自身也是个松散对象。

ls .git/objects/ab/43ad01abfddc06d9ae24d81d9fe96a8007e3dd  ✓ 存在
git rev-list --objects main | grep ab43ad01  ✓ 不被 main 引用

删掉它:

$ rm -f .git/objects/ab/43ad01abfddc06d9ae24d81d9fe96a8007e3dd

如此反复,每删一层就跑 git fsck 看上一层,直到 git fsck 不再报错。

总结这个"顺藤摸瓜"的过程:

  1. git fsck 告诉你哪个对象是"损坏的链接"的源头
  2. git rev-list --objects main | grep <哈希> 确认不被任何分支需要
  3. ls .git/objects/xx/xxx... 确认它是松散对象
  4. 删除,回到第 1 步

注意这里的删除用的是直接 rm -f 删文件,而不是用 git 命令。因为松散对象就是硬盘上实实在在的文件,直接删掉文件系统层面的文件就可以了。但对于 reflog、分支引用这类 git 的"元数据",就要用 git 提供的命令(如 git reflog expire=)来操作,不能直接去删 =./git/ 里的文件。 #+END_EXAMPLE

步骤 4:清理回收

删除孤立对象后,再清理 reflog 和历史备份:

$ git reflog expire --expire=now --all
$ git gc --prune=now

这次 git gc 没有报错了。

步骤 5:验证

$ git fsck --full
# (无输出)

没有输出,说明仓库完全健康!

步骤 6:推送

$ git push -u origin main
To git.zhlh6.cn:lujun9972/CodeQuest.git
 * [new branch]      main -> main
分支 'main' 设置为跟踪 'origin/main'。

推送成功。

总结

这次事故中走了不少弯路。根本原因在于损坏发生在很久以前的提交中,这些对象在包文件里留下了无法清除的引用,且 git gc 也无法执行。

最后的解决方案出奇地简单:不去追求重写历史、不去删除损坏的包文件,而是直接把缺失的对象补上。git 的对象存储就是哈希文件系统,造出来就好了。

小白的教训:

  1. 不要轻易用 git filter-branch 这类高级命令,没有彻底理解之前用它只会制造更多混乱
  2. git fsck --full 是最好的诊断工具,先看看具体缺了什么再动手
  3. 尝试 git gc 失败时,检查是否有孤立的松散对象(loose object),手动删除或补齐它们
  4. .git/objects/ 目录下就是 git 的所有对象,读懂它的结构很多问题就迎刃而解了
  5. 推送前先 git fsck 检查一下,避免推到服务器才发现问题

扩展阅读:

异闻录