那些危险操作,和它们的救援
Git 有个「一不小心就完蛋」的名声。这一章把所有真正危险的操作摆在一起,你会发现其中绝大多数根本不危险——而真正会让你丢东西的,只有一小类,而且它们有一个共同特征。
先把那条判据摆出来
问一句:它有没有被写成过对象?
写过 → 几乎一定救得回来。对象只增不改(第 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 / 文件历史。
既然危险全部集中在「动了工作区、而内容没进过对象库」,那预防措施就只有一条:
跑任何 --hard、clean、restore 之前,先 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'
亲手验证那条判据。做两个对照实验:
# 实验 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 终于用上了差量。但用在哪一层,顺序至关重要。