周五下午四点,我正准备切到主分支合并一个功能,同事在旁边说“那个改动先不要了,帮我把本地回退一下”。我顺手敲了 git reset --hard HEAD~1。回车之后,他脸色变了:“我下午写的代码呢?”那一瞬间我冷汗就下来了。
后来靠 git reflog 把丢失的提交找了回来。那次之后,我把 Git 误操作的恢复方法整理成一份急救笔记,也在团队内部做过分享。这篇就是那份笔记的完整版:当你已经把代码弄丢、分支删错、提交推错、rebase 到一半想跑路时,具体应该敲哪些命令,以及为什么这些命令能救命。
别管你是刚接触 Git,还是已经写了好几年,只要你还在用 Git 管理代码,就早晚会遇到一次“误操作”。Git 和 Word 最大的不同是:它没有撤销按钮,但它把几乎每一次操作痕迹都留在了本地仓库里。你需要的只是知道去哪里翻案发现场。
1. 急救第一课:先把手从键盘上拿开
1.1 第一反应别是“反着再执行一次”
误操作之后的黄金三分钟,最重要的事情不是补救,而是别再制造新的破坏。
我见过太多次这种场面:有人 git reset --hard 之后发现代码没了,本能反应是再执行一个反向的 git reset,或者赶紧 git checkout .,结果因为对当前 HEAD 指向哪里并没有把握,反而把原本还能救的提交覆盖了。还有的人会顺手关掉终端、重新打开 IDE,结果在反复启动中触发了自动清理,好端端放在 reflog 里的记录被 GC 机制处理掉。
正确做法是:
- 停手,不关闭终端窗口,不执行任何写入类 Git 命令;
- 把屏幕上最后一条带 commit hash 的输出复制下来,哪怕你不确定它有没有用;
- 执行
git status查看当前状态,只读命令不会破坏现场; - 心态稳下来,对照下面几章找对应的救援手段。
这里要说一个核心概念:Git 本质上是一个内容寻址的对象数据库。分支、HEAD 这些名字,只是一串指向 commit 对象的“引用”。危险操作之所以危险,是因为它让这些引用指向了别的位置,但只要对象还没有被垃圾回收,数据就还在仓库里。所以大多数“代码丢了”的惊魂时刻,实际是“引用丢了”,不是“数据没了”。
1.2 先看清 Git 留了哪几张牌
在开始救援前,你需要知道本地仓库里有什么“牌”可以打。下面这张表是我判断误操作场景时必看的清单:
| 工具/记录 | 作用 | 常见适用场景 |
|---|---|---|
git reflog |
记录 HEAD 和分支引用的历史移动轨迹 | reset、rebase、checkout 切走后的恢复 |
ORIG_HEAD |
某些大操作(merge、reset 等)之前位置的参考 | 刚执行完误操作,想快速回到从前 |
git fsck --lost-found |
扫描当前不可达但尚未被清理的对象 | 分支被删、stash 被丢、提交无引用 |
| IDE 本地历史 | 编辑器自带的时间线备份 | 从未 git add 过的新文件被 clean 删除 |
| 远端仓库 | 只要 push 过,远端就有副本 | 本地分支损坏、历史误改、想从远端拉回 |
我会在后面的每一章里具体展开其中的用法。
有一个观点值得反复强调:Git 的 GC 不是实时运行的。普通提交对象不会因为你删了一个分支就立刻消失,它们会以“悬空对象”的身份继续存在于 .git 目录里一段时间。默认情况下,reflog 中可达的提交保留 90 天,不可达的引用也有 30 天的缓冲期。这其实就是 Git 留给你的后悔药时间窗。真要过了这个时间窗还没发现,那才叫难办——不过那也是相对难办,下面第三章里我专门讲了怎么用 git fsck 去“考古”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 误执行 git reset --hard 后,靠 reflog 把工作区找回来
2.1 事故现场:一条命令冲掉了三天功能
假设你现在在 feature/pay 分支上,已经写了三个提交,还没推送到远端:
bash复制$ git log --oneline
d6e4a1f (HEAD -> feature/pay) feat: 支付结果页调优
c32a8b9 fix: 修复回调签名校验
4ff10aa feat: 接入支付网关
然后你想撤销某个操作,输入了:
bash复制git reset --hard origin/main
回车之后你发现,本地 feature/pay 上的 d6e4a1f、c32a8b9、4ff10aa 三个提交全不见了,工作区变成了远端主分支的最新代码。
很多人这时候会去翻 git log,然后发现 d6e4a1f 根本没有出现在日志里,以为代码彻底没了。不对——git log 只看得到当前引用链上可达的提交,而你现在 HEAD 指向了 origin/main,那三个 commit 自然不在列表里。但它们并没有被删除,只是变成了“不可达对象”。
2.2 读懂 reflog,别只会刷屏
现在执行:
bash复制git reflog
你会看到类似下面的输出:
text复制a71b6c2 HEAD@{0}: reset: moving to origin/main
d6e4a1f HEAD@{1}: commit: feat: 支付结果页调优
c32a8b9 HEAD@{2}: commit: fix: 修复回调签名校验
4ff10aa HEAD@{3}: commit: feat: 接入支付网关
reflog 本质上是 HEAD 引用每次变更的“日志”。它记录了你在本地执行过的每一个会导致引用移动的动作:commit、checkout、reset、merge、rebase、cherry-pick……在这个例子里,最上面一条是你刚刚那个 reset,它的上一条 HEAD@{1} 就是 reset 之前的位置,也就是你真正想回去的地方。
恢复动作也简单:
bash复制git reset --hard d6e4a1f
执行完再看 git log,三个提交全部回来了。
这里有一个很多人没意识到的细节:git reset --hard 执行后,它本身也会被写进 reflog。也就是说,只要你没有马上执行大量新操作把 reflog 刷掉,你几乎总能从 reflog 里找到 reset 之前的位置。所以遇到 reset 翻车,第一反应真的不用慌。
如果想更保险,可以在恢复之前先把当前状态备份成一个分支:
bash复制git branch backup/after-reset
git reset --hard d6e4a1f
万一你后来又想起当前这个 a71b6c2 状态里还有用,随时可以切回 backup/after-reset。多建一个备份分支的成本极低,后悔成本却能降为零。
2.3 只想恢复某个文件时怎么办
有些时候你不需要整体回滚,只是 reset --hard 把某个文件改回了旧版本,而你只想把那个文件捞回来。
更精准的命令是 git restore:
bash复制# 从指定提交恢复这个文件,并同步更新暂存区和工作区
git restore --source=d6e4a1f --staged --worktree src/main/java/com/example/PayService.java
如果你用的是老版本 Git,也可以写成:
bash复制git checkout d6e4a1f -- src/main/java/com/example/PayService.java
两种写法的效果类似:从指定 commit 里把文件内容取出来,覆盖当前工作区。区别在于 restore 的语义更清晰,而且可以精确控制到底更新暂存区还是工作区。
2.4 叠加了 git clean -fd 时还能救哪些
比裸 reset --hard 更危险的是组合拳:先 git reset --hard,再执行 git clean -fd 清掉未跟踪文件。
git clean -fd 会删除所有未跟踪的文件和目录。如果这些文件从来都没有被 git add 过,Git 的对象库里根本没有它们的任何记录,那就真救不回来了——只能靠 IDE 的本地历史,或者你自己有没有其他备份。这也是为什么我一直跟同事强调:新写的重要文件,第一时间 git add 一下,哪怕不提交,Git 的 object 库里也会留下内容副本。
如果你之前确实 git add 过,但因为后续操作导致它变成了未跟踪文件,最后被 clean 误删,那还有一线生机:执行 git fsck 扫描悬空对象,再配合 git cat-file 找回内容。
bash复制git fsck --lost-found
这个命令会把仓库里所有不可达对象提取出来,commit 放在 .git/lost-found/commit/,blob 对象放在 .git/lost-found/other/。blob 对象没有文件名,所以你只能靠内容去辨认:
bash复制find .git/lost-found/other -type f | head -20
你可以逐个查看文件内容,或者用 grep 搜关键字。这个过程比较费劲,但总比没有强。真正能救你命的,永远是良好的提交习惯,而不是最后的考古手段。
3. 分支误删和 stash 丢失:让 fsck 当你的考古工具
3.1 误删分支:先从 git 的告警里找 hash
删分支是另一个高频误操作。经常有人开发完一个功能,以为已经合并了,顺手执行:
bash复制git branch -D feature/account
结果 Git 回了一句:
text复制Deleted branch feature/account (was 915f5e2).
然后你才发现这个分支其实还没合并,上面的提交全在 915f5e2 这个 commit 上。
注意:Git 删除分支时,已经把最关键的信息告诉你了。915f5e2 就是这个分支当时指向的提交 hash。如果你屏幕还没清,直接执行:
bash复制git checkout -b feature/account 915f5e2
分支原地复活。
但如果当时没注意、回了一句“删就删了”,后面又执行了很多其他操作,把屏幕刷没了,该怎么办?还有一招是 git reflog。如果你在删除这个分支之前,曾在这个分支上有过 commit,那么这些操作记录很可能留在当前的 HEAD reflog 里。但这不是必然——因为 git branch -D 删的是分支引用本身,而不是 HEAD 记录;如果你切到别的主分支之后才删掉它,HEAD reflog 里不一定有那个分支最后 commit 的痕迹。
这时候就要请出真正的考古工具了。
3.2 fsck 救援实操
执行:
bash复制git fsck --full --no-reflogs --unreachable
输出会列出所有当前没有任何引用指向的 commit、tree 和 blob 对象:
text复制unreachable commit 915f5e2d3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d
unreachable blob 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b
看到这种 unreachable commit,用 git show 看一眼内容:
bash复制git show 915f5e2d3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d
确认是你误删分支上的提交后,重建分支:
bash复制git branch feature/account 915f5e2d3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d
这里有三个关键词需要理解:
--full:要求检查完整对象库,而不是只看当前引用可达的部分;--no-reflogs:即使 reflog 里还存在引用记录,也要找出真正不可达的对象;--unreachable:只显示不可达对象,避免输出正常提交刷屏。
之所以能靠 fsck 找到,是因为 Git 删除分支只是删掉一个指向 commit 的引用,commit 本身以及它关联的整棵文件树在 GC 之前都还在对象库里。从这个意义上说,你删除分支后越快执行 fsck,找回的成功率越高。时间长了,git gc 一旦运行,这些悬空对象可能被物理清理,那就只能去远端找备份了。
3.3 stash 被 drop/clear 的真实恢复过程
git stash 也一样。很多人喜欢把临时改动扔进 stash,过两天清理时执行 git stash clear,把一堆 stash 全清了。等想起来里面还有一个重要的修改时,已经晚了。
先说原理:git stash 本质上就是创建一个特殊的 commit,然后用 refs/stash 这个引用指向它。git stash drop 或 git stash clear 删除的只是引用,commit 同样会变成不可达对象。
找回流程:
bash复制git fsck --full --no-reflogs --unreachable | grep commit
会看到一堆 unreachable commit,其中某几个很可能就是你的 stash。逐个确认后,用 stash 的语法套回去:
bash复制git stash apply 4f2c9a1b3c5d7e8f9a0b1c2d3e4f5a6b7c8d9e0f
apply 之后工作区里就会出现当时 stash 的改动。如果发现这个 commit 其实是一个没有父提交的“根 commit”,或者它带着多个 parent,也不用慌,那正好说明它是典型的 stash 对象结构,直接 apply 就行。
经历几次 stash 丢失救援之后,我的个人习惯是:重要 stash 不要长期存放,能当天恢复就当天恢复;真要留,不如直接在本地建一个分支提交上去。分支比 stash 清晰得多,也不怕误清理。
3.4 detached HEAD 下提交“不见了”不是真不见了
还有一个容易被误判为“丢代码”的场景:你 git checkout 到一个历史 commit 上,Git 会提示你处于 detached HEAD 状态,也就是 HEAD 直接指向 commit,而不指向任何分支。此时如果直接在这个状态下继续提交,新 commit 的父链是存在的,但从分支图上看不到——因为没有任何分支引用它。
很多人此时会惊慌,以为提交白做了。其实只需要先看一下当前 HEAD:
bash复制git log -1 --oneline
记下 hash,然后创建分支把它接住:
bash复制git switch -c rescue/headless-commit
或者不新建分支,直接把你原来的分支强制移动过来:
bash复制git branch -f feature/account 022a3b1c
git switch feature/account
只要你还在 reflog 的窗口期内,detached HEAD 状态下做的提交都有据可查。每一条“我切到旧版本后改了代码,怎么切回主分支就找不到了”的求助,80% 都是这个问题。
4. 已推送的错误提交:amend、rebase 与安全的 force push
4.1 只改最后一次提交
如果你的错误提交还没有推送到远端,那处理起来非常轻松。
最常见的情况是提交信息写错了,比如把“feat: 支付回调日志改进”写成了“fix: 支付回调日志改进”。更正:
bash复制git commit --amend -m "feat: 支付回调日志改进"
如果你还想把某个漏掉的文件补进上一次提交,可以这样:
bash复制git add src/main/java/com/example/PayNotifyService.java
git commit --amend --no-edit
--amend 的意思不是“修改补丁”,而是创建了一个全新的 commit,替换掉原来的最后一个提交。新提交会沿用原来提交的父提交,只是内容或信息不同。正因为它会产生新 hash,所以一旦原提交已经推送到远端,这个操作会导致本地与远端历史分叉,必须在推送时小心处理。
4.2 需要改动历史多个提交时用 rebase -i
如果想改的不只是最后一次提交,而是历史中某个提交,就要用到交互式 rebase:
bash复制git rebase -i HEAD~3
Git 会打开一个文本编辑器,列出最近 3 条提交:
text复制pick d6e4a1f feat: 接入支付网关
pick c32a8b9 fix: 修复回调签名校验
pick a71b6c2 feat: 支付结果页调优
把你想操作的提交前关键词改掉,常用的有:
reword:修改提交信息;edit:停下来修改内容;squash:合并到上一个提交,同时可以把多个提交压成一个;fixup:类似 squash,但直接丢弃被合并提交的信息;drop:删除该提交。
比如想把 c32a8b9 的提交信息改掉,就把那行的 pick 改成 reword,保存退出。Git 会停在需要修改信息的提交处,让你输入新信息。
如果想把三个提交压成一个:
text复制pick d6e4a1f feat: 接入支付网关
squash c32a8b9 fix: 修复回调签名校验
squash a71b6c2 feat: 支付结果页调优
保存后 Git 会打开编辑器让你汇总新的提交信息。这种操作在多人共用的分支上要格外克制,因为它的本质是在重写历史。
4.3 推远端前想清楚这三点
amend 和 rebase 都会重写提交的 hash。只要原提交被别人拉取过,强推就会造成同事本地历史与远端不一致。所以强推前至少要确认三点:
- 这是不是只有你一个人在用的特性分支? 如果是主分支或者多人协作分支,不要轻易重写历史;
- 用不用
--force-with-lease? 用; - 是否有同事已经基于旧历史做了新提交? 这种情况下,光强推是不够的,需要同步所有人重新 rebase。
先说 --force-with-lease。普通 git push --force 会无条件把本地历史覆盖到远端,哪怕远端在你这段时间已经有了别人新推的提交,它也会直接冲掉。--force-with-lease 则会在强推前检查远端引用是否还是你上次 fetch 时的状态,如果远端变了,就拒绝推送。
bash复制git push --force-with-lease origin feature/login
这个命令不是万能的,但在绝大多数场景下,它能把“我覆盖了同事的提交”这种事故概率降到极低。我给团队的要求是:所有人 push 大改动时默认只许用 --force-with-lease,把裸 --force 当违禁品。
4.4 历史里混入敏感文件或大文件
比提交信息错误更头疼的,是把不该提交的文件提交进了历史。比如把包含数据库口令的配置文件提交了,或者不小心把一个 500MB 的二进制包提交了。就算你现在把它从最新提交里删掉,它依然存在于历史提交的对象库中,任何人 clone 整个仓库都能看到。
处理思路是重写历史,把这些文件从所有提交中彻底剔除。这里我推荐 git filter-repo,而不是老牌的 git filter-branch。filter-branch 又慢又容易踩坑,官方文档也建议改用 filter-repo。
假设你要把历史中所有路径为 config/secret.properties 的文件删除:
bash复制git filter-repo --path config/secret.properties --invert-paths
--invert-paths 表示“保留除了指定路径之外的所有内容”,效果就是删除该文件在全部历史中的出现。
如果只是某个历史提交中误加了 logs/ 目录,想清空这个目录:
bash复制git filter-repo --path logs/ --invert-paths
历史重写完成后,你本地的所有 commit hash 会全部变化。接下来把新历史推送到远端:
bash复制git remote add origin <远程地址>
git push --force-with-lease origin --all
注意,这还没完。只要敏感信息曾经到达远端,就必须假设它已经泄露。能轮换的密钥马上轮换,能撤销的访问权限马上撤销。历史重写只是让之后的仓库不再包含这个文件,它没法收回已经被人 clone 走的副本。
如果文件极大导致仓库体积膨胀,也可以用 git filter-repo 配合 --strip-blobs-bigger-than 压缩历史体积:
bash复制git filter-repo --strip-blobs-bigger-than 50M
最后还有一个务必告知团队的步骤:所有协作者必须重新拉取新历史,而不是在旧历史基础上继续开发。这一步漏掉,你的历史重写就是白做。
5. merge/rebase 翻车现场:abort、reflog 和备份分支
5.1 冲突解决不了,随时可以按“回到从前”
合并和 rebase 是 Git 操作里最容易翻车的两类。冲突多到怀疑人生、越改越乱、只想回到操作之前,这种状态我太熟悉了。
Git 的设计者其实早就给你留好了逃生通道:
| 操作 | 中止命令 | 效果 |
|---|---|---|
git merge |
git merge --abort |
回到 merge 前的状态,放弃合并 |
git rebase |
git rebase --abort |
回到 rebase 前所在分支的状态 |
git cherry-pick |
git cherry-pick --abort |
放弃这次拣选提交 |
git revert |
一般不需要 abort,可用 git revert --abort |
回到 revert 前 |
比如你在执行 git merge feature/pay 时遇到冲突,Git 提示 Automatic merge failed; fix conflicts and then commit the result。你打开文件一看,冲突标记比我写过的代码还多。这时候别逞强,直接:
bash复制git merge --abort
工作区会回到 merge 之前的状态,分支位置也不变,干净利落。
rebase 同理:
bash复制git rebase --abort
它会完全放弃这次 rebase,把你带回执行 rebase 之前的分支和提交。注意,rebase --abort 不会保留你已经手改到一半的冲突解决结果。如果你在冲突中手工完成了某些关键修改,但还没完全解决完,想留着这部分改法,那就先复制一份文件到仓库外,然后再 abort。
5.2 rebase 结束后后悔,怎样才能回到起点
比进行到一半想跑路更麻烦的,是 rebase 已经完整跑完,你看了看生成的历史,发现完全不是自己想要的样子,甚至比原来更乱了。
git rebase --abort 此时已经无效,因为 rebase 已经结束。但你还有别的救兵——reflog。
比如你之前是:
bash复制git rebase main
执行完以后历史变得一团糟。执行:
bash复制git reflog
输出里会有一条记录类似:
text复制40f1a0e HEAD@{1}: rebase (finish): returning to refs/heads/feature/pay
这条记录的下面一条,也就是 HEAD@{2} 或更早的位置,往往就是 rebase 开始之前你的分支所在位置。你可以直接重置回去:
bash复制git reset --hard HEAD@{2}
不过这里有个细节:reflog 的编号会因为你的具体操作数量而不同,不要死记编号。正确做法是找到那条和 rebase 相关的记录,看它前面的那条 HEAD@{n} 是什么,确认后再执行。
如果 rebase 折腾过程中你经历了很多步,reflog 里可能有很多条 rebase 开头的记录,那就看时间,选最早出现 rebase 之前的那条。
5.3 手动解决冲突前先备份,别把 abort 当后悔药
我在实际工作中总结出来的一个教训是:不要把 abort 当成后悔药,因为它不保留任何手动修改的内容。
有一次同事在 rebase 时手动解决了一个文件的冲突,这个解决方案花了 10 分钟,逻辑本身没问题。但他又遇到另一个冲突时心态崩了,直接 git rebase --abort,结果之前那个手动解决的文件也被回滚掉了,等于白干。
那次之后,我在团队里定了一条规则:只要开始手动解决任何冲突,先把目标文件复制一份到临时目录,或者直接创建一个分支备份当前进度。
创建一个 WIP 分支是最稳妥的:
bash复制git branch wip/conflict-solving
不需要切换,这个分支会指着当前 HEAD。万一 abort 后想找回自己手动改的东西,从 WIP 分支切过去看就行。
rebase 结束后后悔的那个场景,也可以用这个思路解决:执行任何大型 rebase 之前,先建 backup 分支:
bash复制git branch backup/feature/pay-before-rebase
git rebase main
如果 rebase 结果不满意,你永远有个安全出口:
bash复制git reset --hard backup/feature/pay-before-rebase
6. 用一套好习惯,把急救次数降到接近于零
6.1 危险操作前的三句话
急救手册翻得再多,也不如让自己少进急救室。无数次和误操作搏斗之后,我把 Git 高危操作前的自查浓缩成了三句话:
- 这个操作会移动哪些引用? 是 HEAD、分支,还是 push 到远端?
- 移动之后,原来的引用还能从哪里找到? 如果答案只能是 reflog,那就提前把当前 hash 抄下来;
- 如果操作失败,我的逃生路径是什么? 是 abort、是 backup 分支,还是远端副本?
尤其是 git reset --hard、git clean -fd、git push --force、git branch -D 这四个命令,每次敲之前我都会强制自己在心里把这三句话过一遍。
有人觉得这很麻烦。说实话,我一开始也觉得麻烦,直到我用一条 git reset --hard 换来一整晚的 restash 救援,才觉得这点“麻烦”实在是太便宜了。
6.2 推送、备份和 reflog 过期时间配置
有几个配置和小技巧能给救援留出更长的窗口期。
首先是 reflog 的过期时间。默认情况下,本地仓库的 reflog 记录不会永久保留,超过 gc.reflogExpire 的条目会在 git gc 时被清理。如果你希望给自己留更长的后悔时间:
bash复制git config --global gc.reflogExpire 180.days
git config --global gc.reflogExpireUnreachable 90.days
第一个配置控制“仍然可达的引用记录”保留多久,第二个控制“不可达对象”的 reflog 记录保留多久。把时间调到半年左右,能显著降低误操作后等几天才发现、结果 GC 已经跑过的概率。
其次是异地备份。本地 reflog 救不了“硬盘损坏”和“仓库目录被删”的场景。我现在的习惯是:没有推送到远端的本地分支,每周至少 push 到一个私有的远端引用上一次。哪怕只是 git push origin feature/xxx:backup/feature/xxx 这种带前缀的备份分支,也比裸放在本地强。
6.3 这些操作看起来更安全,实际更容易坑人
最后聊一个反直觉的经验。
很多人以为 git reset --hard 是终极危险操作,于是会倾向用 git revert 来撤销,因为 revert 会创建一个反向提交,历史不回退,看着更“安全”。但在协作场景里,revert 同样有它的坑:如果 revert 之后分支又被合并,容易出现“改动被重新引入”或者冲突叠加的问题。
还有 git checkout . 这个命令,看起来只是“丢弃工作区所有未提交修改”,如果在 IDE 自动执行或者手滑的时候执行,可能把你还没 git add 的新代码全部冲掉,而 Git 对象库里根本没有这些内容,想救都没得救。
我的建议是:
- 想撤销尚未提交的修改,优先用
git restore加明确路径,不要用git checkout .这种全量通配; - 想把某个提交从历史里移除,先想清楚要不要保留这个提交的历史记录,再决定用
reset还是revert; - 永远不要在执行高风险命令前保持终端窗口里只有一个模糊记忆,你应该知道自己在干什么,以及干错了从哪里跑。
这些习惯听起来都很基础,但真正让 Git 急救变成“有惊无险”而不是“当场去世”的,恰恰就是这些基础。现在每次看到同事因为误操作心慌,我都会先让他执行 git status 看看,然后再慢慢问他刚才最后一条带 hash 的输出是什么。绝大多数情况下,Git 都在本地留好了退路。你要做的,只是冷静地把那条退路找出来。
