跳到主要内容
画开 Git 的"删除":它基本没删过你的东西

画开 Git 的"删除":它基本没删过你的东西

画唠
画唠

· 阅读约 5 分钟

有人在错误的分支上敲了 git reset --hard,三个小时的工作从 git log 里蒸发了。

第一次听到这事,我跟所有人反应一样——完了。后来我把 Git 的对象模型画开看了一眼,才发现这个"完了"根本站不住脚。

Git 极少真的删东西。它九成时间在挪指针。你以为的"消失",是某个引用不再指着那个提交了,不是那个提交被烧了。

这两句差多远,画张图就知道了。这是我最后画对的那一版:

   工作区      改了、还没 add 的东西            ← 从没进过 Git
     │ git add
   暂存区      已经 add、还没 commit
     │ git commit
   对象数据库   提交已经在里面,有引用指着它
     │ 引用被挪走(reset / 删分支 / rebase)
   孤儿对象     还在库里,能捞出来,等 gc
     │ git gc
   真的没了

这张图只讲了一句话:你在哪一层丢的,决定你能不能用 Git 捞回来。

等等等等,先别急。这张图我简化过,它漏了两件事没画——而漏的那两件恰恰最要命。放后面说。

第一版我把 reflog 画成了回收站

对,第一版图里,"回收站"那个格子上我写的是 reflog。逻辑挺顺的:东西删了进回收站,要找就去回收站翻。

然后我动手试了一下。不对。

reflog 根本不装东西。它一个字的内容都不保存。

它记的是这种:

   HEAD@{0}   现在
   HEAD@{1}   HEAD 上一次移动之前在哪
   HEAD@{2}   再上一次
   ...

就一本流水账。记"引用什么时候动过、动之前在哪个哈希上"。那三个小时的工作不在 reflog 里躺着——它一直在对象数据库里躺着,reflog 只是告诉你它躺的那个哈希叫什么。

我敲完 git reflog 盯着输出看了半天,脑内一片羊叫。这俩差得太远了:回收站是"东西的备份",reflog 是"位置的日志"。我傻等在回收站门口,等它把我的文件吐出来;其实我该做的是拿起那本日志,去找那张还没被烧掉的书目卡。✨

想通之后整个恢复流程被我重画了一遍,短得有点不适:

   ① 找到那个哈希   ← reflog 给;reflog 不够就用 fsck 兜底
   ② 让某个引用指回去

就这两步。reset 惨案、删错分支、rebase 翻车、detached HEAD 上提交完就切走、误删 stash——全是这两步。

咔哒一下扣上之后,我把"错误分类 → 对应命令"那套口诀整个扔了。按"我犯的是哪种错"分类,那是给查表用的;按"东西在哪一层"分类,才是能自己推出来的。

打个比方:被抽掉的书目卡

最贴切的比方是图书馆。

提交是书,引用(分支、HEAD)是书目卡。你敲 git reset --hard 的时候,抽走的是书目卡,书还在架上。所以"三个小时没了"是个错觉——书没动,是你照着目录找不到了。

好用。但这比喻有两处漏风,我得标出来。

第一处:书会被烧。孤儿对象确实在库里待着,但有期限——不可达的提交大概三十天,可达的九十天左右,而且垃圾回收可能提前动手。所以"反正还在库里"这个心安是错的,捞要趁早。

第二处更大:这个图书馆是每个克隆一份。reflog 存在 .git 里面,是本地的,不跟 push 走。你在公司电脑上那场 reset 惨案的记录,回家 clone 一份,什么都没有。别人 force-push 覆盖了你的工作,你本地副本的 reflog 就是唯一的救生圈——前提是那个仓库你还没删。

所以准确的说法不是"Git 不会丢东西",是"只要进过对象数据库、而且那台机器上的 .git 还在,就大概率能捞"。这两句听着差不多,边界差远了。

有一层,谁都救不了

回头看那张五层图。最上面那层——工作区——最脆。

从没进过 Git 的东西,Git 没有任何记录,也就没有任何捞的可能。你改了半天的文件没 add,一个 git restore . 下去,没了。git clean 把未跟踪文件删了,也没了——这些文件从来没进过对象数据库,reflog 里没有它们的哈希,fsck 也扫不出来,因为它们压根不是对象。只能去翻编辑器的本地历史,或者系统回收站。

写这种指南的人自己都承认,这是整个体系里唯一真正不可逆的操作。

我觉得这条应该放在所有 Git 教程的第一行,而不是藏在第十节。前面那些 reset、rebase、删分支的恐慌,事后都是能解的;只有这个解不了,偏偏它发生在你还没意识到该小心的时候。

风险操作之前建个备份分支,成本就是一个分支名,回报是把最上面那层也变成可回退的。

有两个操作,问题不在技术

reset 和 revert 那个老问题,本来不想写,被讲烂了。但它卡在一个很少被说的分岔上:这俩的区别不是"哪个更安全",是那个提交有没有别人正在用。

本地私有的提交,reset,随便改历史,没人受影响。已经推上去、别人可能已经拉了的,就得 revert——加一个反向提交,把历史往前推,不动过去。这是社交约束,不是技术约束。技术上你 reset 到哪儿都能 reset,--force 推上去就行;问题在于同事本地那份已经长出根了。

顺带一句:需要强推的时候用 --force-with-lease,别用裸的 --force。前者在对方已经推了新东西时会拒绝覆盖,把决定权还给你;后者不会,它会静默地把人家的活干掉。这俩参数我用了很久才知道差别,知道之后就没再敲过裸的。

画开完了,自检一下

现在你能不能用自己的话讲清楚:为什么 git reset --hard 之后那三个小时的工作,可能一根毛都没少?

讲不顺的话,多半是卡在"引用"和"对象"是两层没分开——那正好是我第一版画错的地方。把这两层分开,前面那一串 reset / revert / reflog / fsck / force-with-lease 全能自己推出来,不用背。

下期想画开哪个?我候选清单上有三个:reflog 那三十天和九十天到底怎么算出来的、--force-with-lease 比较的究竟是什么、还有 git gc 到底什么时候真的动手。最后这个我自己也没完全搞明白,可能得先去挨一次打。

本期画开:Git 不删东西,它挪指针。

画唠
画唠

把被讲玄的概念用图 + 比喻 + 动手实验拆到咔哒扣明白,错的也保留。

查看主页 →