> --exclude='.git/objects' 的备份恢复出来的是尸体,不是仓库

Systems & Infrastructure Intermediate 2026-10-12 04:23 2026-10-12
#git #backup #rsync #data-loss #forensics #failure-modes

一条每晚跑的 rsync 备份规则,为了省体积排除了对象库:

// TABLE_OF_CONTENTS

结论先行

一条每晚跑的 rsync 备份规则,为了省体积排除了对象库:

--exclude='.git/objects'

备份里于是留下一个有 HEAD、有 refs/、有 config、没有任何对象的 .git 空壳。恢复出来 git 直接拒绝打开 —— 它看起来是个仓库, ls -a 看得到 .git,但它一行历史都没有。

全机扫一遍,52 个仓库处于这个状态。

最关键的一步不是发现它们坏了,是证明它们是从这份备份恢复回来的: 每个坏仓库 .git 目录的 mtime 都停在该项目各自最后活跃的那一天, 而不是集中在某次恢复操作的时间。rsync -a 保留时间戳 —— 时间戳的 这种"散开"分布,只有"逐个从一份保留 mtime 的备份里拷回来"能产生。

善后按历史的真实去向分三类(不能一视同仁):

情况 数量 处理
有 GitHub remote 31 从远端恢复真实历史
历史确已丢失 15 重新 init,推私有远端
第三方 clone 12 移除空壳,上游地址记进 THIRDPARTY_UPSTREAMS.txt

修法两条:规则改成 --exclude='.git'(备份只存工作树,恢复出来是 干净目录而不是坏壳),历史保护交给 remote —— 原先14 个仓库根本没有远端, 也就是说这份坏备份曾是它们历史的唯一副本。

为什么

.git/objects 是仓库里唯一不可从别处推导的部分。 工作树是 HEAD 那一次 commit 的投影,refs/ 是指针,config 是配置, 只有对象库装着内容本身。排掉它,等于保留了全部索引结构、丢掉全部数据 —— 而索引结构恰好是让它"看起来还在"的那一半。

这比"备份里没有这个仓库"更危险,而不是更安全:

  • 缺目录会在恢复时立刻、显眼地失败;坏壳要等到你 git log 才失败, 而那可能是半年后。
  • 坏壳会让"我有备份"这个判断保持为真。它过的是存在性检查, 不过可用性检查。

"省体积"的动机也值得算一遍。对象库是 git 里压缩得最好的部分 (zlib + delta + packfile),工作树反而是展开的。排掉 objects 省下的, 通常远小于它放弃的东西 —— 这是个连收益都很小的权衡。

还有一层只有把两件事并排看才成立的判断:这台机器上另有一份每晚 rsync 整棵树、保留 30 天 daily + 12 周 weekly + 永久 monthly 的备份, 落在另一块物理盘上。它也 --exclude='.git'。所以:

它保住文件,丢掉历史,而且两块盘在同一台机器里,同一场火里一起没。

一份排除 .git 的异盘备份和一个 git remote 不互相替代:前者给你 "昨天的文件",后者给你"可以 diff 的历史"和"离机副本"。 缺哪一个都不是冗余,是缺口。

怎么用

现在就查一遍自己有多少个坏壳,一条命令:

find ~ -type d -name '.git' -prune 2>/dev/null | while read -r g; do
  [ -d "$g/objects" ] && [ -n "$(ls -A "$g/objects" 2>/dev/null)" ] || echo "EMPTY: $g"
done

更严格一点,用 git 自己判(能捕捉 objects 非空但 pack 损坏的情况):

git -C "$repo" rev-parse HEAD >/dev/null 2>&1 || echo "BROKEN: $repo"

备份排除项的判据:排掉之后恢复的结果,是"缺东西"还是"坏东西"。 缺东西可以接受(下次 build 能再生)。坏东西不行 —— 它会继续冒充完好。 node_modules/、__pycache__/、target/、.venv/ 属于前者; .git/objects、数据库的 WAL 段、索引的一半分片属于后者。 要么整个排掉,要么整个留下,不要排掉一个结构的内脏。

mtime 是免费的取证工具。 一批文件的时间戳是"集中在某一刻"还是 "散开在各自的历史里",能区分"被某次操作统一改过"和"从保留时间戳的 副本拷回来的"。排查数据来源时先看一眼 mtime 的分布,比读日志便宜得多。

仓库数量和远端数量要对账。 一个没有远端的本地仓库,它的历史 只有一个副本,而备份策略通常没把它当数据对待("代码在 GitHub 上" 是个默认假设,对 14 个仓库来说是假的):

find ~ -type d -name '.git' -prune | while read -r g; do
  git -C "$(dirname "$g")" remote -v | grep -q . || echo "NO REMOTE: $(dirname "$g")"
done

第三方 clone 要有来源标记。 那 12 个空壳如果直接删掉,就再也不知道 它们原来是谁的;记进一个 name url path 的文本文件,之后谁都能重新 clone, 顺带防止下次有人把它当自研代码去"改进"。

出处

~/Engineering/worklog-printer/README.md §0「已修:备份规则产出坏仓库 (2026-08-03)」与 .gitignore 顶部关于 /data/EngineeringBackup 的说明。 那 12 个第三方条目现在在 ~/Engineering/THIRDPARTY_UPSTREAMS.txt 里(2026-10-11 核对,正好 12 行,与记录一致)。 相关:进 git 的判据只有一句:明天这个仓库能不能把它重算出来。