Skip to content
This page has been auto-translated and may contain errors.View in English

撤销 Git 中的改动和提交 ​

docs.scrimba.com

总有需要撤销的时候。你可能敲了一段不想要的改动,试了个反而把事情弄得更糟的修复方案,或者提交了不想让任何人看到的东西。Git 几乎记录了你项目曾经存在过的每一个版本,所以大多数错误都比它们看起来更容易挽回。用哪个工具补救,取决于这个错误走到了哪一步:还留在编辑器里、已经暂存、已经提交,还是已经推送到了队友能看见的地方。本章就按这个顺序依次讲解。

push 和 pull 是什么?

push 会把你的提交发送到一个队友们也在使用的共享仓库副本,pull 则会把队友的提交拉取到你本地。远程仓库与 GitHub 会完整讲解这两者;在本章里,"已推送"指的是这个提交已经离开了你的机器。

在提交之前丢弃一处改动 ​

假设你还在 提交循环 那一节里改 weather-app 中的 styles.css 样式,结果发现新布局是个糟糕的主意。你还没有暂存它,也没有提交它。你只想把文件恢复成上一次保存时的样子,别的什么都不要。专门干这件事的命令就是 git restore:

bash
$ git status
On branch main
Changes not staged for commit:
        modified:   styles.css

$ git restore styles.css

$ git status
On branch main
nothing to commit, working tree clean

git restore <file> 会丢弃一处尚未提交的编辑,把文件恢复成最后一次保存时的状态。如果你在改变主意之前已经用 git add 暂存了这处改动,单独的 git restore 就碰不到它了,因为这个文件现在待在暂存区,而不只是在你的工作目录里。这时得先把它移出暂存区,再恢复它:

bash
$ git add styles.css
$ git restore --staged styles.css
$ git restore styles.css

两个不同的操作对应两个不同的区域:git restore --staged 把文件从暂存区挪回去,单独的 git restore 则会丢弃工作目录里的编辑。git restore 只能触及尚未提交的工作,一旦改动被提交,就要换别的命令上场了。

老一点的教程和脚本里会用 git checkout -- <file> 来做 git restore <file> 现在做的事情。checkout 过去身兼两职——既能"切换分支",又能"恢复文件",这种混用相当容易让人搞混,所以 Git 把它拆成了 switch 和 restore 两个命令。你在旧资料里还是会碰到 checkout,认出它就好,但自己写东西时请一律用 restore。你还可以用 git restore . 一次性丢弃当前文件夹里所有未暂存的改动,运行前值得停下来想一想,因为它会一口气清空所有文件。

restore 的撤销能力,止步于工作目录这一层。提交是一个会被保存下来的对象,会一直存在,直到 Git 的垃圾回收把它清除掉——这也是为什么即便执行了硬重置,一个糟糕的提交也还能通过 reflog 找回来。而一处未提交的编辑压根就没有被变成过一个保存下来的对象:restore 只是把文件上一次保存的版本检出,覆盖掉你的编辑,这里没有任何更早的状态被存放在什么地方等你挖出来。这就是"尽早提交、频繁提交"在实践中的意义。一个乱糟糟的提交几乎总能修好;一处从未保存过的编辑,通常修不好。

Juno丢弃一处未提交的改动git restore <file> 会丢弃一处你还没提交的编辑,把文件恢复成最后一次的样子。如果你已经用 git add 暂存了这个改动,先用 git restore --staged <file> 取消暂存,如果也想连编辑一起去掉,再恢复文件。这只对还没提交的改动有效,救不回你已经保存过的失误。
Juno丢弃一处未提交的改动git restore <file> 是 git checkout -- <file> 的现代替代品,git restore . 会一次性清空文件夹里所有未暂存的改动,所以先跑一遍 git status 看看这会动到什么。你在旧回答和旧脚本里还是会看到 checkout 干这活儿,其实是同一个思路,只是套了个更老、身兼多职的名字。
Juno丢弃一处未提交的改动restore 永远不会创建或触碰任何提交,它只是用文件最后一次保存的版本覆盖你的工作目录。这意味着一旦你判断错了,它不会留下任何可以恢复的痕迹,这一点和本章里几乎所有其他命令都不一样。小步提交、频繁提交,这样"糟了"就会变成一个 reset 或 reflog 能解决的问题,而不是一处从来没有存档点可以回退的改动。

用 git stash 把工作暂时放到一边 ​

有时候并没有什么错误要修,只是时机不凑巧。你正在改 styles.css 改到一半,队友现在就需要你切到另一个分支,可你还没准备好把改了一半的样式提交上去。git stash 会把你的改动收进一个"储藏室",交还给你一个干净的工作目录,让你可以先去忙别的事,之后再回来。

bash
$ git status
On branch main
Changes not staged for commit:
        modified:   styles.css

$ git stash
Saved working directory and index state WIP on main: 8f3c7d1 Cache forecast responses in localStorage

$ git status
On branch main
nothing to commit, working tree clean

对 styles.css 的编辑并没有消失,只是被存放起来了。等你准备好了,用 git stash pop 把它取回来:

bash
$ git stash pop
On branch main
Changes not staged for commit:
        modified:   styles.css

git stash 是给那些你还没准备好提交的改动用的,它绝对不会在你的分支上产生任何提交。

一个储藏很少能装下一整周真实的工作量,所以还得了解工具箱里其余的部分。git stash list 会显示你储藏过的一切,最新的排在最前面。git stash pop 会重新应用最上面那一条并把它从列表里移除;git stash apply 也会重新应用,但会把它留在列表里,如果你想把同一份储藏改动用在两个分支上,这就很有用。存的时候顺手起个名字,免得以后 list 是一堆没有标签的条目:

bash
$ git stash push -m "wip: forecast card layout"
$ git stash list
stash@{0}: On main: wip: forecast card layout

储藏和 Git 里其他一切东西一样,都是用同一套提交机制搭建出来的。git stash 会把你已暂存和未暂存的状态各自存成一个提交对象,再让一个特殊的引用 refs/stash 指向它们,这也是为什么 git stash list 读起来就像是它自己的一小段历史。reflog 靠的也是同一个技巧:一个指向若干个提交、但没有任何分支能到达它们的引用。

Juno把工作暂时放到一边git stash 会把你还没准备好提交的改动收起来,让工作目录变干净,之后用 git stash pop 再取回来。既不会产生提交,也不会丢失任何东西,改动只是被暂时存放着而已。每次队友需要我立刻切到另一个分支时,我都会用这一招。
Juno把工作暂时放到一边git stash list 会显示所有储藏过的改动,pop 重新应用并移除最新的那一条,apply 重新应用但不移除。存的时候就用 push -m 起个名字,一周之后一堆没有标签的储藏列表,谁看了都头疼,包括你自己。
Juno把工作暂时放到一边 储藏其实是几个 refs/stash 指向的提交对象,游离在任何分支之外,这也是它表现得像自己的一段小历史的原因。一旦你这样理解它,储藏就不再显得神秘,它不过是同一套对象模型又变了一次戏法而已。

用 git revert 撤销一个提交 ​

假设提交 8f3c7d1,也就是 "Cache forecast responses in localStorage",被发现是错的,而且它已经被推送出去了,队友也已经把它拉取下来了。如果现在改写这个提交,就会改变别人已经拥有的东西,等他们下次拉取时,他们的副本和你的就会对不上。git revert 能在不改变任何已有内容的前提下解决这个问题:它会创建一个全新的提交,把原来的改动反着做一遍。

bash
$ git revert 8f3c7d1
[main 2b6f0d1] Revert "Cache forecast responses in localStorage"

历史记录里会同时保留这两个提交,原来那个和撤销它的那个,按顺序排列。没有人的副本需要被改动过的历史"重塑",队友只需要像拉取任何其他提交一样,拉取这个新的 revert 提交。

git revert 对别人已经拥有的提交永远是安全的,因为它是新增一个提交,而不是改动一个旧提交。

如果撤销的改动和后面的提交有重叠,可能会产生冲突,和 合并与冲突 里讲的是同一种情况:Git 会用 <<<<<<<、=======、>>>>>>> 标出文件,等你去解决。修好这些标记,git add 这个文件,再用 git revert --continue 收尾。

bash
$ git revert 8f3c7d1
Auto-merging src/index.js
CONFLICT (content): Merge conflict in src/index.js

$ git add src/index.js
$ git revert --continue

revert 在共享历史上之所以安全,背后的道理和"永远不要在公共分支上做 rebase"是一样的:改写一个别人已经拉取过的提交,会让他们的副本和你的对不上,之后总得有人强行推送来越过这个错位。revert 让每一个已有的提交都原封不动,它只是新增一个提交,所以没有人的副本需要跟你的做协调。

Juno用 revert 撤销一个提交git revert <commit> 撤销一个提交的方式,是新增一个反向的提交,而不是抹掉原来那个。这让它成为一旦提交已经和别人共享之后的安全选择,因为任何已经保存下来的东西都不会被改动。历史记录会同时留下这个错误和修复它的记录,用 Git 用得越久,我越喜欢这一点。
Juno用 revert 撤销一个提交 如果后面的提交动过同样的代码行,revert 也会像合并一样产生冲突,解决方式一样:修好标记,add 这个文件,再执行 git revert --continue。只要你要撤销的提交已经被推送或共享出去,就用它。
Juno用 revert 撤销一个提交 revert 从不触碰任何已有的提交,所以它是那个能和别人正在拉取的分支和平共处的撤销工具。一旦某个糟糕的提交离开了你自己的机器,就把它当作默认选择。

用 git reset 倒回去:soft、mixed 和 hard ​

撤销一个提交还有第三种办法,git reset,它的做法和 revert 不一样:它不是新增一个提交来抵消旧的,而是把你当前分支直接指向另一个提交。这让它在处理还没有人见过的提交时又快又干净,但在处理别人已经见过的提交时就有风险。

reset 是前面 restore 和 revert 的"表亲",更强大,也更容易用错。在日常大多数修复场景里你用不到它,restore、revert 和 stash 已经覆盖了绝大多数"救命,把这个撤销一下"的时刻。不过你会在别人的操作说明里见到它,所以先记住要点:reset 会把你的分支往历史里倒回去,它的其中一种模式 --hard,会不加任何确认地丢弃未提交的工作。如果你从哪儿抄来的命令里带了 git reset --hard,运行之前先看清楚它到底会做什么。

git reset <commit> 会把你的分支指向那个提交,而 --soft、--mixed、--hard 决定了还有多少别的东西会跟着一起移动。想象三层结构:提交历史,也就是分支指针所在的位置;暂存区,也就是排队等着提交的内容;还有工作目录,也就是磁盘上的文件。下面这些短哈希的读法和 历史记录 一节里 git log --oneline 讲的一样,目标 HEAD~1 的意思是"HEAD 之前的一个提交",是表示"撤销最新一个提交"的标准写法。

bash
$ git log --oneline
8f3c7d1 Cache forecast responses in localStorage
4f2a1c9 Fix temperature conversion in Celsius-to-Fahrenheit formula
5f3d8b2 Add five-day forecast to the dashboard
1a2b3c4 Set up project structure

$ git reset --soft HEAD~1

--soft 把分支指针往回移一个提交,然后就停在那儿。暂存区和工作目录都不受影响,所以被撤销的那个提交里的所有内容都变成暂存状态,随时可以按你喜欢的方式重新提交。

bash
$ git reset --mixed HEAD~1

如果不加任何参数,运行的就是 --mixed。它把分支指针往回移,同时也清空暂存区,所以被撤销提交里的改动会变回工作目录里未暂存的编辑。什么都不会丢,提交之前再重新 git add 一次就行。

bash
$ git reset --hard HEAD~1

git reset --hard 会把分支指针往回移,并把工作目录也覆盖成对应的样子,所以未提交的工作和被撤销的那个提交会在这一步里一起从视野中消失,而且不会有任何提示问你是否确定。这是运行前一定要三思的模式。

这也解释了在 提交循环 那一节里 git commit --amend 到底在做什么:amend 差不多就是先做一次 reset --soft HEAD~1,紧接着立刻新建一个提交,这也是为什么它会替换掉你上一个提交,而不是堆在它上面。

reset 做的事情始终只是移动指针,在 --hard 模式下再顺带让工作目录跟着匹配一下。它从来不会真正删除某个提交对象。被 reset --hard 甩在后面的那个提交,仍然待在 Git 的对象数据库里,只是没有任何分支能到达它——这一点很关键,因为还有办法能重新找到它:接下来要讲的 git reflog。不过在共享分支上,把分支往回移再推送,就意味着要强制推送、覆盖掉队友已经拥有的提交,他们下次拉取时就会和被重写过的历史对不上。这也是为什么 reset 更适合当作本地清理工具,而 revert 才是用来处理任何已经离开你机器的东西的实际原因。

Juno用 git reset 倒回去 日常大多数修复场景里你用不到 git reset,restore、revert 和 stash 已经覆盖了几乎一切。不过还是要知道它的大致样子:reset 会把你的分支挪回到更早的一个提交,它的 --hard 模式会毫无确认地清空你的工作目录。凡是你抄来的命令里带了 reset --hard,都要格外小心对待。
Juno用 git reset 倒回去git reset --soft 会让被撤销提交里的改动保持暂存状态,--mixed(默认模式)会取消暂存但保留编辑,--hard 则会把编辑彻底丢掉。commit --amend 基本上就是一次 soft reset 紧接着立刻新建一个提交,这就是为什么它会替换上一个提交,而不是堆在它上面。
Juno用 git reset 倒回去 reset 只移动指针,加上 --hard 时再让你的工作目录跟着匹配,它从来不会删除提交对象本身。那个对象会一直留在数据库里,直到 reflog 记录过期、垃圾回收把它清理掉为止。把重写过的分支推送出去,队友下次拉取就会和不再匹配的历史撞在一起。

该选 revert 还是 reset ​

决定用哪个工具的判断标准是:这个提交有没有被别人拉取过?如果有,用 revert。如果这个提交只存在于你自己的机器上,在一个你还没推送过的分支里,那么用 reset 没问题,往往还更干净利落。拿不准的时候,revert 永远是安全的选择,因为它在两种情况下都能用。

"有没有被人拉取过",实质上问的是"这个提交能不能通过别人拥有的某个引用到达",实际操作中就相当于它是否位于一个已经被推送过的分支上。一个你五分钟前刚建的、从没推送过的本地分支上的提交,是 reset 可以随意处置的地盘。一旦你把它推送出去,就要把它当作共享内容对待:在本地重置它再强制推送,就意味着要先和所有会碰这个分支的人协调好。

Juno该选 revert 还是 reset 撤销一个提交之前先问自己一个问题:有没有别人已经拉取过它?如果有,就用 revert,它永远安全。如果这个提交只存在于你自己的机器上,用 reset 也没问题。拿不准的时候,revert 是更安全的默认选择。
Juno该选 revert 还是 reset 一旦一个提交被推送出去、可能已经有人拿到了,就用 revert。只要它还完全是你本地的东西,reset 就没问题,往往还更干净利落,因为它不会在日志里留下一条"撤销了一次撤销"的记录。
Juno该选 revert 还是 reset 真正的判断标准是可达性:这个提交是否位于别人已经拉取过的某个引用上?本地且未推送,reset 随便用。已推送且共享,重置它之前要先协调好,因为后续的强制推送会和队友已经拥有的内容撞在一起。

用 git reflog 找回一个提交 ​

一次 reset --hard、一次搞砸的 rebase,或者不小心删掉了一个分支,看起来都像是某个提交彻底消失了。但通常并没有。Git 在本地保留了一份日志,记录着你的 HEAD 和各个分支曾经指向过的每一个地方,叫做 reflog,这份日志几乎总能带你找回一个看起来已经丢失的提交。

你大概率不会经常用到这个,但先记住要点:Git 很少会在东西看起来已经消失的那一刻,就真的把它扔掉。如果你哪次运行了 reset --hard,事后才发现那个提交其实还需要,多半是有办法找回来的,等你准备好深入了解的时候再看这一部分。

简单来说:git reflog 会列出 HEAD 最近所在的各个位置,每个都带一个短哈希,你可以对其中任何一个哈希执行 git reset --hard,回到那个确切的状态。它读起来就像是第二份历史记录:HEAD 曾经站过的每一个地方,包括你的分支现在已经不再指向的那些提交。

一个不再有任何分支、标签或 HEAD 指向它的提交,就是 不可达(unreachable) 的:它漂在仓库里,仍然被保存着,只是少了一个能带你回到它那儿的标签。reflog 是一份按时间顺序排列的清单,记录着 HEAD 在你机器上移动过的每一个地方——这次分支切换、那次重置、那次 rebase,每一条都用一个像 HEAD@{2} 这样的短引用来标记。找到错误发生前一刻的那条记录,把一个分支重新指向它:

bash
$ git reflog
2b6f0d1 HEAD@{0}: reset: moving to HEAD~1
8f3c7d1 HEAD@{1}: commit: Cache forecast responses in localStorage
4f2a1c9 HEAD@{2}: commit: Fix temperature conversion in Celsius-to-Fahrenheit formula

$ git reset --hard 8f3c7d1

那个曾经看似被 reset --hard 抹掉的提交,就这样恢复了。这里有两点局限值得了解。第一,reflog 只存在于你自己的机器上,push 和 pull 都碰不到它,所以它救不回队友在他们自己机器上丢失的提交。第二,它不会永远保留:Git 最终会运行垃圾回收,清理掉那些超过默认几周期限、已经不可达的提交,所以这张安全网覆盖的是近期的失误,而不是整个项目从头到尾的历史。

Juno找回一个丢失的提交 如果你哪次运行了 reset --hard,事后才发现那个提交其实还需要,那它多半还能找回来。Git 在本地保留了一份叫 reflog 的记录,记着你的工作曾经指向过的每一个地方,通常靠它就能把一个看起来已经消失的提交找回来。这部分内容属于 Deep Dive,但知道有这么个保险,会让人安心不少。
Juno找回一个丢失的提交git reflog 会列出 HEAD 最近所在的位置,带着可以拿来做 reset --hard 的短哈希,所以一次走得太远的重置通常都能找回来。不过它只存在于你自己的机器上,救不回队友在他们那台机器上丢失的提交。
Juno找回一个丢失的提交 reflog 是一份本地的、按时间顺序记录 HEAD 曾经站过的每一个地方的清单,这就是为什么一个看似被 reset --hard 抹掉的提交,只要把分支指向正确的 HEAD@{n} 条目,就能重新出现。它最终会随着垃圾回收过期,而且只覆盖你自己近期的失误。队友丢了提交的话,得在他们自己的机器上跑一遍同样的恢复流程。