git reset --hard 后,丢的提交怎么捞回来

格 格达软件 2026-11-07 4 次阅读

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 传输。这意味着三件事:

  1. 同事的机器上看不到你丢的提交,让同事帮忙找是没用的。
  2. 重新 clone 一份仓库,reflog 是空的。
  3. 换台机器也没有。

可以直接用文本工具看:

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 不是文件系统的回收站。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。