Git的各种Undo技巧

git undo

GitHubHow to undo (almost) anything with Git这篇文章介绍了Git使用中的各种Undo技巧。

任何版本控制系统中最有用的功能之一就是能够**”撤销(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。

git reflog

那么…你如何来利用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会做下面的这些事情:

  1. 首先它会找到你当前checkout的分支和master分支的共同祖先。
  2. 然后它reset当前checkout的分支到那个共同祖先 ,在一个临时保存区存放所有之前的提交。
  3. 然后它把当前checkout的分支提到master的末尾部分,并从临时保存区重新把存放的commit提交到master分支的最后 一个commit之后。

大量的撤销/恢复

场景:

你向某个方向开始实现一个特性,但是你半路意识到另一个方案更好。你已经进行了十几次的提交,但是你现在只需要其中的一部分。你希望其他不需要的提交统统消失。

方案:

git rebase -i <carlier SHA>

原理:

-i 参数会让rebase进入“交互模式”。它开始类似于签名讨论的rebase,但在重新进行任何提交之前,它会暂停下来并允许你详细的修改每一个提交。

rebase -i 会打开你缺省的文本编辑器,里面列出候选的提交。

rebase -i

前面两列是键: 第一个是选定的命令,对应第二列里的SHA确定的commit。缺省情况下,rebase -i假定每个commit都要通过pick命令被运行。

要丢弃一个commit,只要在编辑器中删除那一行就行了。如果你不再需要项目里面的那几个错误的提交, 删除你想删除的几行。

如果你需要暴漏commit的内容,而是对commit消息进行编辑,你可以使用reword命令,将第一列的pick替换成reword(或者直接使用r)。有人会觉得再这里直接重写commit消息就行了,但是这样是不管用的(rebase -i会忽略SHA列前面的任何东西,它后面的文本只是用来帮助我们记住那一串SHA代表什么)。当你完成rebase -i的操作之后,你会被提示输入需要编写的任何commit消息。

如果你需要把两个commit合并在一起,你可以使用squash或者fixup命令。

rebase -i

squash和fixup会“向上”合并(带有这两个命令的commit会被合并到他的签一个commit里)。上面的这个例子里,0835fe26943e85会合并成一个commit,38f5c4caf67f82会被合并成另一个。

如果你选择了squash,Git会提示我们给新合并的commit一个新的commit消息; fixup则会把合并清单里的第一个commit的消息直接给新合并的commit。

在你保存并推出编辑器的时候,Git会按从顶部到底部的顺序运用你的commit。你可以通过在保存前修改commit顺序来改变运用的顺序。如果你愿意,你也可以同如下安排把af67f820835fe2合并到一起。

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上了。

rebase --autosquash -i

在使用 --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命令,请查看下面的相关文档。