How to undo (almost) anything with Git
任何版本控制系统中最有用的功能之一就是能够“撤销(undo)”你之前的错误。在Git中“undo”功能可能因为场景的不同而有些许的差异。
当你进行一个新的提交时,Git会保存你在这个特定时间点的快照到本地的仓库中,之后,你可以通过Git来回到你早期的某个版本。
我们来先看看一些需要你“撤销”的常见场景,你可以尝试使用Git来用最佳的方式来解决它。
一.撤销已经推送到远程的变更
场景:
你已经执行git push,把你的修改推送到远程的仓库,现在你意识到之前推送的commit中有一个有些错误,想要撤销该commit。
方案:
git revert <SHA>
原理:
git revert 会创建一个新的commit,它和指定SHA对应的commit是相反的(或者说是反转的)。如果原型的commit是“物质”,那么新的commit就是“反物质”。
任何从原来的commit里删除的内容都会再新的commit里被加回去,任何原来的commit中加入的内容都会在新的commit里被删除。
这是Git中最安全、最基本的撤销场景,因为它并不会改变历史。所以你现在可以git push新的“反转”commit来抵消你错误提交的commit。
二.修正最后一个commit的消息
场景:
你在最后一条commit消息里有一个笔误,已经执行了 git commit -m ‘Fixes bug #42’ ,但是在git push之前你意识到这个消息应该是“Fix bug #43”。
方案:
git commit --amend
或
git commit --amend -m'Fixes bug #43'
原理:
git commit –amend 会用一个新的commit更新并替换最近的commit,这个新的commit会把任何修改内容和上一个commit的内容结合起来。如果当前没有提出任何修改,这个操作就只会把上次的commit重写一遍。
三.撤销“本地的”修改
场景:
一只喵从键盘上走过,无意中保存了修改,然后破坏了编辑器。不过,你还没有commit这些修改。你想要恢复被修改文件里的所有内容–就像上次commit的时候一模一样。
方案:
git checkout -- <bad filename>
原理:
git checkout
会把工作目录中的文件修改到Git之前记录的某个状态。你可以提供你想返回的分支或者特定的SHA,或者在缺省情况下,GIt会认为你希望checkout的是HEAD,当前checkout分支的最后一次commit。
记住: 你用这种方法“撤销”的任何修改真的会完全消失。因为它们从来没有被提交过,所以之后Git也无法帮助我们恢复它们。你一定要确保自己了解在这个操作中丢掉的东西是什么?(也行可以利用git diff
先确认一下)
四.重置“本地的”修改
场景:
你在本地提交了一下东西(还没有push),但是所有这些东西都很糟糕,你希望撤销前面的三次提交(就像它们从来没有发生过一样)。
方案:
git reset <last good SHA>
或
git reset --hard <last good SHA>
原理:
git reset会把你的代码库历史返回到指定的SHA状态。这样就像是这些提交从来没有发生过。缺省情况下,git reset会保留工作目录。这样,提交是没有了,但是修改内容还在磁盘上。这是一种安全的选择,但通常我们会希望一步就“撤销”提交已经修改内容(这就是--hard选项的功能)。
五.在撤销“本地修改”之后再恢复
场景:
你提交了几个commit,然后用git reset --hard撤销了这些修改(见上一段),接着你又意识到:你希望还原这些修改!
方案:
git reflog
和
git reset
或
git checkout
原理:
git reflog对于恢复项目历史是一个超棒的方式。你可以恢复几乎任何(commit过的)东西。
你可以能熟悉git log命令,它会显示commit的列表。 git reflog也是类似的,不过它显示的是一个HEAD发生改变的时间列表。
一些注意事项:
- 它涉及的只是HEAD的改变。在你切换分支、用git commit进行提交、以及用git reset撤销commit时,HEAD会发生改变,但当你使用git checkout -- <bad filename>撤销时,HEAD并不会发生改变。就像我们在上面说的,这些修改从来没有被提交过,因此reflog也无法帮助我们恢复它们。
- git reflog不会永远保持。Git会定期清理那些“用不到的”对象。不要指望几个月前的提交还一直躺着那里。
- 你的reflog就是你的,只是你的,你不能用git reflog来恢复另外一个开发者没有push过的commit。
那么…你如何来利用reflog来“恢复”之前“撤销”的commit呢?它取决于你想做到的到底是什么。
- 如果你希望准确的恢复项目的历史到某个时间点,用git reset --hard <SHA>
- 如果你希望重建工作目录里的一个或多个文件,让它们恢复到某个时间点的状态,用git checkout <SHA> -- <filename>
- 如果你希望把这些commit里的某个重新提交到你的代码库里,用git cherry-pick <SHA>
六.利用分支的另一种做法
场景:
你进行了一些提交,然后意识到你开始checkout的是master分支。希望这些提交进入到另外一个特性(feature)分支。
方案:
git branch feature
git reset --hard origin/master
git checkout feature
原理:
你可能习惯用 git checkout -b <name>创建一个新的分支(这是创建新分支并马上checkout的流行捷径),但是你不希望马上切换分支。这里,git branch feature创建了一个叫做feature的新分支,并指向你最近的commit,但是还是让你checkout在master分支上。
下一步,在提交任何新的commit之前,用git reset --hard 把master分支倒回origin/master。不过别担心,那些commit还在feature分支里。
最后,用git checkout切换到新的feature分支,并让你最近所有的工作成果都完好无损。
七.及时分支,省去繁琐
场景:
你在master分支的基础上创建一个feature分支,但是master分支已经滞后于origin/master很多。现在master分支已经和origin/master同步,你希望在feature上的提交从现在开始,而不是从滞后很多的地方开始。
方案:
git checkout feature
git rebase master
原理:
要达到这个效果,你本来可以通过git reset(不加--hard,这样可以再磁盘上保留修改)和git checkout -b <new branch name>然后再重新提交修改,不过这样做的话,你就失去提交历史。我们有更好的办法。
git rebase master会做下面的这些事情:
- 首先它会找到你当前checkout的分支和master分支的共同祖先。
- 然后它reset当前checkout的分支到那个共同祖先 ,在一个临时保存区存放所有之前的提交。
- 然后它把当前checkout的分支提到master的末尾部分,并从临时保存区重新把存放的commit提交到master分支的最后 一个commit之后。
八.大量的撤销/恢复
场景:
你向某个方向开始实现一个特性,但是你半路意识到另一个方案更好。你已经进行了十几次的提交,但是你现在只需要其中的一部分。你希望其他不需要的提交统统消失。
方案:
git rebase -i <carlier SHA>
原理:
-i 参数会让rebase进入“交互模式”。它开始类似于签名讨论的rebase,但在重新进行任何提交之前,它会暂停下来并允许你详细的修改每一个提交。
rebase -i 会打开你缺省的文本编辑器,里面列出候选的提交。
九.修复更早期的commit
场景:
你在一个更早期的commit里忘记了加入一个文件,如果更早的commit能包含这个忘记的文件就太棒了。你还没有push,但这个commit不是最近的,所以你还没法用commit –amend
方案:
git commit --squash <SHA of the earlier commit>
git rebase --autosquash -i <even earlier SHA>
原理:
git commit --squash会创建一个新的commit,它带有一个commit消息,类似于squash! Earlier commit。(你也可以手工创建一个带有类似commit消息的commit,但是 commit –squash 可以帮你省下输入的工作)
如果你不想被提示为新合并的commit输入一条新的commit消息,你也可以利用 git commit --fixup。在这个情况下,你很有可能会用commit --fixup,因为你只是希望在rebase的适合,使用早期commit的commit消息。
rebase --autosquash -i 会激活一个交互式的rebase编辑器,但是编辑器打开的适合,在commit清单里任何squash!和fixup!的commit都已经配对到目标commit上了。
在使用 --squash 和 --fixup的时候,你可能不记得想要修正的commit的SHA了(只是记得它是前面的第1个或者第5个commit)。你会发现Git的^和操作符特别好用。HEAD是HEAD的前一个commit。HEAD~4是HEAD往前第4个(或者一起算,倒数第5个commit)。
十.停止追踪一个文件
场景:
你偶然把application.log加到代码库里了,现在每次你运行应用,Git都会报告在application.log里有未提交的修改。你把 *.log放到了.gitignore文件里,可文件还是在代码库里,你怎样才能让Git“撤销”对这个文件的追踪呢?
方法:
git rm --cached application.log
原理:
虽然.gitignore会阻止Git追踪文件的修改,甚至不关注文件是否存在,但这只是针对那些以前从来没有追踪过的文件。一旦有个文件被加入并提交了,Git就会持续关注改文件的改变。类似地,如果你利用git add -f来强制或覆盖了.gitignore,Git还会持续追踪改变的情况。之后你就不必用-f来添加这个文件了。
如果你希望从Git的追踪对象中删除那个本应忽略的文件,git rm --cached会从追踪对象中删除它,但让文件在磁盘上保持原封不动。因为现在它已经被忽略了,你在git status里就不会再看见这个文件,也不回再偶然提交该文件的修改了。
这就是如何在Git里撤销任何操作的方法。要了解更多关于本文中用到的Git命令,请查看下面的相关文档。