撤销由两个问题决定
一句话总结
选择回退方法取决于两个问题:要回退的内容位于哪里(工作树 / 索引 / 提交),以及该提交是否已经传给其他人。
为什么需要它
Git 回退命令之所以令人觉得困难,不是因为命令太多,而是因为从未学过选择命令的标准。第一个问题决定命令类型,第二个问题决定是否可以改写历史。
还可以记住一个令人安心的原则:曾经提交过的内容几乎总能恢复,从未提交过的内容则几乎无法恢复。因此,真正危险的时刻不是“删除提交”,而是“覆盖未提交的修改”。
它如何运作
reset 的三种模式具有不同的回退范围。
| 模式 | 回退范围 | 会丢失什么 | 使用场景 |
|---|---|---|---|
--soft |
只移动分支引用 | 无 | 把多个提交重新合并为一个提交 |
--mixed(默认) |
回退到索引 | 暂存状态 | 希望重新选择暂存内容时 |
--hard |
同时覆盖索引和工作树 | 所有未提交的修改 | 完全丢弃本地实验时 |
--keep |
回退到索引,但存在冲突的本地修改时中止 | 无 | 希望在覆盖前被拒绝时 |
风险等级不取决于命令名称,而取决于“该命令是否会覆盖未提交的修改”。
如果提交已经存在于其他人的仓库中,任何删除该提交的方法都会破坏他人的历史。正确答案不是修改历史,而是追加历史,因此要使用 revert。如果通过强制推送删除提交,已经拉取过它的人仍会保留本地历史;他们下一次推送时,被删除的提交还可能重新出现。
对合并执行 revert 还有一个陷阱。revert 合并后,即使修复分支再重新合并,先前撤销的修改也不会回来,因为 Git 只根据历史可达性判断是否已经合并。正确做法是再次 revert 那次 revert。
生产现场中的表现
使用 git reset --hard 删除两个提交后脸色发白,是很常见的场景。通常仍然可以恢复:打开 git reflog,回到 HEAD@{1} 即可。但需要知道两点。删除分支时,该分支的引用日志也会一并删除(HEAD 的 reflog 中仍会保留);默认过期时间则是可达对象 90 天、不可达对象 30 天。
还有会立即移除安全网的命令:git reflog expire --expire=now --all 和 git gc --prune=now。如果团队把它们称为“清理”,并习惯性执行,事故发生时就不会留下恢复手段。
真正的最后手段,是通过 git fsck --full --unreachable --no-reflogs 查找游离对象,再使用 git cat-file -p <blob> > 파일 把内容取出。即使只做过暂存,内容也已经成为对象,因此可以用这种方法恢复。
不同场景应该使用什么
把前面两个问题应用到实际场景,就得到下面的表。紧急情况下,只需查看此表,不必再纠结命令选择。
| 场景 | 应使用的命令 | 注意事项 |
|---|---|---|
| 刚刚写错提交消息 | commit --amend |
已经推送时不要使用 |
| 刚刚的提交漏掉一个文件 | 添加后执行 commit --amend --no-edit |
与上一项相同 |
| 希望把三个提交合并为一个 | 执行 reset --soft HEAD~3 后重新提交 |
修改内容会完整保留 |
| 只想取消暂存 | restore --staged <파일> |
不会改动文件内容 |
| 把一个文件恢复到最后一次提交状态 | restore <파일> |
未提交的修改会消失 |
| 取消已经推送的提交 | revert <커밋> |
会产生新提交并保留历史 |
| 完全丢弃本地实验 | reset --hard |
唯一真正无法回退的位置 |
| 误删后恢复 | 通过 reflog 找到记录,再执行 reset --hard <해시> |
通常在 30~90 天内仍可恢复 |
表中真正危险的只有两行:restore <파일> 和 reset --hard。二者都会覆盖从未提交过的修改,而这些修改没有记录在任何地方,reflog 也无法恢复。因此建议养成一个习惯:**不确定是否应该丢弃时,先提交,或用 git stash 暂存起来。**二者都会创建对象,从那一刻起就有恢复方法。
关于 stash 还要补充一点。默认行为不会暂存未跟踪的新文件,因此新建文件会继续留在工作树中,并混入下一项工作。要一起清理这些文件,需要加上 -u。不了解这一点,就会长时间困惑于“明明 stash 了,为什么工作树仍不干净”。
下一项实验要做什么
由于需要创建同一仓库的多个副本,首先会编写一个批量生成仓库的脚本。随后在不同仓库中分别执行 amend、reset 的三种模式、revert 和 reflog 恢复,直观比较结果差异,最后整理一份按场景分类的速查表。