敲完 git reset --hard 的下一秒,发现刚才那半天的活儿没了。
先停手。 这一刻数据多半还在 .git 里,而接下来你敲的每一条命令都可能把它推得更远。
先分清丢的是哪一种
Git 的恢复能力不是一个开关,它取决于丢掉的内容有没有进过对象库:
| 情况 | 能找回吗 | 用什么 |
|---|---|---|
已经 commit 过 | 能 | git reflog |
add 过但没 commit | 能(gc 之前) | git fsck --lost-found |
改完从没 add 过 | 不能 | —— |
分界线就是 git add。add 会立刻把文件内容写成一个 blob 对象存进 .git/objects,哪怕你之后从没提交过。而从没 add 过的修改,Git 从头到尾就不知道它存在。
这也是那条老建议的真正理由:不确定某条命令的后果时,先 git add . 或 git stash -u。两秒钟,换一次免费快照。
一、提交过的:reflog
git reflog 记录的是引用的每一次移动——HEAD 曾经指向哪里、分支曾经指向哪里,包括被 reset 掉的那些位置。
git reflog
a1b2c3d HEAD@{0}: reset: moving to HEAD~3
9f8e7d6 HEAD@{1}: commit: 写完了导出逻辑 ← 要找的是这个
5c4b3a2 HEAD@{2}: commit: 补测试
确认内容,再恢复:
git show 9f8e7d6 # 先看一眼是不是它
git branch rescue 9f8e7d6 # 挂到新分支上,当前分支不动
恢复时建分支而不是直接 reset,这一条值得养成习惯。git branch rescue <哈希> 完全不改变你当前的状态,确认 rescue 分支里的东西对了再合回去;直接 git reset --hard 过去,万一哈希找错了还得再捞一轮。
几个常用变体:
git reflog show feature-x # 只看某个分支的移动(找回误删的分支)
git reflog --date=iso # 带上时间,好认
git show HEAD@{2.hours.ago} # 按时间回溯
git diff HEAD@{1} HEAD # 对比上一个位置
还有一个经常被忘记的快捷方式:merge、rebase、reset 这几条命令执行前会把原位置存进 ORIG_HEAD,所以「刚 rebase 完发现搞砸了」最快的一条是:
git reset --hard ORIG_HEAD
二、add 过但没提交的:悬空对象
reflog 对这一类无能为力——没有任何引用指向过它们。但对象还在,只是成了「悬空对象」(dangling object)。
git fsck --lost-found
它会把捞到的东西写进 .git/lost-found/:
.git/lost-found/commit/ ← 悬空的提交
.git/lost-found/other/ ← 悬空的 blob,也就是文件内容
接下来是逐个认:
ls -lt .git/lost-found/other/ # 按时间排序,最近的在最上面
git cat-file -p <哈希> | head -50 # 看看是不是要找的那个文件
git cat-file -p <哈希> > src/lost.ts # 认出来了就写回去
注意 blob 不带文件名——对象库里只存内容,路径信息在 tree 对象里。所以这一步只能靠看内容辨认,文件多的时候会有点枯燥,但东西确实在。
一个更省事的筛选方式,是先按类型和时间过滤:
git fsck --unreachable --no-reflogs | grep blob
三、stash 被 drop 或 clear 了
git stash drop 执行时会打印一行:
Dropped refs/stash@{0} (a1b2c3d4e5f6...)
那串哈希就是钥匙,终端还没关就抄下来:
git stash apply a1b2c3d4e5f6
输出已经翻不到了也不要紧,stash 条目本质上是提交对象,同样能捞:
git fsck --unreachable | grep commit
git show <哈希> # 逐个看,stash 条目通常有 2-3 个父提交
git stash apply <哈希>
git stash clear 也一样——它只删掉了 refs/stash 这个引用,底下的提交对象要等 gc 才会被真正清掉。
四、Git 什么时候真的删
三个默认时限,各管一段:
| 配置项 | 默认值 | 管什么 |
|---|---|---|
gc.reflogExpire | 90 天 | 仍然可达的 reflog 条目保留多久 |
gc.reflogExpireUnreachable | 30 天 | 已经不可达的 reflog 条目保留多久 |
gc.pruneExpire | 两周 | 悬空对象被物理删除前的宽限期 |
而 gc 本身不是定时跑的。多数情况下它是被 commit、merge、rebase 这类命令以 gc --auto 的形式顺带触发,且默认要等松散对象攒到六千多个才真动手。小仓库可能几个月都不 gc 一次——这也是很多「以为早就没了」的东西其实还在的原因。
反过来,有两条命令会立刻把宽限期清零:
git gc --prune=now # ⚠ 立刻物理删除悬空对象
git reflog expire --expire=now --all # ⚠ 立刻清空 reflog
数据还没找回来之前,这两条绝对不要敲。
五、这几种是真的没了
诚实地列一下边界:
git reset --hard丢掉的从没 add 过的工作区修改git checkout -- 文件/git restore 文件覆盖掉的未暂存修改git clean -fd删掉的未跟踪文件——它们从来就不在 Git 里- 已经
git gc --prune=now过的悬空对象
这几种只能指望 Git 之外的东西:IDE 的本地历史(JetBrains 的 Local History、VS Code 的 Timeline 都独立于 Git 记录文件快照,很多人是靠这个救回来的)、编辑器的自动备份、系统级的时间机器或文件历史。
六、慌的时候最不该做的三件事
- 继续敲改写历史的命令 —— 又一次 reset、又一次 rebase,只会让 reflog 更长、更难认
- 敲
git gc --prune=now—— 网上某些「清理仓库」的帖子会推荐它,在这个时刻它是删除键 - 重新 clone 覆盖当前目录 —— 远端没有的东西,clone 回来也不会有;而你本地的
.git一删,所有悬空对象一起没
预防比恢复便宜得多
- 不确定就先
git stash -u—— 两秒钟,只在本地,不会被推出去 - 提交粒度小一点 —— 提交过的东西 90 天内基本捞得回来,没提交的只有两周
- 危险命令先认一遍 —— Git 命令速查 把会丢改动的命令单独标了出来,勾上「只看会丢东西的命令」一次能看完,敲之前扫一眼比事后捞省事
--force-with-lease替代--force—— 代价为零,能挡掉一类很难解释的事故
配套的两篇:.gitignore 加了却不生效、已提交的文件怎么让 Git 忘掉,见 .gitignore 不生效怎么排查。