git reset --hard 敲下去,发现 reset 的是错的分支——五个提交连同今天的改动从 git log 里消失了。这是 Git 用户最常遇到的恐慌时刻。
好消息:只要没有手动跑过 git gc --prune=now,这些提交对象仍然完整地躺在 .git/objects 里,只是没有任何引用指向它们。Git 不会因为「没人指向」就删除数据,删除是 gc 的活,而 gc 有默认的宽限期。
这篇文章讲三件事:reflog 怎么用、reflog 失效后 fsck 怎么捞、以及哪些东西其实救不回来。
先搞清楚丢了什么:reset 只移动指针
git reset --hard HEAD~3 做了三件事——把当前分支指针往回移 3 个提交、把 index 重置成那个提交的树、把工作区文件覆盖成那个提交的内容。第一步只是让分支不再指向那些提交,提交对象本身没动。
一个可以自己跑的实验,后面所有操作都基于这个目录:
mkdir /tmp/git-rescue && cd /tmp/git-rescue
git init -q
git config user.email demo@example.com
git config user.name demo
for i in 1 2 3 4 5; do
echo "line $i" >> app.txt
git add app.txt
git commit -qm "commit $i"
done
git log --oneline # 5 个提交都在
git reset --hard HEAD~3
git log --oneline # 只剩 commit 2 和 commit 1
现在「丢了」三个提交。
第一招:reflog,能解决九成的误操作
git reflog 打印的是 HEAD 的移动历史。每次 commit、reset、rebase、merge、checkout、cherry-pick 都会留下一条记录,带 SHA 和动作描述。
git reflog
# 5b1d0c9 HEAD@{0}: reset: moving to HEAD~3
# 9a3f2e1 HEAD@{1}: commit: commit 5
# 7c8d4b0 HEAD@{2}: commit: commit 4
# ...
HEAD@{1} 就是 reset 之前的位置。正确做法不是直接 reset 回去,而是先建一个救援分支:
# 建分支指向 reset 前的位置,这一步是无损的
git branch rescue/before-reset HEAD@{1}
# 确认内容对不对
git log --oneline rescue/before-reset
git diff rescue/before-reset HEAD
# 确认无误再切过去
git switch rescue/before-reset
为什么不直接 git reset --hard HEAD@{1}?也能成功,但 reflog 条目多的时候很容易数错位置,数错之后指针又动一次,虽然还能再找回来,可现场更乱,而且这一步本身又会覆盖工作区。建分支只多一个 ref,零风险。
reflog 默认输出太简略,三个更好用的形式:
git reflog --date=iso # 带绝对时间,方便对上「我几点干的」
git log -g --oneline # 用 log 的格式看 reflog,能看到完整提交信息
git reflog show main # 只看 main 这一个分支的移动历史
git log -g 在找回时特别有用:reflog 只显示 commit: xxx 的标题,而 git log -g 能把完整提交信息和作者时间列出来。
reflog 什么时候靠不住
reflog 是 .git/logs/ 目录下的文本文件,不是 Git 对象,不随 push/clone/fetch 传输。这意味着三件事:
- 同事的机器上看不到你丢的提交,让同事帮忙找是没用的。
- 重新 clone 一份仓库,reflog 是空的。
- 换台机器也没有。
可以直接用文本工具看:
ls .git/logs/HEAD
tail -5 .git/logs/HEAD # 每行就是一次 HEAD 移动,纯文本
过期时间由两个配置控制:
git config --get gc.reflogExpire # 默认 90 days
git config --get gc.reflogExpireUnreachable # 默认 30 days
第一条管可达的 reflog 条目(分支上还在的提交),第二条管不可达的(被 reset 掉、rebase 掉的那些)。也就是说一次 reset --hard 之后,你有 30 天找回窗口,超过就可能被 gc 清掉。
真正会立刻毁掉一切的是这个:
# 不要在有未恢复数据的时候执行
git gc --prune=now
--prune=now 无视宽限期,立即删除所有不可达对象。有人为了「让仓库小一点、快一点」,随手就跑 git gc --prune=now --aggressive,跑完之后 reflog 里那些 SHA 就变成悬空指针,git cat-file 直接报 Not a valid object name。默认的 git gc 有 gc.pruneExpire(默认 2 weeks)兜底,不需要手动加 --prune=now。
还有一个坑单独说:git reset --hard 不会删除 untracked 文件。有人以为会,于是先跑 git clean -fdx「清理干净」,结果把还没被 Git 跟踪的新文件删得干干净净——这些文件从来没进过对象库,reflog 和 fsck 都救不了。顺序反了,代价是永久丢数据。
第二招:reflog 没了,用 fsck 捞 dangling 对象
过期了、换了机器、或者想找回的根本不是提交而是暂存区的内容,git fsck 是下一道防线。
git fsck --lost-found --unreachable
# dangling commit 9a3f2e1d...
# dangling blob 3f8a2c0...
# dangling tree ...
dangling 的意思是「这个对象存在,但没有任何 ref 或 reflog 指向它」。--lost-found 会把找到的对象写到 .git/lost-found/commit/ 和 .git/lost-found/other/ 下。
dangling commit 可能有一堆,逐个 git show 太慢,先列出来:
git fsck --lost-found 2>/dev/null | awk '/dangling commit/ {print $3}' | while read c; do
echo "$c $(git log -1 --format='%ci %s' $c)"
done
按时间扫一眼就能认出目标。找到之后 git branch rescue <sha> 或 git cherry-pick <sha>。
性能提醒:git fsck 要遍历全部对象并校验内容,大仓库上很慢,内核那个量级的仓库完整跑一遍要几分钟到十几分钟。加 --connectivity-only 只检查对象之间的引用是否连通、跳过 SHA-1 校验和比对,能快一个数量级:
git fsck --connectivity-only --lost-found
代价是它不会发现损坏的对象,只回答「还能不能走通」。找丢失的提交用这个足够,排查仓库损坏才需要完整 fsck。
再说 blob。git add 过的内容已经作为 blob 写进对象库了,即使随后 git reset --hard 清空了暂存区,blob 仍然在,直到 gc。
# 列出所有悬空 blob
git fsck --lost-found 2>/dev/null | awk '/dangling blob/ {print $3}' | head -20
# 看内容,确认是不是你要的
git cat-file -p 3f8a2c0
# 确认后导出
git cat-file -p 3f8a2c0 > src/recovered.ts
边界条件:blob 里没有文件名,也没有目录结构。你只能靠内容辨认它是哪个文件,一个目录下的多个文件会被拆成多个独立 blob,得一个个对。文件多的时候这是纯体力活。所以「git add 过」和「能轻松恢复」之间还差一截。
stash 和 rebase 的两种特殊情况
git stash drop 之后想找回:
git fsck --lost-found 2>/dev/null | awk '/dangling commit/ {print $3}' | while read c; do
git log -1 --format='%h %s' $c
done | grep -i 'WIP on'
stash 提交的标题固定是 WIP on <branch>: <sha> <message>,grep 一筛就出来。找到之后不要 git checkout,那个提交有三个父提交,直接切过去得到的工作区很怪。用:
git stash apply <sha>
或者 git branch rescue <sha> 再慢慢看。git stash drop 会删掉 refs/stash 对应的 reflog 条目,所以 git log -g refs/stash 里往往已经找不到它,得靠 fsck。
rebase 分两个阶段:
# 还在冲突中,rebase 没结束
git rebase --abort
# rebase 已经做完,但结果不对,想整体撤销
git reset --hard ORIG_HEAD
ORIG_HEAD 是 rebase/merge/reset 之前 Git 自动记录的备份指针。它只有一个槽位,下一次危险操作就覆盖。rebase 完之后又做了 merge,ORIG_HEAD 就不是你要的点了,这时候回 reflog:
git reflog | grep -n 'rebase (start)'
rebase 在 reflog 里会留下多条记录(每个 pick 一条),rebase (start) 标记起点,它上面那一条就是 rebase 之前分支的真实位置。
能不能救,一张表说清
| 场景 | 能否恢复 | 手段 | 前提 |
|---|---|---|---|
commit 后 reset --hard |
能 | git reflog |
30 天内且没 gc |
commit --amend 改错了 |
能 | HEAD@{1} |
同上 |
| rebase 结果不对 | 能 | ORIG_HEAD / reflog |
同上 |
git add 后 reset --hard |
大概率能 | fsck 找 dangling blob | blob 没被 gc,且能认出内容 |
stash drop |
能 | fsck + stash apply |
同上 |
分支被 -D 删除 |
能 | reflog show <branch> 或 fsck |
分支 reflog 未过期 |
手改文件后 git checkout -- file |
不能 | 无 | 除非之前 add 过 |
git clean -fdx 删的 untracked 文件 |
不能 | 无 | 从没进过对象库 |
| 浅克隆里丢的提交 | 不能 | 无 | reflog 为空 |
倒数第三行那种情况每天都在发生:git checkout -- <file> 拿 index 或 HEAD 的内容覆盖工作区,被覆盖的内容如果从没 git add 过,就不在任何地方,没有任何 Git 命令能救。
让它变得可救
两条配置:
# 仓库级:把不可达对象的保留期从 30 天拉到 90 天
git config gc.reflogExpireUnreachable 90.days
git config gc.reflogExpire 180.days
这是仓库级配置,不会跟着 --global 走,克隆到新机器要重设;CI 里一般用不上,因为那边是浅克隆或一次性仓库。
习惯一:危险操作前打一个备份 ref,成本接近零。
git branch backup/$(date +%Y%m%d-%H%M)
git reset --hard origin/main
习惯二:reset --hard 之前先 stash,把未提交的改动(含 untracked)收起来:
git stash push -u -m "reset 前的现场"
git reset --hard origin/main
# 需要时
git stash pop
-u 会把 untracked 文件也放进 stash。这一步的价值在于:即使后面 gc 把对象清了,stash 的 ref 还在,内容就还在。
真正的分界线只有一条:内容有没有进过 Git 的对象库。进过——commit 过、add 过、stash 过——就有救,差别只是找回来费多大劲。没进过——手写的、编辑器里改了一半的、clean 掉的——谁都救不了,Git 不是文件系统的回收站。