6篇文章讲清楚Git:Git 撤销与恢复:reset、revert、restore 应该怎么选?(4/6)

0 阅读7分钟

撤销相关的命令有一堆,reset、restore、revert、checkout、amend,最容易踩坑的地方不是不会用某个命令,而是用错了对象:明明只是想取消一下暂存,结果把工作区的改动也一起删了。

所以选命令之前先问自己一句:这个改动走到哪一步了。

改完了,还没 add          → git restore <file>
已经 add,还在暂存区       → git restore --staged <file>
已经 commit,但没 push     → git reset / git commit --amend
已经 push 了              → git revert(安全)

下面按这个顺序一个个来。

一、还没 add:丢掉工作区的改动

git restore a.txt              # 把 a.txt 恢复成暂存区的样子
git restore .                  # 所有文件

⚠️ 这个操作不可逆,工作区里没 add 的改动会永久消失。原因和第 1 篇讲的对象模型有关:这些改动连 blob 对象都还没生成,reflog 也不会起作用

老写法是 git checkout -- a.txt,效果一样,但 checkout 身兼数职容易误操作,推荐用 restore

如果只想撤销其中一部分,可以按块来选:

git restore -p a.txt           # 交互式,一块一块问要不要还原

那如果改错了想撤销,但这个改动已经进暂存区了呢?

二、已经 add:取消暂存

git restore --staged a.txt     # 从暂存区拿出来,工作区的改动保留

关键点在于工作区的内容不受影响,改的东西还在,只是"这次提交不打算带上它"了。

老写法是:

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:

git log --oneline
9c67ceb commit3
b5898a9 commit2
0c06c01 commit1

--soft:只撤销提交

git reset --soft HEAD~1
git status -s
M  f.txt

M 在第一列,说明改动在暂存区。提交没了,但改动原封不动地待在暂存区里,随时可以重新提交。--soft 的典型用途就是"提交信息写错了,或者少提交了文件,想重新提交一次"。

--mixed:撤销提交和 add

git reset HEAD~1
git status -s
 M f.txt

M 跑到第二列了,说明改动退到了工作区,暂存区被清空了,文件内容还在。

这是不带参数时的默认模式,也是最常用的:撤销提交,把改动放回工作区,然后可以重新挑着 add。

--hard:连工作区一起重置

git reset --hard HEAD~1
git log --oneline
b5898a9 commit2
0c06c01 commit1

commit3 从历史里没了,f.txt 的内容也退回到了只有 line1 line2,commit3 加的那一行是真的没了。

⚠️ --hard 会丢弃工作区里所有未提交的改动,不管有没有 add 过。用之前一定先 git status 看一眼,或者先 git stash 存一份(第 5 篇讲)。

HEAD~1 是什么意思

HEAD~1 是"往前一个提交",几个常见写法:

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 的做法完全不同:它不删历史,而是产生一个新的提交,用来抵消指定的那个提交。

git revert 9c67ceb
[main 7d1e4f2] Revert "commit3"

历史变成了这样:

9c67ceb commit3     ← 保留了,没删
b5898a9 commit2
0c06c01 commit1
7d1e4f2 Revert "commit3"    ← 新提交,内容上把 commit3 抵消掉

commit3 还在历史里,但它的改动被后面的 Revert 提交撤销了。这样别人拉取的时候只是多了一个普通提交,不会有任何冲突。

两者的对比

resetrevert
对历史做了什么删掉提交,指针回退不删,新增一个抵消提交
哈希会变吗被撤销的提交从分支上消失老提交原样不动
能对已推送的提交用吗不能(需要强推,会搞乱别人)能,安全
适用场景本地还没推的提交已经推送到公共分支的提交

一句话:没推过用 reset,推过了用 revert。

五、改最后一次提交:commit --amend

如果只是想修补最近一次提交,比如补个文件或者改个提交信息,不用 reset:

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 和分支指针的每一次移动:

git reflog
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 里:

git reset --hard 9c67ceb
git log --oneline
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 不允许普通推送。

git push --force              # 有风险
git push --force-with-lease   # 推荐

两者的区别在于:--force 是无条件覆盖远程,如果在我们 rebase 期间同事也往这个分支推了新提交,那些提交会被无声地抹掉;而 --force-with-lease 在推之前会先检查远程是不是还是我们上次看到的那个状态,如果被别人更新过就直接拒绝。

所以规则是这几条:

  1. 能用 revert 就不要强推,这是对别人最友好的做法
  2. 确实要强推时,永远用 --force-with-lease 而不是 --force
  3. 只对自己独占的分支强推(自己的功能分支、自己的 PR 分支)。公共分支(main、develop)任何情况下都不该强推,很多平台可以直接配置保护规则禁掉