卷 IV · 网CH 20深度 20/24

那些危险操作,和它们的救援

Git 有个「一不小心就完蛋」的名声。这一章把所有真正危险的操作摆在一起,你会发现其中绝大多数根本不危险——而真正会让你丢东西的,只有一小类,而且它们有一个共同特征。

救援手册fsckforce-with-lease一条判据

先把那条判据摆出来

◆ 全书最实用的一句话

问一句:它有没有被写成过对象?

写过 → 几乎一定救得回来。对象只增不改(第 5 章),reflog 记着引用去过哪儿(第 9 章),实在不行还有 fsck 全库扫描。默认还有 30–90 天的余裕。

没写过 → 是真的没了。Git 的对象库里从来没有过它的副本,reflog 里也不会有——Git 手上根本没有这份数据。

而「被写成对象」的时刻只有三个:

  • git add —— 内容变成 blob 进库(第 10 章)
  • git commit —— 树和提交对象进库
  • git stash —— 工作区和索引被写成提交对象(第 9 章)
▶ 动手 · 救援演练

八种情况,逐个点开看。注意「曾被写成对象吗」那一栏——它和「救得回来吗」是完全对应的。

救得回来的那些(大多数)

reset --hard 掉了提交

$ git reflog
$ git reset --hard HEAD@{1}        # 或者具体那个哈希
# 更稳妥:先贴张贴纸,不动当前状态
$ git branch 救回来 HEAD@{1}

推荐第二种。贴纸只要 41 个字节(第 6 章),贴之前不用犹豫;而 reset --hard 又是一次会覆盖工作区的操作,没必要冒第二次险。

误删了分支

$ git reflog                        # 找那个分支最后的位置
$ git branch 分支名 <那个哈希>

删分支只是删了一个 41 字节的文件(第 6 章),提交一个没动。重新贴一张贴纸就回来了。

如果 git reflog(HEAD 的)里找不到,试试那个分支自己的:

$ git reflog show 分支名           # 分支删了这个也没了,但可以碰运气
$ git fsck --lost-found            # 兜底

rebase 搞砸了

$ git rebase --abort               # 还在进行中:直接放弃,零代价
$ git reset --hard ORIG_HEAD       # 已经做完了:ORIG_HEAD 存着起点

rebase 开始前 Git 会自动把原位置写进 ORIG_HEAD(第 7 章)。这是专门为后悔准备的。

detached HEAD 上提交完切走了

$ git reflog
$ git branch 救回来 <那个哈希>

提交在库里,只是没贴纸(第 7 章)。reflog 里有它的号。

丢了 stash

git stash drop 删的是 reflog 里的一条记录,提交对象一个没动(第 9 章):

$ git fsck --unreachable | grep commit
# 逐个看,stash 的提交信息通常是「WIP on ...」
$ git show <那个哈希>
# 找到了就恢复
$ git stash apply <那个哈希>

连 reflog 都指望不上:全库扫描

$ git fsck --lost-found
Checking object directories: 100% (256/256), done.
dangling commit a3f21c9e77b0d4c8135e6a9f02b4d7e8c1a5b3d6
dangling blob 7f2e8b1c4d9a0e3f5b8c2d6a1e4f7b0c3d5a8e2f

它会遍历整个对象库,把所有没有任何东西指向的对象列出来,并把它们写进 .git/lost-found/

注意那个 dangling blob那是add 过但从没 commit 过的内容——它没有文件名(blob 不知道自己叫什么,第 3 章),只能按内容认:

$ git cat-file -p 7f2e8b1 | head -20     # 看看是不是你要找的

救不回来的那些(少数)

只有两类,而且它们的共同点很清楚:

操作丢了什么为什么救不回
git restore <文件>
git checkout -- <文件>
git reset --hard(工作区那部分)
没暂存过的改动那份内容从没被写成对象。Git 里没有它的副本
git clean -fd未跟踪的文件Git 压根不知道它们存在。这条命令执行的就是 rm

剩下的唯一指望是 Git 之外的东西:

  • IDE 的本地历史——IntelliJ / VS Code 都有(Local History),它们独立于 Git 定期存快照。这大概是最常真正救回东西的手段。
  • 编辑器的临时文件、系统的 Time Machine / 文件历史。
◆ 把风险压成一句预防

既然危险全部集中在「动了工作区、而内容没进过对象库」,那预防措施就只有一条:

跑任何 --hardcleanrestore 之前,先 git stash

$ git stash --include-untracked      # 连未跟踪的一起收走

它把你的工作区写成提交对象——那一瞬间,这些改动从「Git 不管的东西」变成了「Git 保管的东西」,从上表的第二类跳到了第一类。

成本:一条命令。收益:把「可能永久丢失」变成「一定找得回来」。

还有一条同样便宜的:做危险操作前先 git branch backup-xxx。41 个字节,换一个确定的退路。

强推:唯一能伤到别人的操作

前面所有情况伤的都是你自己,而且都能救。强推是唯一一个会伤到别人的。

git push --force,服务器上那些提交失去引用。而你本地的 reflog帮不上任何忙——它记的是你这台机器上的事(第 9 章)。

⚠ 强推覆盖了别人的提交,怎么救

好消息是:那些提交在别的地方还有副本。按可能性排序:

一、对方的本地仓库(最靠谱)

# 在他机器上
$ git reflog
$ git push origin <那个哈希>:main

二、服务端的 reflog

如果你有服务器 shell 权限,裸仓库里也有 reflog(前提是开了 core.logAllRefUpdates,GitLab 自建实例通常有)。

三、平台的 API

GitHub 的 events API 会记录 push 事件里的提交哈希,能撑一段时间:

$ gh api repos/OWNER/REPO/events | grep -o '"sha":"[a-f0-9]*"' | head

而且 GitHub 上「不可达」的对象通常还能通过 URL 直接访问github.com/OWNER/REPO/commit/<哈希>),只要知道哈希就能捞。

四、任何 CI 的构建日志——里面通常打印了构建时的提交哈希。

关键是快。服务端 gc 一跑就真没了。所以出了这种事,第一件事是告诉对方先别动、别 gc,而不是自己默默想办法。

而预防它只要改一个习惯:

$ git push --force-with-lease        # 而不是 --force

它拿你本地的 origin/main 便条当凭据(第 8 章):便条过期了(说明有人在你之后推过)就拒绝。

可以直接把它设成默认,从此不用记:

$ git config --global alias.pushf 'push --force-with-lease'
⌗ 掀开 .git 看一眼

亲手验证那条判据。做两个对照实验:

# 实验 A:add 过的内容 —— 能救
$ echo "版本 A" > t.txt && git add t.txt
$ echo "版本 B" > t.txt && git add t.txt      # 覆盖它

$ git fsck --lost-found 2>/dev/null | grep blob
dangling blob 8a7c3f2...

$ git cat-file -p 8a7c3f2
版本 A                                          ← 找回来了
# 实验 B:从没 add 过 —— 救不回来
$ echo "版本 C" > t.txt        # 不 add
$ git restore t.txt            # 覆盖掉

$ git fsck --lost-found 2>/dev/null | grep blob
# 什么都没有 —— 「版本 C」从未存在于对象库

这两个实验就是整章的证明。差别只在有没有那一句 git add

顺带看看你的安全网还剩多少余裕:

$ git count-objects -v            # 有多少松散对象等着被 gc
$ git config gc.reflogExpire      # 可达记录多久过期(默认 90 天)
$ git config gc.pruneExpire       # 悬空对象多久才真删(默认 2 周)
✎ 那句「别跑这个」的命令

全书唯一一条真心建议你别随手跑的:

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

立刻清空 reflog 并回收所有不可达对象——等于亲手拆掉本章讲的全部安全网。

它的正当用途只有一个:刚用 git filter-repo 清理完敏感数据或大文件,要立刻抹掉旧对象。

而即便在那个场合,也要记住第 5 章那个提醒:误提交的密钥必须当作已泄露处理。你只清掉了自己这份副本——别人 clone 走的、平台缓存的、fork 里的,全都还在。清理历史是善后,吊销密钥才是补救。

↩ 回到你的仓库

「有没有一套通用的『我不确定这步会不会出事』的流程?」

有,而且很短。做任何拿不准的操作前:

# 1. 把工作区存成对象(未跟踪的也带上)
$ git stash --include-untracked

# 2. 给当前位置贴一张贴纸(41 字节,别犹豫)
$ git branch backup-$(date +%m%d-%H%M)

# 3. 放手去做

# 4. 成功了就清理,失败了就回去
$ git reset --hard backup-1120-1430
$ git stash pop

这套流程把所有操作的风险降到零——因为它保证了两件事:你的工作区进了对象库,你的位置有贴纸指着。

而这两句话,正好就是这本书前两卷的全部内容:一座只增不改的对象库,和一把随手可撕的贴纸。

这一章的一句话

Git 里几乎所有「危险操作」都能救——判据只有一条:它有没有被写成过对象。写过(add / commit / stash)就有 reflog 和 fsck 兜着,默认还有一个月起步的余裕;没写过就是真没了,因为 Git 手上从来没有那份数据。而唯一能伤到别人的是强推,把 --force 换成 --force-with-lease 就能挡掉。

卷 IV 结束。

下一卷回答一个从第 1 章就欠着的问题:每次提交都存整棵树的快照,磁盘怎么可能撑得住?第 5 章给了一半答案(去重),另一半在 packfile 里——而在那里,Git 终于用上了差量。但用在哪一层,顺序至关重要。