写在前面
每个用 Git 的人都有那样的时刻:Enter 落下去半秒之后,大脑才反应过来——reset --hard 的对象好像选错了;或者 push 完盯着屏幕,发现 .env 正安静地躺在提交列表里;又或者一段改了两天的工作,在某个“清理命令”之后消失了。误操作不是“会不会发生”的问题,是“什么时候发生”的问题。
这是 Git 三部曲的第三篇:《Git 命令速查手册》负责“查命令”,《深入理解 .git 目录》负责“懂原理”,这一篇负责“救事故”——把最常见的误提交、误删除、误重置,按 事故深度 整理成可以对照执行的补救手册。全文 18 个案例,每个案例都是“事故现场 → 补救步骤 → 每步验证”的完整流程,可以直接照着敲。
Git 几乎所有操作都可逆——但“怎么逆”取决于坏改动走到了哪一层。 区域判断错了,救命的命令就会变成销毁证据的命令:
reset --hard救过无数人,也毁过无数人的下午。
理解了这一点,急救就有了统一方法论:先定位区域,再选命令,每一步都要验证。 下文的章节顺序,就是事故由浅到深的顺序。
一、先定位,再动手:事故深度决定补救姿势
急救的第一步不是敲命令,而是回答一个问题:我的错误改动(或错误操作)渗透到了哪一层? Git 的四个区域,在事故视角下就是四个深度档位:
| |
三个贯穿全文的原则,先立在这里:
- 补救命令大多本身就是危险命令。
reset --hard、rebase、push --force都能救命,也都能毁仓库——区别只在于你是否先做了定位。 - 每一步补救都配一条验证命令。 本文所有案例都遵循“执行 → 验证 → 下一步”的节奏,照着做就不会在中途迷路。
- 深度越深,涉及的人越多。 工作区的事故只关乎你自己;推送之后的事故关乎所有拉过这条分支的人;密钥泄露则关乎整个系统。
二、最浅的事故:改动还在工作区与暂存区
这一层的共同点:大部分改动还没有提交快照。好处是补救零成本;坏处是一旦丢弃,能救回多少取决于它进没进过暂存区——从未 add 过的确实无解,add 过的还有一线生机(见 2.4)。
2.1 案例 1:改错了文件,还没有 add
事故现场:改了几个文件,发现方向完全错了,想丢弃这些改动、回到上次提交的样子。
| |
只想丢弃部分改动、保留部分?用
git restore -p(patch 模式)按块挑选,和git add -p是同一套交互。
2.2 案例 2:add 了不想提交的东西
事故现场:手滑 git add .,把调试文件、临时脚本一起放进了暂存区——但改动本身还要,只是不该现在提交。
| |
注意 restore 与 restore --staged 的分工:前者动工作区,后者动暂存区。后者只是“退出”,什么都不丢。
2.3 案例 3:改动舍不得丢,但马上要切分支
事故现场:功能写了一半,突然要切到别的分支修个紧急 bug——半成品不能提交,也不能丢。
| |
pop 只在恢复成功、没有冲突时才删除该条记录;如果恢复时发生冲突,记录会保留,需要解决冲突后再用 git stash drop 手动清理。apply 则总是保留记录(想多处应用或多一层保险时用)。收起时 务必带 -m 说明 ——三条以上的匿名暂存摆在一起,你不会记得哪条是哪条。
2.4 警告:这一层丢掉的东西,能救回多少取决于进没进过暂存区
| |
要分两种情况:
- 从未
add过的改动:只存在于工作区文件里,Git 没有为它创建任何对象——reflog 里不会有它,垃圾回收也无从谈起,真的无解。 add过又被丢弃的改动:git add的那一刻,内容已经写成了对象库里的 blob;即使后来从暂存区丢弃,在被垃圾回收清掉之前,还可能用git fsck --lost-found找回这些悬空(dangling)对象——输出会写进.git/lost-found/。代价是恢复难度高、原文件名会丢失,只能当最后的碰运气手段。
| |
所以规矩是:不确定要不要丢,就先 stash 或 commit 一版——工作区层的事故,预防远比补救可靠(见第八节)。
三、黄金补救区:已提交、未推送
如果错误已经 commit 但还没 push——恭喜,这是 最好的事故状态:历史完全属于你,随便改,没人知道。这一节的六个案例覆盖了日常 80% 的误操作。
3.1 案例 4:提交信息写错了
事故现场:commit 完一看,信息里有错别字,或者写了个毫无信息量的 “fix bug”。
| |
3.2 案例 5:提交完发现漏了一个文件
事故现场:刚提交完,发现还有一个文件忘了 add,不想为它再开一个碎提交。
| |
3.3 案例 6:提交里混进了不该提交的文件(还没推送)
事故现场:git add . 一把梭,提交完才发现本地配置 config/local.settings.json、.env 之类的文件混了进去。
| |
rm --cached 是这一步的灵魂:只动索引,不动磁盘。如果手滑执行了不带 --cached 的 git rm,文件会被同时从工作区删除——那就先 git restore --staged --worktree <file> 找回,再重来。另外注意:因为同一步把它写进了 .gitignore,普通的 git status --short 不会再显示它——被忽略的未跟踪文件要加 --ignored 才看得见。
3.4 案例 7:整个提交都不要了,但改动想留着
事故现场:刚提交完就后悔——这些改动还没到能提交的状态,想收回继续改。
| |
3.5 案例 8:整个提交连同改动一起不要
事故现场:提交的内容完全错误,改动本身也是垃圾,想彻底回到上一条提交。
| |
⚠️ 这是本文第一个高破坏性命令:已提交的内容通常还能靠 reflog 兜底(见 7.1),但它会 无警告丢弃工作区未提交的改动 ——那部分一旦没
add过就真没了(2.4)。执行前务必完成 ① 的确认:reset --hard最常见的事故就是没看清回退目标。
3.6 案例 9:晚了三个提交才发现,想把它们合并成一个
事故现场:紧张地连点了三个"WIP"“fix"“真正修复”碎提交,推送前想整理成一个完整提交。
| |
reset 三种模式差别一张表看全(更详细的解释见速查手册 4.1):
| 模式 | 工作区 | 暂存区 | 一句话 |
|---|---|---|---|
--soft | 保留 | 保留 | 只退提交,改动原地待命 |
--mixed(默认) | 保留 | 清空 | 退提交 + 退 add,重新挑选 |
--hard | 丢弃 | 丢弃 | 一切归零(危险) |
四、已推送:历史不再只属于你
push 之后,游戏规则变了:远端历史是所有协作者的公共财产,改写它需要付出协调成本。这一节先给一个完整的真实案例(改编自一次实际事故,细节已脱敏),再给两个变体,最后讲清楚力推的安全阀。
4.1 案例 10(主案例):架构文档误进了 feature 分支,已推送
事故现场:在 feature/grpc-gateway 上开发时,把架构评审稿 docs/architecture-notes/ 顺手 git add . 进了 最新提交,并且已经 push 到远端。目标:从这条提交里摘掉文档目录——本地文件保留、其它未提交的代码改动保留、提交信息不变——然后覆盖远端。
完整手术,每一步都可以照敲:
| |
这套手术的原理:commit --amend 定型的是暂存区,不是工作区。 第 ② 步先把暂存区对齐 HEAD(清掉无关内容),第 ④ 步再从索引摘掉目标目录,于是 amend 出的新提交恰好等于“旧提交 − 文档目录”,多一分少一分都没有。三道验证闸门分别防的是:杂物混入(③)、误伤其它文件(⑤)、摘除不彻底(⑦)。
4.2 案例 11:推送后发现提交信息里有敏感内容
事故现场:提交信息里带了内网地址或不该公开的细节,已经推送。
| |
4.3 案例 12:代码推错了分支(推到了共享的 main)
事故现场:本想推到 feature/grpc-gateway,结果推到了 main——而 main 是共享分支,不能改写历史。
| |
共享分支的铁律:用 revert 新增反提交,而不是 reset + 力推。 反提交不动任何人的历史,所有人拉取即可对齐;力推则会让每个已经拉取过 main 的人本地分叉。只有当分支确实只有你一个人用(比如上一节的个人 feature 分支),才轮到“改写 + 力推”方案。
误推的是连续多条提交时,不必一条条 revert:
git revert --no-commit <最早SHA>^..<最新SHA>把整段一起反提交,再git commit -m "revert: 撤销误推到 main 的提交"合成一条。若误推里含 merge 提交,不能按区间直接处理,要用git revert -m <父编号> <merge的SHA>指定沿哪条主线撤销。
4.4 --force 与 --force-with-lease:力推的安全阀
改写历史后的推送必然被远端拒绝(非快进),这时需要力推。两个命令的差别值得单独讲:
| |
两个诚实的提醒:
--force-with-lease防的是“覆盖 你没见过 的更新”。一旦你fetch过,本地的跟踪引用随之更新,lease 检查就会放行——所以正确节奏是“fetch →git log看一眼远端新提交 → 决定要不要 force”,而不是 fetch 完直接无脑力推。- 力推前自问三个问题:这条分支只有我在用吗?有没有开着的 PR?有没有 CI 或别人基于旧提交工作?任何一个是“有”,先协调再动手。
五、更深一层:坏提交在更早的历史里
事故现场升级:有问题的提交不是最新的那条,而是三个提交之前的一条。此时 amend 够不着了,需要 rebase -i(交互式变基)回到过去动手术。
5.1 案例 13:三个提交前混进了配置文件
| |
编辑器里会看到(从旧到新排列):
| |
把目标行的 pick 改成 edit,保存退出。Git 会 停在 那条提交上:
| |
验证要用
--all而不是只看当前分支——如果这份配置也曾进过别的分支,单看 HEAD 会漏判。
5.2 案例 14:中间某个提交整个不要了
| |
rebase 是“换底重放”:被编辑范围之内的提交都会 改写提交 ID(对象与引用的机制见《深入理解 .git 目录》)。因此黄金法则不变——只 rebase 还没推送的提交;已经推送的,要么协调后统一改写,要么退回 revert 方案。
六、彻底抹除:密钥与大文件
前面所有手段的共同局限:只是让最新视图里看不到,并不等于从仓库里消失。 被 amend / rebase / reset 淘汰的旧提交,要么还被 reflog、其它分支、标签或远端跟踪引用牵着,要么只是变成对象库里暂时无人认领的不可达对象、在被清理前一直躺在磁盘上;而所有克隆过的副本,完整保留着旧历史的每一份内容。密钥被推送过一次,就等于已经离开过你的机器。这一节处理最深的两种事故。
6.1 案例 15:API 密钥提交并推送了
处理顺序本身就是知识点:
| |
为什么轮换优先于清理:密钥进入远端的那一秒就可能被抓取(爬虫、fork、CI 缓存、别人的 fetch)。事后无论 Git 手术做多干净,都改变不了“它曾经公开存在过”这个事实。清理只是止损的收尾,不是止血;轮换及时且充分时,历史清理甚至可以量力而行——GitHub 官方也是这个口径。
抹除使用 git filter-repo(官方推荐的现代工具,取代了慢且易错的 filter-branch)。它不是 Git 核心自带的工具,需要单独安装(安装后通过 git filter-repo 外部子命令的方式调用):
| |
四个容易踩的细节:
- 文件改过路径的,每条历史路径都要列:文件曾被移动或重命名,就要为每个旧路径补一个
--path(或分两次执行),否则旧路径上的密钥仍留在历史里。 refs/pull/推送失败是预期行为:GitHub 把 PR 引用设为只读;若还有其它引用推送失败,多半是分支保护拦住了力推,需临时关闭后重推。- 力推清不掉的部分要找 Support:PR 引用与 GitHub 的缓存视图不随力推消失,需带仓库信息、受影响 PR 数量和 filter-repo 输出的 First Changed Commit 联系 GitHub Support 清理;fork 里的副本要联系 fork 所有者处理。
- 协作者必须变基(rebase),不能合并(merge):一次 merge 就可能把刚清除的污染历史原样带回来。最省事、最不易复发的做法是丢弃旧克隆、重新克隆。
6.2 案例 16:大文件让仓库膨胀了几百 MB
事故现场:早期误提交了视频、数据集或 node_modules,仓库越来越大,clone 越来越慢。
| |
注意:
filter-repo会改写 全部相关提交的 ID,这是自案例 13 以来“改写历史”的极端形态——协作成本最高,值得为它单独发一次全员通知。另外,托管平台(如 GitHub)可能仍缓存旧对象,重要密钥泄露后可联系平台支持清理缓存视图。
七、最后的底牌:reflog
到目前为止的所有“不可逆”,其实都还有一个兜底:reflog(reference log,引用日志)——Git 为本地引用自动记录的更新日志,HEAD、每个本地分支、stash 各自都有一份(不带参数的 git reflog 显示的就是 HEAD 的那份,下文示例用的都是它)。reset --hard、rebase、branch -D 都只是让提交“不再被分支指向”,对象本身还躺在对象库里,reflog 里的记录还能找到它们。
想理解 reflog 为什么存在、记录在 .git 的哪里,见《深入理解 .git 目录》;这一节专注“怎么用它救命”。
7.1 案例 17:reset --hard 错杀了想要的提交
事故现场:本想丢弃垃圾改动执行了 git reset --hard HEAD~1,退完才发现——那条提交里有没保存的工作。
| |
| |
| |
想更稳妥,用
git branch rescue e4f5g6h把它标记成一个新分支——先保住现场,再慢慢决定怎么处置,比直接再reset --hard来回跳安全得多。
7.2 案例 18:误删了还在开发的分支
事故现场:手滑 git branch -D feature/login,删完想起里面有三条没合并的提交。
| |
定位 SHA 的方法:git reflog 默认最新记录在最上面、越往下越早。先找到 checkout: moving from feature/login to … 这一行——注意这行本身的 SHA 是离开后的目标分支(如 main),不是 feature/login 的尖端;分支尖端是紧挨它下方的那条更早记录(也就是 HEAD@{n+1}),即切换前 HEAD 所在的提交——那条记录是 commit:、merge: 还是别的类型都无所谓。翻不到的话,退一步用 git fsck --lost-found 找悬空提交(思路同 2.4)。
7.3 reflog 的三条边界
- 它是本地的。 记录的是这台机器上的引用更新,clone 时不会带过来;而且删除分支时,该分支自己的那份 reflog 会随之删除——所以案例 18 只能借 HEAD 的日志找人。
- 它有期限。 默认可达记录保留 90 天、不可达记录 30 天,
git gc过期清理——所以救场要趁早。 - 它只认有对象的东西。 从未
add过的工作区改动连对象都没有,reflog 和fsck都无能为力;add过再丢弃的还有fsck --lost-found一线生机(回扣 2.4);stash清空后的记录一旦过期同样无处可寻。
八、预防:最好的急救是不用急救
.gitignore第一天就配齐。 新仓库落地的第一件事不是写代码,是把bin/、obj/、node_modules/、.env、*.local.*这些“未来的事故”挡在门外。经验法则:git status里长期挂着某个??,它早晚会混进某次git add .。- 提交前预览,是性价比最高的仪式。
git add之后、commit之前,花五秒看一眼git diff --cached --stat——本文一半以上的事故(混入文件、漏掉文件)都能在这一眼里拦住。 - pre-commit 钩子做自动安检。 用 pre-commit 框架挂 gitleaks 扫密钥、挂大小检查拦大文件,把“人肉记得”变成“机器记得”。
- 推送想一秒再敲。 推送是发布:本地历史怎么改都是你自己的事,推送之后就进入公共领域。分支名、目标远端、提交内容,过一眼再回车(
git push之前git log origin/<分支>..HEAD --oneline看看将要推出去什么)。
九、急救决策表
把全文收拢成一张表。用法:先在“症状”列对号入座,再执行对应补救,危险级高的先回看对应章节。
| 症状 | 所在区域 | 首选补救 | 危险级 | 章节 |
|---|---|---|---|---|
改错文件,还没 add | 工作区 | git restore <file> | ★ | 2.1 |
add 了不想提交的 | 暂存区 | git restore --staged <file> | ★ | 2.2 |
| 想暂时收起改动切分支 | 工作区 | git stash push -u -m | ★ | 2.3 |
| 提交信息写错(未推送) | 本地仓库 | git commit --amend -m | ★ | 3.1 |
| 提交漏了 / 多了文件(未推送) | 本地仓库 | 补 add 后 --amend --no-edit | ★ | 3.2 / 3.3 |
| 整个提交不要,改动保留 | 本地仓库 | git reset --soft HEAD~1 | ★ | 3.4 |
| 提交连同改动全部不要 | 本地仓库 | git reset --hard HEAD~1 | ★★★ | 3.5 |
| 多条碎提交合成一条 | 本地仓库 | git reset --soft HEAD~N 后重新提交 | ★★ | 3.6 |
| 误提交文件且已推送(个人分支) | 远端 | rm --cached + amend + --force-with-lease | ★★★ | 4.1 |
| 敏感信息在提交信息里且已推送 | 远端 | amend -m + --force-with-lease | ★★ | 4.2 |
误推到共享分支(如 main) | 远端 | git revert 反提交,不改写历史 | ★ | 4.3 |
| 坏提交在更早历史(未推送) | 本地历史 | git rebase -i(edit / drop) | ★★★ | 五 |
| 密钥已提交并推送 | 历史对象 | 先轮换密钥,再 filter-repo | ★★★ | 6.1 |
| 大文件撑爆仓库 | 历史对象 | git filter-repo --analyze 后按路径抹除 | ★★★ | 6.2 |
reset --hard 错杀了提交 | 对象库 | git reflog 找 SHA 回退 / 建分支 | ★ | 7.1 |
| 误删了分支 | 对象库 | git reflog + git branch <名> <SHA> | ★ | 7.2 |
| 未提交的改动被丢弃 | 无快照 | 从未 add:无解;add 过:git fsck --lost-found 碰运气 | ★★★ | 2.4 |
十、小结
- 改动还在工作区与暂存区时,补救零成本,但丢弃后能救回多少取决于有没有进过对象库——
restore/restore --staged/stash各管一段,reset --hard不要当清扫工具。 - 已提交未推送是黄金补救区:
amend改一条、reset --soft退一批,历史随便重写,因为只有你看得到。 - 已推送就进入了公共领域:个人分支用“改写 +
--force-with-lease”,共享分支用revert反提交;手术的核心是“清空手术台(restore --staged)→ 精确摘除(rm --cached)→ 三道验证 → amend → 力推”。 - 更深的事故交给
rebase -i(回到过去动手术)和filter-repo(彻底抹除),密钥泄露永远先轮换再清理。 - reflog 是最后的底牌——本地的 HEAD 移动日志,能在记录过期、对象被清理前给错杀的提交第二次机会;不可达记录默认约 30 天过期,事故后要立即处理。从未
add过的改动,它也无能为力。
| |
下次手滑时,先停一秒,回到第一节的分诊图:确认改动走到了哪一层,再挑那一层的刀——你会发现 Git 的“可撤销”,比传说中靠谱得多。