reflog:Git 几乎不会真的弄丢东西
这是全书最让人放松的一章。你会看到一个精确的结论:只要它被 commit 过,它就几乎不可能真的消失。Git 那个「一不小心就完蛋」的吓人名声,绝大部分是模型不清楚带来的错觉。
先回忆两件事
卷 I 建立的:对象只增不改。你没法修改一个对象,也没有任何日常命令会删除对象(第 5 章)。
卷 II 建立的:贴纸随手可撕。分支、HEAD 都是几十字节的小文件,改起来毫无成本(第 6、7 章)。
把这两句拼起来,就得到这一章的全部内容:
当你 reset --hard 掉三个提交、删掉一个分支、或者 rebase 重建了历史——没有任何对象被删除。
发生的只有一件事:某些提交失去了通往它们的路径。
它们还在 .git/objects 里,字节一个不少。只是 git log 从任何一张贴纸出发都走不到它们,所以你看不见。
「看不见」≠「没了」。而 reflog 的作用,就是把那条路径还给你。
reflog 是什么
Git 在每一次移动 HEAD 或分支时,都会往一个日志里追加一行,记下「从哪儿到哪儿、因为什么」。
$ git reflog
1bada99 HEAD@{0}: reset: moving to HEAD~2
a3f21c9 HEAD@{1}: commit: 加了个功能
7f2e8b1 HEAD@{2}: commit: 修了个 bug
1bada99 HEAD@{3}: checkout: moving from feat to main
06b431f HEAD@{4}: commit: second
读法很简单:每一行的哈希,是「这一步完成后 HEAD 停在哪」。
HEAD@{n} 就是「往回数 n 步时 HEAD 在哪」。所以 HEAD@{1} 永远是「上一步之前的位置」——也就是后悔药。
第 5 章讲可达性时列过根的清单,其中有一条是「reflog 里记录过的每一个位置」。
这一条至关重要。它意味着:那些「失去引用」的提交,其实并没有真的失去引用——reflog 还引用着它们。
所以它们不是悬空对象,git gc 不会碰它们。它们受保护,直到 reflog 里的那条记录本身过期为止。
Git 的安全网,是一条你从来没注意过的日志。
故意弄丢,再找回来
按顺序:① 提交两个新的 → ② 点那个红色的 reset --hard HEAD~2 → ③ 看图上发生了什么 → ④ 用 reflog 救回来。
注意第 ③ 步:那两个提交变成了虚线圈——图上看不见了,但底下的统计会告诉你它们还在库里。
四种「找回来」的手段
按从简单到万不得已排列:
一、ORIG_HEAD——最近一次大动作之前
$ git reset --hard ORIG_HEAD
Git 在 reset、merge、rebase、pull 这类会大幅移动 HEAD 的操作之前,会自动把原位置存进 .git/ORIG_HEAD(第 7 章见过)。
刚干错一件事,这是最快的路。缺点:只保留最近一次,再操作一下就被覆盖了。
二、reflog——HEAD 的完整足迹
$ git reflog # 看 HEAD 的历史
$ git reset --hard HEAD@{3} # 回到第 3 步之前
这是主力工具。90% 的「我搞砸了」都能在这里解决。
三、分支自己的 reflog
容易被忽略的一点:每个分支都有自己独立的 reflog,不只是 HEAD 有。
$ git reflog show main # 只看 main 这张贴纸移动过哪些位置 $ ls .git/logs/refs/heads/ # 每个分支一个日志文件
当你要找的是「某个分支之前指向哪儿」而不是「我之前在哪儿」时,这个更准。
四、fsck——连 reflog 都没有的时候
如果连 reflog 都不记得(比如你从没 checkout 过那个提交),还有最后一招:
$ git fsck --lost-found $ git fsck --unreachable --no-reflogs | grep commit
它会遍历整个对象库,把所有「没有任何东西指向」的对象列出来。然后你逐个 git show 去认。
这是暴力手段,输出可能很长,但它确实能找到任何还没被 gc 掉的东西。丢了 stash(stash drop 之后)通常就得靠它。
reflog 就是普通的文本文件,可以直接读:
$ cat .git/logs/HEAD
0000000000000000000000000000000000000000 06b431f5f556d4b4... Sakura <sakura@example.com> 1700000000 +0800 commit (initial): first 06b431f5f556d4b4d9b2f2fa4240e4d12fc6c975 1bada9988e4c142f... Sakura <sakura@example.com> 1700000060 +0800 commit: second
每行的格式:旧哈希、新哈希、是谁、什么时候、干了什么。
第一行的旧哈希是全零——那表示「之前什么都没有」,也就是仓库的第一个提交。
目录结构:
$ find .git/logs -type f
.git/logs/HEAD HEAD 的足迹(你去过哪儿) .git/logs/refs/heads/main main 这张贴纸的足迹 .git/logs/refs/heads/feat feat 这张贴纸的足迹 .git/logs/refs/remotes/origin/main 连便条的移动都记着
每一张贴纸的每一次移动,都留了痕。
它很强,但不是万能的。三条限制:
一、它是纯本地的,而且不会被传输。
clone 一个仓库,你拿到的 reflog 是空的——它记的是「你这台机器上 HEAD 的移动史」,跟仓库内容无关。所以别人强推覆盖了你的提交,你的 reflog 帮不上忙,得去他的机器上找(第 20 章)。
二、它会过期。
$ git config gc.reflogExpire # 默认 90 天(可达的记录) $ git config gc.reflogExpireUnreachable # 默认 30 天(已经不可达的)
所以「几乎不可能丢」有个有效期。半年前那个提交,可能真的已经被回收了。
三、它只记录「被 Git 追踪过的东西」。
这是最重要的一条:reflog 记的是引用的移动,而引用指向的是对象。如果一份改动从来没被写成对象(没 add 过、没 commit 过),那么 reflog 里根本没有它的位置。
这就是下面那条判据的由来。
问一句:它有没有被写成过对象?
写过 → 几乎一定救得回来。对象不会被改、不会被立刻删,reflog 或 fsck 总能把它找出来。
没写过 → 是真的没了。Git 里没有任何地方存过它。
而「被写成对象」的时刻只有两个:
git add—— 内容被写成 blob 存进对象库(第 10 章会讲这个常被忽略的事实)git commit—— 树和提交对象被建出来
所以那条被说烂了的建议「常 commit」,在这里有了精确的技术含义:commit 是你把工作交给 Git 保管的那一刻。在那之前,它谁也不欠你。
顺带一个很多人不知道的推论:git add 过的内容,即使后来被覆盖,也能找回来。因为 add 那一刻 blob 已经进库了。用 git fsck --lost-found 能捞出这些「悬空的 blob」——虽然它们没有文件名(blob 不知道自己叫什么,第 3 章),得靠内容去认。
那 gc 到底什么时候真的删
把时间线捋清楚,你就知道自己有多少余裕:
| 阶段 | 状态 | 能救吗 |
|---|---|---|
| 刚 reset 掉 | 提交不可达,但被 reflog 引用着 | reflog 一句话搞定 |
| 30 天后 | 不可达的 reflog 记录过期,提交成为真悬空对象 | fsck 还能找到 |
下一次 gc | 悬空且超过 gc.pruneExpire(默认 2 周)才会被删 | 这时候才是真没了 |
而 gc 什么时候跑?Git 会自动跑(gc.auto,默认松散对象超过 6700 个时触发),也可以手动。
所以真实的余裕通常是一个月起步。除非你手动跑了这句核弹:
# 这句会立刻清掉所有不可达的东西,包括 reflog。 # 除非你非常清楚自己在干什么,否则别跑。 $ git reflog expire --expire=now --all && git gc --prune=now --aggressive
这条命令的正当用途只有一个:刚用 filter-repo 清理完敏感数据或大文件,要立刻抹掉旧对象。其他情况下跑它,等于亲手拆掉自己的安全网。
git stash 常被当成一个「临时存储区」,其实它就是提交对象:
$ git stash $ git cat-file -p refs/stash
tree 8f3c2a1... parent 1bada99... ← 你 stash 时所在的提交 parent 4d7e8c1... ← 工作区状态的那个提交 author ... WIP on main: 1bada99 second
它是一个有两个(有时三个)父提交的合并提交,分别装着「当时的 HEAD」「当时的工作区」「当时的索引」。
而 refs/stash 就是又一张贴纸,加上它自己的 reflog(stash@{0}、stash@{1}……那个栈其实就是 reflog)。
所以 git stash drop 干的事是删掉一条 reflog 记录,提交对象一个没动。误删了就用 fsck 捞:
$ git fsck --unreachable | grep commit | cut -d' ' -f3 | xargs git log --merges --no-walk
再一次印证了那条主线:Git 里几乎所有东西都是对象 + 贴纸,包括那些看起来很特殊的功能。
「我 git reset --hard 了,一下午的工作没了。真的没救了吗?」
先分成两半问,答案完全不同:
那些已经 commit 的部分 → 一定能救。
$ git reflog # 找到 reset 之前那一行,记下哈希 $ git reset --hard <那个哈希> # 或者更稳妥:先建个分支再说,不动当前状态 $ git branch 救回来 <那个哈希>
那些还没 commit 的改动 → 大概率没了。除非你 add 过——那样 blob 还在,可以 git fsck --lost-found 去 .git/lost-found/other/ 里翻,按内容认。
最实在的一条经验:跑任何 --hard 之前,先 git stash。
因为 stash 会把你的工作区写成提交对象——那一瞬间,它从「Git 不管的东西」变成了「Git 保管的东西」。就算你后来忘了 stash 的存在,fsck 也能捞出来。
这一条建议的分量,现在你应该能掂量出来了:它把一件「可能永久丢失」的事,变成了一件「一定找得回来」的事。
这一章的一句话
reflog 记着每一张贴纸走过的每一步,而它本身是一条可达性的根——所以那些「弄丢」的提交并没有真的失去引用,默认还受保护 30 到 90 天。判据只有一条:它有没有被写成过对象?写过(add 或 commit)就几乎一定救得回来,没写过就是真没了。
卷 II 结束。你现在手里有一座只增不改的对象库,和一把随手可撕的贴纸,外加一条记录贴纸去过哪儿的日志。
下一卷回到你每天敲的那些命令:add、commit、status、reset。你会发现它们全都是三棵树之间的搬运——而那三棵树,能一口气解释掉「我 add 过了怎么还显示未暂存」「reset 三个模式到底啥区别」这类日常困惑。