章节 ▾ 第二版

7.7 Git 工具 - 揭秘 Reset

揭秘 Reset

在转向更专业的工具之前,我们先来讨论 Git 的 resetcheckout 命令。当你初次接触它们时,这两个命令是 Git 中最令人困惑的部分。它们的功能如此之多,以至于想要真正理解并正确运用它们似乎是徒劳的。为此,我们推荐一个简单的比喻。

三棵树

理解 resetcheckout 的一个更简单的方法是将其看作 Git 管理三个不同“树”的内容。这里的“树”实际上是指“文件集合”,而非特指数据结构。在某些情况下,索引(index)并非完全像一棵树,但就目前而言,这样思考会更容易。

Git 系统在正常操作中管理和操作三棵树

作用

HEAD

上次提交的快照,下一个父提交

索引

建议的下一次提交快照

工作目录

沙盒

HEAD

HEAD 指向当前分支的引用,而该引用又指向该分支上最后一次提交。这意味着 HEAD 将是下一次创建的提交的父提交。通常,最简单的理解方式是把 HEAD 看作是你在该分支上最后一次提交的快照。

事实上,查看该快照的内容非常容易。下面是一个获取 HEAD 快照中每个文件的实际目录列表和 SHA-1 校验和的示例:

$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon  1301511835 -0700
committer Scott Chacon  1301511835 -0700

initial commit

$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152...   README
100644 blob 8f94139338f9404f2...   Rakefile
040000 tree 99f1a6d12cb4b6f19...   lib

Git 的 cat-filels-tree 命令是用于底层操作的“管道(plumbing)”命令,在日常工作中并不常用,但它们能帮助我们理解这里发生了什么。

索引

索引 是你建议的下一次提交。我们也将这个概念称为 Git 的“暂存区”(Staging Area),因为当你运行 git commit 时,Git 就是查看这里的内容。

Git 会用上次检出到工作目录中的所有文件内容及其原始状态来填充此索引。然后,你可以用文件的新版本替换其中一些文件,git commit 会将这些更改转换为新提交的树。

$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0	README
100644 8f94139338f9404f26296befa88755fc2598c289 0	Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0	lib/simplegit.rb

同样,这里我们使用了 git ls-files,它更像是一个幕后命令,能显示你的索引当前的状态。

从技术上讲,索引并非树形结构——它实际上被实现为一个扁平化的清单——但就我们的目的而言,这样理解就足够了。

工作目录

最后,你拥有你的 工作目录(也常被称为“工作树”)。其他两棵树将内容以高效但不方便的方式存储在 .git 文件夹内。工作目录将它们解包成实际的文件,这使得你可以更轻松地编辑它们。将工作目录视为一个沙盒,你可以在将更改提交到暂存区(索引)并最终提交到历史记录之前,在这里尝试修改。

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

工作流程

Git 的典型工作流程是通过操作这三棵树,以连续递进的良好状态记录项目的快照。

Git’s typical workflow
图 137. Git 的典型工作流程

让我们来可视化这个过程:假设你进入一个新目录,里面只有一个文件。我们将这个文件称为 v1 版本,并用蓝色表示。现在我们运行 git init,它将创建一个 Git 仓库,其中 HEAD 引用指向尚未诞生的 master 分支。

Newly-initialized Git repository with unstaged file in the working directory
图 138. 新初始化的 Git 仓库,工作目录中存在未暂存的文件

此时,只有工作目录树包含内容。

现在我们想要提交这个文件,因此我们使用 git add 将工作目录中的内容复制到索引。

File is copied to index on `git add`
图 139. 文件通过 git add 被复制到索引

接着我们运行 git commit,它会获取索引中的内容并将其保存为永久快照,创建一个指向该快照的提交对象,并更新 master 分支指向该提交。

The `git commit` step
图 140. git commit 步骤

如果我们运行 git status,将看不到任何更改,因为所有三棵树都是相同的。

现在我们想对该文件进行更改并提交。我们将经历相同的过程;首先,我们更改工作目录中的文件。我们将此文件称为 v2 版本,并用红色表示。

Git repository with changed file in the working directory
图 141. Git 仓库中工作目录内的文件已更改

如果我们现在运行 git status,会看到文件显示为红色的“Changes not staged for commit”(未暂存的更改),因为该条目在索引和工作目录之间存在差异。接下来我们对它运行 git add 以将其暂存到我们的索引中。

Staging change to index
图 142. 将更改暂存到索引

此时,如果我们运行 git status,我们将看到文件以绿色显示在“Changes to be committed”(要提交的更改)下,因为索引和 HEAD 不同——也就是说,我们建议的下一次提交与我们上次提交不同。最后,我们运行 git commit 来完成提交。

The `git commit` step with changed file
图 143. 带有更改文件的 git commit 步骤

现在 git status 将不会显示任何输出,因为所有三棵树又恢复一致了。

切换分支或克隆也经历类似的过程。当你检出一个分支时,它会更改 HEAD 指向新的分支引用,用该提交的快照填充你的 索引,然后将 索引 的内容复制到你的 工作目录 中。

Reset 的作用

在这种上下文中,reset 命令会更有意义。

为了这些示例的目的,我们假设我们再次修改了 file.txt 并第三次提交了它。所以现在我们的历史记录看起来像这样:

Git repository with three commits
图 144. 包含三次提交的 Git 仓库

现在让我们详细了解当你调用 reset 时它到底做了什么。它以一种简单且可预测的方式直接操作这三棵树。它最多执行三个基本操作。

步骤 1:移动 HEAD

reset 要做的第一件事就是移动 HEAD 指向的位置。这与更改 HEAD 本身(checkout 的作用)不同;reset 移动的是 HEAD 所指向的分支。这意味着如果 HEAD 设置为 master 分支(即你当前在 master 分支上),运行 git reset 9e5e6a4 将首先使 master 指向 9e5e6a4

Soft reset
图 145. 软重置

无论你调用带有提交的何种形式的 reset,这都是它总是会尝试做的第一件事。使用 reset --soft 时,它将仅仅停在这里。

现在花点时间看看那张图,并理解发生了什么:它实质上撤销了上次的 git commit 命令。当你运行 git commit 时,Git 会创建一个新的提交,并将 HEAD 指向的分支向上移动到该提交。当你 reset 回到 HEAD~(HEAD 的父提交)时,你是将分支移回到它原来的位置,而不改变索引或工作目录。你现在可以更新索引并再次运行 git commit 来完成 git commit --amend 所能做的事情(参见修改最后一次提交)。

步骤 2:更新索引(--mixed

请注意,如果你现在运行 git status,你将看到索引与新的 HEAD 之间的差异以绿色显示。

reset 接下来会做的是用 HEAD 当前指向的快照内容来更新索引。

Mixed reset
图 146. 混合重置

如果你指定 --mixed 选项,reset 将会停在此处。这也是默认行为,因此如果你根本不指定任何选项(在此例中仅使用 git reset HEAD~),命令也会停在这里。

现在再花一秒钟看看那张图,并理解发生了什么:它仍然撤销了你的上次 commit,但也取消暂存了所有内容。你回滚到了运行所有 git addgit commit 命令之前。

步骤 3:更新工作目录(--hard

reset 将要做的第三件事是使工作目录看起来与索引一致。如果你使用 --hard 选项,它将继续执行到此阶段。

Hard reset
图 147. 硬重置

那么,让我们想想刚才发生了什么。你撤销了最后一次提交,撤销了 git addgit commit 命令,以及你在工作目录中完成的所有工作。

重要的是要注意,这个标志(--hard)是使 reset 命令变得危险的唯一方式,也是 Git 实际会销毁数据的极少数情况之一。reset 的任何其他调用都可以很容易地撤销,但 --hard 选项不能,因为它会强制覆盖工作目录中的文件。在这个特定情况下,我们的文件 v3 版本仍然存在于 Git 数据库的一个提交中,我们可以通过查看 reflog 来找回它,但如果我们没有提交它,Git 仍然会覆盖文件,而且将无法恢复。

总结

reset 命令以特定顺序覆盖这三棵树,并在你告知时停止

  1. 移动 HEAD 指向的分支 (如果 --soft 则在此停止)

  2. 使索引看起来像 HEAD (除非 --hard 否则在此停止)

  3. 使工作目录看起来像索引。

带路径的 Reset

这涵盖了 reset 的基本行为,但你也可以为其提供一个路径来操作。如果你指定一个路径,reset 将跳过步骤 1,并将其余操作限制到特定文件或文件集。这实际上也说得通——HEAD 只是一个指针,你不能指向一个提交的一部分和另一个提交的一部分。但索引和工作目录可以部分更新,因此 reset 会继续执行步骤 2 和 3。

那么,假设我们运行 git reset file.txt。这种形式(因为你没有指定提交的 SHA-1 或分支,也没有指定 --soft--hard)是 git reset --mixed HEAD file.txt 的简写,它将:

  1. 移动 HEAD 指向的分支 (跳过)

  2. 使索引看起来像 HEAD (在此停止)

所以它实质上只是将 file.txt 从 HEAD 复制到索引。

Mixed reset with a path
图 148. 带路径的混合重置

这实际上起到了取消暂存文件的效果。如果我们看那个命令的图示,并思考 git add 的作用,它们是完全相反的。

Staging file to index
图 149. 将文件暂存到索引

这就是为什么 git status 命令的输出会建议你运行此命令来取消暂存文件(更多信息请参见取消暂存已暂存的文件)。

我们也可以通过指定一个特定的提交来拉取文件版本,而不让 Git 假设我们指的是“从 HEAD 拉取数据”。我们只需运行类似 git reset eb43bf file.txt 的命令。

Soft reset with a path to a specific commit
图 150. 带路径到特定提交的软重置

这实际上与我们在工作目录中将文件内容恢复到 v1,然后对其运行 git add,接着又将其恢复到 v3 相同(而无需实际执行所有这些步骤)。如果我们现在运行 git commit,它将记录一个将该文件恢复到 v1 的更改,即使我们从未在工作目录中再次拥有过它。

另外值得注意的是,与 git add 类似,reset 命令也接受 --patch 选项,以逐个块地取消暂存内容。因此,你可以选择性地取消暂存或恢复内容。

合并提交

让我们来看看如何利用这种新获得的能力做些有趣的事情——合并提交(squashing commits)。

假设你有一系列提交,其消息类似于“oops.”、“WIP”和“forgot this file”。你可以使用 reset 快速轻松地将它们合并成一个单一的提交,让你看起来非常聪明。合并提交展示了另一种实现方式,但在本例中,使用 reset 更简单。

假设你有一个项目,第一次提交包含一个文件,第二次提交添加了一个新文件并修改了第一个文件,第三次提交再次修改了第一个文件。第二次提交是一个进行中的工作,你想将其合并。

Git repository
图 151. Git 仓库

你可以运行 git reset --soft HEAD~2 将 HEAD 分支移回一个较旧的提交(即你想要保留的最新提交)。

Moving HEAD with soft reset
图 152. 通过软重置移动 HEAD

然后只需再次运行 git commit

Git repository with squashed commit
图 153. 包含合并提交的 Git 仓库

现在你可以看到,你的可达历史(即你将要推送的历史)看起来就像你有一个包含 file-a.txt v1 的提交,然后是第二个提交,它同时将 file-a.txt 修改为 v3 并添加了 file-b.txt。包含文件 v2 版本的提交不再存在于历史记录中。

检出

最后,你可能会想 checkoutreset 有什么区别。和 reset 一样,checkout 也操作三棵树,并且根据你是否给命令提供文件路径,它的行为会略有不同。

不带路径

运行 git checkout [branch] 与运行 git reset --hard [branch] 非常相似,因为它都会更新所有三棵树使其看起来像 [branch],但有两点重要的区别。

首先,与 reset --hard 不同,checkout 是工作目录安全的;它会检查以确保不会清除已更改的文件。实际上,它比这更智能一些——它会在工作目录中尝试进行一次简单的合并,因此你更改的所有文件都将被更新。而 reset --hard 则会不经检查地直接替换所有内容。

第二个重要区别是 checkout 更新 HEAD 的方式。reset 会移动 HEAD 所指向的分支,而 checkout 会移动 HEAD 本身去指向另一个分支。

例如,假设我们有 masterdevelop 分支,它们指向不同的提交,并且我们当前在 develop 分支上(因此 HEAD 指向它)。如果运行 git reset masterdevelop 本身现在将指向与 master 相同的提交。如果我们改为运行 git checkout masterdevelop 不会移动,而是 HEAD 本身移动。HEAD 现在将指向 master

因此,在这两种情况下,我们都将 HEAD 移动到指向提交 A,但如何移动却大相径庭。reset 会移动 HEAD 所指向的分支,checkout 会移动 HEAD 本身。

`git checkout` and `git reset`
图 154. git checkoutgit reset

带路径

运行 checkout 的另一种方式是带文件路径,这和 reset 一样,不会移动 HEAD。它就像 git reset [branch] file 一样,用该提交中的文件更新索引,但它也会覆盖工作目录中的文件。它会完全等同于 git reset --hard [branch] file(如果 reset 允许你那样运行的话)——它不是工作目录安全的,并且不会移动 HEAD。

此外,与 git resetgit add 类似,checkout 也接受 --patch 选项,允许你逐个块地选择性地恢复文件内容。

总结

希望现在你已经理解并对 reset 命令感到更自在,但可能仍然对它与 checkout 的确切区别感到有些困惑,并且可能无法记住所有不同调用规则。

这里有一份备忘录,说明哪些命令影响哪些树。“HEAD”列显示“REF”表示该命令移动了 HEAD 所指向的引用(分支),显示“HEAD”表示它移动了 HEAD 本身。请特别注意“WD Safe?”(工作目录安全?)列——如果显示,请在运行该命令前三思。

HEAD 索引 工作目录 工作目录安全?

提交级别

reset --soft [commit]

引用

reset [commit]

引用

reset --hard [commit]

引用

checkout <commit>

HEAD

文件级别

reset [commit] <paths>

checkout [commit] <paths>

scroll-to-top