"我把所有东西都弄丢了"(剧透:多半没有)
先深呼吸。只要丢失的工作曾经被提交过 -- 哪怕只有一次,哪怕提交在错误的分支上,哪怕在一个你后来"删掉"的提交里 -- 它几乎可以肯定此刻还躺在你的仓库里。Git 保存东西的本事远比弄丢东西大,接下来十分钟的阅读,很可能以你的代码重新出现在屏幕上收尾。
关于 Git 内部的工作方式,有一个令人安心的事实:Git 几乎从不立即删除任何东西。你创建的每一个提交都作为对象存储在 .git 目录里,由它的 SHA 标识。当你执行 git reset --hard、删除一条分支,或者用 rebase 改写历史时,Git 并不会抹掉那些提交对象。它只是移动指针 -- 分支名和 HEAD -- 让它们不再指向那些提交。提交变成了"不可达"状态,意思是不再有任何分支或标签通向它们,但对象本身还会在磁盘上保留数周,垃圾回收才会开始考虑碰它们。
所以问题不在于你的提交没了,而在于你不再有指向它们的名字。你需要的是 SHA。而 Git 一直在悄悄记录你的仓库经过的每一个 SHA,这本日志就是:reflog。
reflog 到底是什么
reflog(reference log,引用日志)是一份本地的、仅存在于本机的日志,记录着 HEAD 和每条分支顶端随时间指向过的位置。每当 HEAD 移动 -- 无论是因为 commit、checkout、merge、rebase、reset 还是 amend -- Git 都会往这本日志里追加一行。它就是你仓库的黑匣子。
要查看它,运行:
git reflog
输出类似这样:
e4f5a6b HEAD@{0}: reset: moving to HEAD~3
a1b2c3d HEAD@{1}: commit: add payment validation
9f8e7d6 HEAD@{2}: commit: refactor checkout flow
3c4d5e6 HEAD@{3}: checkout: moving from main to feature/payments
从上往下读,"最新的排在最前"。每一行显示 HEAD 曾指向的 SHA、一个形如 HEAD@{1} 的位置引用、移动了 HEAD 的操作,以及一句简短描述。HEAD@{n} 语法的意思是"n 次移动之前 HEAD 所在的位置":HEAD@{0} 是你现在的位置,HEAD@{1} 是上一步操作之前,以此类推。任何接受提交作为参数的命令都能使用这些引用,这正是救援操作如此直截了当的原因。
在上面的例子里,故事一目了然:你做了两个提交(HEAD@{2} 和 HEAD@{1}),然后一次 reset 把 HEAD 往回移了三个提交。那两个提交并没有丢 -- 它们就在 a1b2c3d 那里。
有日志的不只是 HEAD,每条分支也有自己的:
git reflog show feature/payments
这条命令显示 feature/payments 顶端到过的每一个位置 -- 当你只想看某一条分支的历史,而不想被你做过的每一次 checkout 干扰时,非常有用。
救援场景,一步一步来
1. git reset --hard 退过头了
你本想撤销一个提交,结果干掉了三个,或者重置到了完全错误的位置。reflog 记录了你在 reset 之前所在的位置,而那个位置就是 HEAD@{1}:
git reset --hard HEAD@{1}
修复就这么简单。reset 本身也只是一次 HEAD 移动,所以把 HEAD 移回上一个位置就能恢复一切。如果在那次错误的 reset 之后你还做了别的操作,先运行 git reflog,找到 reset 记录之前的那一行,改为重置到那一行的 SHA。想更全面地了解各种撤销策略,请看如何撤销 Git 提交。
2. 你删除了一条分支
删除分支删掉的是指针,不是提交。如果这条分支最近在本机被 checkout 过或提交过,它的顶端就在 HEAD 的 reflog 里。找到它:
git reflog | grep "feature/payments"
找该分支上的最后一次提交,或者最后一条 checkout: moving from feature/payments 记录 -- 那一行的 SHA 就是分支顶端。然后重新创建指向它的分支:
git branch feature/payments a1b2c3d
分支回来了,和被删除那一刻一模一样。还有个额外福利:删除分支时 Git 会打印出它顶端的 SHA(Deleted branch feature/payments (was a1b2c3d))-- 如果这条消息还留在终端的回滚记录里,你连 reflog 都不用翻。
3. rebase 搞砸了
rebase 会改写提交,在一次冲突不断的 rebase 进行到一半时,你可能觉得分支已经面目全非、无法挽回。其实不然:rebase 之前的状态就在 reflog 里。如果 rebase 还在进行中,最干净的退出方式是 git rebase --abort。如果它已经完成而你对结果深恶痛绝,找到它开始之前的那条记录:
git reflog
# 找 "rebase (start)" -- 它正下方的那条记录
# 就是 rebase 之前你的分支所在的位置
git reset --hard HEAD@{5}
(把 {5} 替换成 rebase 前那条记录在你的 reflog 里实际占据的位置。)你的分支就完全恢复原样了。如果 rebase 经常把你逼到这种境地,我们的指南无所畏惧的交互式 rebase会教你把它从高风险操作变成日常例行。
4. --amend 之后提交不见了
git commit --amend 并不是编辑提交 -- 它会创建一个新提交,并把分支移过去。原来的提交不可达但完好无损,amend 之后它就在 HEAD@{1}。要查看它:
git show HEAD@{1}
如果你 amend 错了提交,或者需要找回原提交,可以 reset 回去,或者用 cherry-pick 把它放到该去的地方。
5. 你在 detached HEAD 状态下提交,然后切走了
你 checkout 了某个具体的提交或标签,在 detached HEAD 状态下做了几个提交,然后切换到了某条分支 -- 你的提交看起来就这么蒸发了。Git 在你切走时甚至会警告你,并打印出那些被遗弃提交的 SHA。无论如何,reflog 里都有它们:
git reflog
# 找到 "checkout: moving from <sha> to <branch>"
# 记录之前你做的最后一次提交
git branch rescued-work e4f5a6b
你那些"游离"的提交现在住进了一条真正的分支,你可以把它们 merge 或 rebase 到任何需要去的地方。
先检查,再恢复
在把任何东西指向一个恢复出来的 SHA 之前,先看看它。确认它确实是你以为的那个提交:
git show a1b2c3d
这会打印出提交信息、作者、日期和完整的 diff。如果你想浏览带完整提交详情的 reflog,而不是一行一条的记录,用 log 的 reflog 模式:
git log -g
还有一个最安全的习惯:不要把当前分支重置到恢复出来的提交上,而是先在它上面建一条临时分支:
git branch rescue a1b2c3d
现在,恢复出来的工作有了一个永久的、可达的名字。你的当前分支没有任何变化,垃圾回收永远碰不到被救回的提交,你可以从容地检查、对比、合并。建一条分支不花一分钱;只要你没有百分之百的把握,就建一条。
坦白说说局限
reflog 是一张了不起的安全网,但它有实实在在的边界,最好在用到它之前就了解清楚:
- 它是严格本地的。reflog 只存在于你的机器上,永远不会随 push、pull 或 clone 传播。新克隆的仓库从一份空 reflog 开始。如果你在笔记本上弄丢了提交,起作用的就是这台笔记本上的 reflog -- 同事的克隆帮不上忙,服务器上的副本也不行。
- 记录会过期。默认情况下,仍可从分支到达的提交的 reflog 记录 90 天后过期,不可达提交的记录 30 天后过期。过期之后,垃圾回收就可以真正删除那些不可达对象。实践中这个时间绰绰有余 -- 但这意味着 reflog 能救上个月的失误,救不了去年的。
- 它找不回从未提交过的东西。被
git reset --hard或git checkout -- <file>毁掉的未提交改动从来都不是 Git 对象,所以没有任何日志记录过它们。唯一的部分例外:至少用git add放进过 staging 的文件会以 blob 对象的形式存在,git fsck --lost-found可以把这些游离的 blob 挖到.git/lost-found/里 -- 只有内容,没有文件名也没有历史,但聊胜于无。这是最后的手段,不是工作流。 - 被丢弃的 stash 有自己的逃生通道。被你 drop 或 clear 掉的 stash 不在 HEAD 的 reflog 里,但 stash 提交通常会以游离对象的形式幸存下来。用
git fsck --unreachable | grep commit加上对候选对象逐个git show,往往能把它们找出来。如果你经常用 stash,我们的 git stash 详解介绍了更安全的工作方式。
预防:让未来的恐慌变得无聊
上面每个场景都有一个前提:工作已经提交过。由此得出唯一真正重要的预防建议:尽早提交,频繁提交。小而糙的、半成品式的提交毫无成本 -- 之后你随时可以 squash、改写信息或重新排序。一样东西一旦被提交,它就进了对象数据库,reflog 会替它兜底。一旦没有,你赌的就是运气。
需要快速切换上下文、又觉得提交太重的时候,用 stash,而不要带着未提交的改动在各个 checkout 之间来回折腾 -- stash 同样是 Git 之后还能找回的真实对象。
GitSquid 适合做什么(以及 CLI 赢在哪里)
诚实的回答:reflog 正是命令行才是正确工具的场景之一。GitSquid 没有 reflog 浏览器,当你需要 git reflog 时,你应该打开终端直接使用它 -- 本文中的命令就是恢复路径。
GitSquid 做的是让周边的工作流更安全,让你更少需要去翻 reflog。文件时间线(右键任意文件,选择 Show history)提供按文件的可视化历史,常常不需要任何恢复操作就能回答"那段代码去哪了?"。reset、删除分支这类破坏性操作都要经过提交图上明确的右键菜单,你在动手之前就能清楚看到自己将要做什么。而当你在还原"刚才到底发生了什么"时,命令日志(Cmd/Ctrl+Shift+L)会展示 GitSquid 在你的仓库上执行过的每一条 Git 命令,带完整参数和退出码 -- 在复盘事故时,这正是你想放在 reflog 旁边的证据。
速查表
| 场景 | 命令 |
|---|---|
| 查看 HEAD 到过哪里 | git reflog |
| 查看分支顶端到过哪里 | git reflog show <branch> |
撤销一次错误的 reset --hard |
git reset --hard HEAD@{1} |
| 恢复被删除的分支 | git branch <name> <sha> |
| 撤销已完成的 rebase | git reset --hard HEAD@{n}(rebase 前的记录) |
| 检查恢复出来的提交 | git show <sha> |
| 浏览带完整详情的 reflog | git log -g |
| 把救回的工作安全停放 | git branch rescue <sha> |
| 已进 staging 但从未提交的文件的最后手段 | git fsck --lost-found |
reflog 把"我把所有东西都弄丢了"变成"我只是把一个指针放错了十分钟"。频繁提交,恐慌之前先看 reflog,在动其他任何东西之前把恢复出来的工作停放到一条临时分支上。如果你想要一个把破坏性操作做得明明白白、并完整记录自己执行的每一条命令的 Git 客户端,免费下载 GitSquid -- 它不会取代你的 reflog,但会让你更少需要它。