有人在错误的分支上敲了 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 不删东西,它挪指针。
