撤销相关的命令有一堆,reset、restore、revert、checkout、amend,最容易踩坑的地方不是不会用某个命令,而是用错了对象:明明只是想取消一下暂存,结果把工作区的改动也一起删了。
所以选命令之前先问自己一句:这个改动走到哪一步了。
text
改完了,还没 add → git restore <file>
已经 add,还在暂存区 → git restore --staged <file>
已经 commit,但没 push → git reset / git commit --amend
已经 push 了 → git revert(安全)
下面按这个顺序一个个来。
一、还没 add:丢掉工作区的改动
shell
git restore a.txt # 把 a.txt 恢复成暂存区的样子
git restore . # 所有文件
⚠️ 这个操作不可逆,工作区里没 add 的改动会永久消失 。原因和第 1 篇讲的对象模型有关:这些改动连 blob 对象都还没生成,reflog 也不会起作用
老写法是 git checkout -- a.txt,效果一样,但 checkout 身兼数职容易误操作,推荐用 restore
如果只想撤销其中一部分,可以按块来选:
shell
git restore -p a.txt # 交互式,一块一块问要不要还原
那如果改错了想撤销,但这个改动已经进暂存区了呢?
二、已经 add:取消暂存
shell
git restore --staged a.txt # 从暂存区拿出来,工作区的改动保留
关键点在于工作区的内容不受影响,改的东西还在,只是"这次提交不打算带上它"了。
老写法是:
shell
git reset HEAD a.txt # 等价,这里 reset 的语义是"重置暂存区"
这其实就是 git reset 不带模式参数时的默认行为(--mixed)。很多教程直接说"用 git reset 取消 add",但没解释为什么,于是就和后面更危险的用法混在一起了,所以我们先记住这一层。
三、已经 commit,还没 push
这是 reset 的主场。
reset 到底重置了什么
git reset 会动三个东西,用哪个参数决定动其中几个:
| 模式 | 移动 HEAD 和分支指针 | 重置暂存区 | 重置工作区 | 危险程度 |
|---|---|---|---|---|
--soft |
是 | 否 | 否 | 只是撤销提交 |
--mixed(默认) |
是 | 是 | 否 | 改动还在,安全 |
--hard |
是 | 是 | 是 | ⚠️ 改动彻底丢失 |
三种模式从温和到暴力,我们实际跑一遍看差别。仓库里有三个提交,f.txt 的内容分别是 line1、line1 line2、line1 line2 line3:
shell
git log --oneline
text
9c67ceb commit3
b5898a9 commit2
0c06c01 commit1
--soft:只撤销提交
shell
git reset --soft HEAD~1
git status -s
text
M f.txt
M 在第一列 ,说明改动在暂存区 。提交没了,但改动原封不动地待在暂存区里,随时可以重新提交。--soft 的典型用途就是"提交信息写错了,或者少提交了文件,想重新提交一次"。
--mixed:撤销提交和 add
shell
git reset HEAD~1
git status -s
text
M f.txt
M 跑到第二列 了,说明改动退到了工作区,暂存区被清空了,文件内容还在。
这是不带参数时的默认模式,也是最常用的:撤销提交,把改动放回工作区,然后可以重新挑着 add。
--hard:连工作区一起重置
shell
git reset --hard HEAD~1
git log --oneline
text
b5898a9 commit2
0c06c01 commit1
commit3 从历史里没了,f.txt 的内容也退回到了只有 line1 line2,commit3 加的那一行是真的没了。
⚠️ --hard 会丢弃工作区里所有未提交的改动 ,不管有没有 add 过。用之前一定先 git status 看一眼,或者先 git stash 存一份(第 5 篇讲)。
HEAD~1 是什么意思
HEAD~1 是"往前一个提交",几个常见写法:
shell
git reset HEAD~1 # 往前 1 个
git reset HEAD~3 # 往前 3 个
git reset HEAD^ # 等价于 HEAD~1
git reset <具体的哈希> # 直接回到某个提交
git reset --hard 9c67ceb
~ 是沿着父提交往回走,^ 是选第几个父提交(合并提交有两个父),日常用 ~ 就够了。
四、已经 push 了:用 revert
分支已经推送到远程,别人可能已经拉下去了,这时候不能用 reset。
原因是 reset 把分支指针往回挪,等于宣称"这些提交从来没存在过"。但别人手里已经有它们了,我们再推上去还得强推,两边历史就对不上,别人一拉取就乱套。
revert 的做法完全不同:它不删历史,而是产生一个新的提交,用来抵消指定的那个提交。
shell
git revert 9c67ceb
text
[main 7d1e4f2] Revert "commit3"
历史变成了这样:
text
9c67ceb commit3 ← 保留了,没删
b5898a9 commit2
0c06c01 commit1
7d1e4f2 Revert "commit3" ← 新提交,内容上把 commit3 抵消掉
commit3 还在历史里,但它的改动被后面的 Revert 提交撤销了。这样别人拉取的时候只是多了一个普通提交,不会有任何冲突。
两者的对比
reset |
revert |
|
|---|---|---|
| 对历史做了什么 | 删掉提交,指针回退 | 不删,新增一个抵消提交 |
| 哈希会变吗 | 被撤销的提交从分支上消失 | 老提交原样不动 |
| 能对已推送的提交用吗 | 不能(需要强推,会搞乱别人) | 能,安全 |
| 适用场景 | 本地还没推的提交 | 已经推送到公共分支的提交 |
一句话:没推过用 reset,推过了用 revert。
五、改最后一次提交:commit --amend
如果只是想修补最近一次 提交,比如补个文件或者改个提交信息,不用 reset:
shell
git add forgotten.txt
git commit --amend # 把新文件并进上一次提交,提交信息不变
git commit --amend -m "新信息" # 顺便改提交信息
git commit --amend --no-edit # 保持提交信息不变,只合并文件
--amend 的本质是"用当前暂存区的内容重新生成一个提交,替换掉 HEAD"。所以有几点要注意:它也是改写历史 ,哈希会变,已经推送过的提交不要 amend;如果暂存区是空的,--amend 就只是改一下提交信息;想撤销 --amend 同样可以用 reflog 找回之前的提交。
六、reflog:最后的救命稻草
前面说的那些"危险操作",其实没有真的一段代码就凭空消失了,因为 Git 有个引用日志,记录着 HEAD 和分支指针的每一次移动:
shell
git reflog
text
b5898a9 HEAD@{0}: reset: moving to HEAD~1
9c67ceb HEAD@{1}: commit: commit3
b5898a9 HEAD@{2}: commit: commit2
0c06c01 HEAD@{3}: commit: commit1
左边是当时的提交哈希,中间是"第几次移动"(HEAD@{0} 是最近一次),右边是那次移动的原因。
所以 reset --hard 丢掉的提交是可以找回来的。上面这个例子里 commit3(9c67ceb)刚被 reset --hard 干掉,但它还在 reflog 里:
shell
git reset --hard 9c67ceb
git log --oneline
text
9c67ceb commit3 ← 回来了
b5898a9 commit2
0c06c01 commit1
文件内容也跟着回来了。
关于 reflog 有几个边界要说清楚:
- 它只记录本地 的指针移动,所以能救回
reset --hard、rebase、commit --amend、误删分支丢掉的提交- 它救不回从来没 commit 过的东西,因为那连对象都没有
- 它默认保留 90 天(不可达的对象 30 天),
git gc之后可能被回收,所以出事了要尽快找想看某个分支的移动记录用
git reflog show <branch>。删除的分支也能用git reflog找到它最后的哈希,然后git branch <名字> <哈希>就恢复了。
强推的使用边界
有一种情况确实需要强推:我们 rebase 了自己的分支,或者 amend 了提交,本地和远程的历史对不上,Git 不允许普通推送。
shell
git push --force # 有风险
git push --force-with-lease # 推荐
两者的区别在于:--force 是无条件覆盖远程,如果在我们 rebase 期间同事也往这个分支推了新提交,那些提交会被无声地抹掉 ;而 --force-with-lease 在推之前会先检查远程是不是还是我们上次看到的那个状态,如果被别人更新过就直接拒绝。
所以规则是这几条:
- 能用
revert就不要强推,这是对别人最友好的做法 - 确实要强推时,永远用
--force-with-lease而不是--force - 只对自己独占的分支强推 (自己的功能分支、自己的 PR 分支)。公共分支(
main、develop)任何情况下都不该强推,很多平台可以直接配置保护规则禁掉