Git的反悔操作(一)

概述

这次主要来讲讲Git的反悔操作,自己平时在写代码的过程中经常会出现想要弃用所有的改动或回滚到上一次commit的情况。Git上的反悔操作有resetrebaserevert等,每个操作各有区别和对应的使用场景,这里做下总结。

Git的反悔操作有两大类:

  • 撤销改动 ( Undoing Change )
  • 重写历史 ( Rewriting History )

文章大部分翻译于 Undoing ChangesRewriting history,并结合了自己的一些理解和补充。

撤销改动(Undoing Change)

git checkout

git checkout有三个不同的功能:切换分支、回滚至某个commit、回滚一个文件至某个commit。切换分支是git checkout最常见的功能,不做介绍,这里主要介绍下它在撤销文件改动上的应用。

回滚至某个commit

git chekcout <commit>

上面的命令是回滚到工作目录中指定的 commit 上,这是一个 只读 操作,不会影响到当前工作区的状态,它在你查看旧版本的文件时不会损坏你的代码仓库。通常,HEAD指向master分支或其他本地分支,当使用git checkout回滚到以前的 commit 时,HEAD就不再指向某个分支了,而是直接指向一个commit,这时就叫做detached HEAD状态。

切换到detached HEAD状态时,会有一个警告。

警告

这个警告是告诉你,你现在做的所有事情与你开发项目的其余工作区是分离的,即所有的改动与本地仓库的任一分支都无关,不会影响到其他的分支的状态。如果你准备在detached HEAD状态下开发新的feature,那将会没有分支允许你回退这里,当你不可避免地切换到其他分支时,将没有任何办法引用到这个feature。你可以把detached HEAD状态看作是正在一个未命名的分支上。

HEADdetached HEAD 的区别可以参考 How can I reconcile detached HEAD with master/origin?

将英文翻译为中文经常会词不达意,很难把握,建议还是看英文原文:)。

示例

假设你正在进行一次疯狂的重构,但现在你不确定是否要继续下去。这时你想要看一下开始这次重构之前项目原来的样子,首先你需要找到你想要查看的版本的ID。

git log --oneline

假设你的项目历史看起来像下面这样:

b7119f2 Continue doing crazy things
872fa7e Try something crazy
a1e8fb5 Make some important changes to hello.py
435b61d Create hello.py
9773e52 Initial import

你可以使用git checkout查看Make some important changes to hello.py这次commit,如下:

git checkout a1e8fb5

这让你的工作区切换到了a1e8fb5comimit的状态。你可以查看文件、编译项目、运行测试用例,甚至编辑文件,完全不用担心丢失项目“当前”的状态,你在这里做的所有修改都不会被保存到项目中。当你想要继续那次疯狂的重构时,你需要回到项目的“当前”状态。

git checkout master

回滚一个文件至某个commit

git checkout <commit> <file>

回滚一个文件到以前的一个版本,这个操作会 影响 当前工作区的状态。

你可以在一个新的快照中重新提交这个旧版本,当然也包含其他任何文件。实际上,checkout的这个用法和revert类似,只不过是仅针对一个文件。

示例

如果你只对单个文件感兴趣,你可以使用 git checkout 获取到该文件的旧版本。比如,如果你只想要看看 某次commit下的hello.py文件,可以使用下面的命令:

git checkout a1e8fb5 hello.py

记住,不像切换commit,这会影响当前项目的状态。这个旧版本的文件的状态会变为 Change to be committed,给你一个机会将该文件恢复到先前的版本。

如果你决定不需要保留这个旧版本了,你可以切换到最近的版本,如下:

git checkout HEAD hello.py

git revert

git revert 可以撤销一个已提交的快照(snapshot),但它解决的是如何撤销已提交的被引入的改动,并生成内容来追加一个新的提交,而不是从项目的历史中移除这个提交,这避免了丢失历史记录,这对于项目的每一次修改的历史记录的完整性来说非常重要,并这是服务于可靠的多人协作开发的。

git revert <commit>

这句命令会撤销这次所有被引入的改动,生成一个新的commit,并应用在当前分支上。

当你想从你的项目历史中移除一个完整的commit时,就应该使用git revert。比如,你正在追踪一个Bug并发现它是在一次单一的commit中被引入的,你可以手动进行修改,删除有Bug的代码来修复它,然后提交一个新的快照,但这样很麻烦,效率也很低,你更应该做的是,使用git revert自动完成,撤销这次commit所有被引入的改动。

Reverting vs. Resetting

很重要的一点,revert是对一次单一的commit的撤销,并不是真正意义上的回滚。它不是通过移除项目中一次commit后面的所有提交来“回滚”之前的状态,实际上那样的操作在Git上被叫做reset,而不是revert

比起resetrevert有两个重要的好处:

  • revert不会改变项目的历史。如果那些commits已经推到了共享的代码仓库,它会是一个“安全”的操作。为什么改变共享代码仓库的历史是危险的,请看后面的git reset的介绍。

  • revert可以作用于历史中 任意 的单一的commit节点,然而reset只能做到从当前 最新 的commit开始回滚。比如说,如果你想要只撤销一次旧的指定的commit,使用git reset,你则必须移除该commit和该commit之后出现的所有commits,然后再把那些随后的commit重新提交。毫无疑问,这种撤销的方式一点都不优雅。

示例1

下面的例子是git revert的一个简单示例,提交了一个快照,然后立即使用revert撤销了它。

# Edit some tracked files

# Commit a snapshot
git commit -m "Make some changes that will be undone"

# Revert the commit we just created
git revert HEAD

注意:在revert后,第4次commit仍然被保留在项目历史中,git revert新增了一个新的commit来撤销它的改动,而不是删除它。结果就是,第3次和第5次commit的代码是完全一样的,第4次commit依然保留在历史中,以防我们想要重新回滚到这里。

示例2

假设你发现在某次commit中引入了一个bug,你想使用 git revert来回滚。查看历史:

git log --oneline

项目历史如下:

417e4a9 commit 4
427d76b commit 3
1642475 introduced a bug
71d3ef7 commit 1
bf4f6f6 git initial

使用 revert 回滚到 1642475

git revert 1642475

但你会发现没有想象中那么简单,而是发生冲突了,报错如下:

error: could not revert 1642475... introduced a bug
hint: after resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' or 'git rm <paths>'
hint: and commit the result with 'git commit'

revert 仅仅是撤销introduced a bug这一commit的改动,默认会生成一个新的commit提交,但在它之后还有commit 3commit 4,它们的改动不会被影响,依然保留在工作区中,因此产生了冲突。你可以手动解决冲突后commit,但这却是个麻烦且不优雅的方式。因为1642475427d76b417e4a9这几个commit的改动被一起合并在暂存区中,如果你修改的不止一个文件,那手动解决冲突将会非常麻烦。解决方式是,默认 生成新的commit,并按顺序回滚。

先强制结束revert

git revert --abort

按顺序回滚

git revert 417e4a9 --no-commit
git revert 427d76b --no-commit
git revert 1642475 --no-commit    
git revert --continue

git revert --continue,会生成带默认message的commit。

更多参数说明详见:git-revert-document

git reset

如果git revert是以一个”安全””的方式来撤销改动,那你可以认为git reset是一种 危险 的方式。当你使用git reset后,将没有办法恢复原样,它是一个永恒的撤销,因为那些commits不再被任何refreflog引用。在使用这个工具时请务必谨慎,因为它是git命令中唯一一个潜在的使你的努力付诸东流的命令。

git reset是一个功能丰富的命令,它可以用于移除已提交的快照,但它更多的是用来撤销暂存区和工作区的改动,另一种情况是,它应该只用于撤销本地的改动(不应该reset那些已经与其他开发者共享了的快照)。

用法

git reset <file>

从暂存区中移除指定的文件,但保留工作区不变。它unstage一个 文件且没有覆盖任何改动。

把文件加入暂存区叫做stage,文件修改过但还未使用git add加入暂存区叫做unstage

git reset

重置暂存区匹配至最近的一次commit,但保留工作区不变。它unstage所有 文件且没有覆盖任何改动,让你有机会从头开始重建暂存快照。

git reset --hard

重置暂存区和工作区匹配至最近的一次commit。除了unstage所有文件外,--hard还告诉Git也一并覆盖工作区的所有改动,也就是说,这个操作撤销了所有未提交的改动,所以在使用它前,请确定你是真的想丢弃本地的开发。

git reset <commit>

将当前分支的HEAD移动至<commit>,重置暂存区匹配至<commit>,但不包括工作区。从<commit>开始的所有改动会被驻留在工作区,这让你可以使用更干净、更原子性的快照来重新提交项目历史。

git reset --hard <commit>

将当前分支的HEAD移动至<commit>以及重置暂存区和工作区匹配至<commit>。它不仅撤销了未提交的改动,还撤销了<commit>之后的所有commits。

讨论

正如上面提及到的,git reset是用来从一个代码仓库中移除改动的。没有--hard标记时,git reset通过unstage改动或撤销(uncommit)一系列已提交的快照来清理干净代码仓库,然后重头开始重建它们。当一个试验已经往可怕的方向发展时,--hard标记就派上用场了,你需要一个干净的工作空间。

reset是被设计来撤销 本地 的改动的,而revert是被设计来安全地撤销 公有 的commit的。出于完全不同的目的,这两个命令的执行结果也不同:reset是完全地移除有改动的地方,而revert则是维持原来的改动,使用一个新的commit来达到撤销的目的。

不要重置公有的历史

<commit后面的任一快照被推送到公有仓库时,你就不应该使用git reset <commit>,推送一个commit到公有仓库后,就必须假设其他开发者是依赖于它的。删除一个其他团队成员在此基础上持续开发的commit会引发团队协作上的严重问题,当他们尝试与你的代码仓库同步时,就像一大块项目历史突然地消失了。

下面的例子就是当你尝试reset一个公有的commit时会发生的。

一旦你在reset后新增一个commit,Git会认为你本地的历史与origin/master背道而驰了,当合并commit时,需要先同步你的代码仓库,这就有可能使你的团队感到迷惑和无助。

所以重点就是,你打算用git reset <commit>来撤销你那糟糕的试验时,请确保它只作用于本地(还没被推送至远程服务器)的改动。如果你需要修复一个公有的commit,请使用git revert,因为它正是为了这个目的而被设计的。

示例

Unstage 一个文件

假设有两个文件hello.pymain.py,已经被添加到Git仓库中,修改这两文件并进行提交。

# Edit both hello.py and main.py

# Stage everything in the current directory
git add .

# Realize that the changes in hello.py and main.py
# should be committed in different snapshots

# Unstage main.py
git reset main.py

# Commit only hello.py
git commit -m "Make some changes to hello.py"

# Commit main.py in a separate snapshot
git add main.py
git commit -m "Edit main.py"

正如你所看到的,你可以使用git resetunstage掉一些不小心加入暂存区但又与此次commit无关的文件,让你的commits保持高度的专一。

移除本地的commits

接下来的例子展示了一个更高级的使用情况,它示范了你在一个新的试验上工作了一段时间并在提交了一些快照后,决定彻底抛弃它这整个过程究竟发生了什么。

# Create a new file called `foo.py` and add some code to it

# Commit it to the project history
git add foo.py
git commit -m "Start developing a crazy feature"

# Edit `foo.py` again and change some other tracked files, too

# Commit another snapshot
git commit -a -m "Continue my crazy feature"

# Decide to scrap the feature and remove the associated commits
git reset --hard HEAD~2

git reset HEAD~2这句命令让当前分支回滚了两个提交,实际上,从项目历史上删除了我们刚刚创建的两个快照。请记住,这种类型的reset应该只用在未推送到远程服务器的commits上,绝不要在那些已经被推送至公有仓库的commits上执行上面的操作。

git clean

git clean从工作区移除未追踪的文件。这的确是一个更方便的命令,因为它使用git status琐细地查看哪些文件未追踪,然后手动删除它们。就像普通的rm命令一样,git clean是不可恢复的,所以在运行它之前请确保你是真的想要删除那些未追踪的文件。

git clean命令经常和git reset --hard一起被执行,reset仅仅影响已追踪的文件,因此需要git clean来单独清理未追踪的文件,这两个命令相结合可以让你的工作区回滚到一个特定的commit的确切状态。

用法

git clean -n

执行git clean的“演习”。这向您展示哪个文件将会被删除,但不会真正地执行。

git clean -f

从当前工作区中移除未追踪的文件。-f(force)标记是必需的,除非clean.requireForce选项被设为false(默认是true)。这不会移除.gitignore指定的未追踪的文件。

git clean -f <path>

移除未追踪的文件,但仅限于操作指定的路径。

git clean -df

从当前工作区中移除未追踪的文件和目录。

git clean -xf

从当前工作区中移除未追踪的文件,包括Git忽略的文件。

讨论

当你在本地仓库中做了一些令人尴尬的开发想要销毁证据时,git reset --hardgit clean -f会是你最好的朋友,运行着两个命令将会使你的工作区回滚至最近的一次commit,还你一个干净的工作区。

git cleanbuild后清理工作区是很有用的,比如,你可以很容易地移除.o.exe等C编译器生成的二进制文件,这是偶尔打包项目发布前的必要步骤,-x选项达到这个目的特别方便。

记住,一起使用git resetgit clean是唯一一个具有潜在威胁的永久地删除提交的命令,所以请谨慎使用。事实上,在使用git clean时,-f是必须的,Git`的维护者甚至将它作为最基本的操作,而很多人会忘记的这一重要步骤,但这也预防了愚蠢行为而一不小心突然地删除所有辛辛苦苦写的代码。

示例

下面的例子撤销了工作区所有的改动,包括新增的文件。假设你已经提交了一些快照,然后正在尝试一些些新的开发,但不知道自己做了什么导致了一些错误,想要撤销然后重新开始。

# Edit some existing files
# Add some new files
# Realize you have no idea what you're doing

# Undo changes in tracked files
git reset --hard

# Remove untracked files
git clean -df

运行完reset/clean一系列命令后,工作区和暂存区回滚到最近的commit,git status将会告诉你这是一个干净的工作区,你现在可以准备重新开始了。

注意,那些新增的文件没有被加入暂存区,它们不会被git reset --hard影响,必须使用git clean删除它们。

热评文章