章节 ▾ 第二版

7.7 Git 工具 - Reset 详解

Reset 详解

在深入更专业的工具之前,我们先来讨论 Git 的 resetcheckout 命令。这应该是 Git 中最容易让人感到困惑的部分。它们能做的事情太多了,以至于我们似乎很难真正理解并恰当地使用它们。为了帮助你理解,我们推荐一个简单的比喻。

三棵树

理解 resetcheckout 的一个更简单的方法是,将 Git 想象成一个管理三种不同“树”(文件集合)的内容管理器。在这里,“树”指的是“文件集合”,而不是特指数据结构。在某些情况下,索引(index)的行为并不完全像一棵树,但为了方便理解,我们暂时可以这样想。

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

角色

HEAD

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

索引 (Index)

将要提交的下一个快照

工作目录 (Working Directory)

沙盒

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 命令是“管道”命令,用于较低级别的操作,在日常工作中并不常用,但它们有助于我们理解这里发生的事情。

索引 (Index)

索引是你**打算提交的下一个快照**。我们之前也把它称为 Git 的“暂存区”,因为当你运行 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,这是一个更偏向幕后的命令,它展示了你当前索引的样子。

技术上来说,索引并不是一个树结构——它实际上被实现为一个扁平的清单(manifest)——但对我们目前的理解来说,已经足够接近了。

工作目录 (Working Directory)

最后,你拥有你的工作目录(也常被称为“工作树”)。另外两棵树将它们的内容以一种高效但不方便的方式存储在 .git 文件夹内。工作目录将它们解压成实际的文件,方便你进行编辑。将工作目录看作一个**沙盒**,你可以在提交到暂存区(索引)然后提交到历史记录之前,在这里尝试修改。

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

1 directory, 3 files

工作流程

Git 的典型工作流程是通过操作这三棵树,以逐步改进的状态记录项目的快照。

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

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

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. Soft reset (软重置)

无论你调用哪种形式的带提交的 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 (混合重置)

如果你指定了 --mixed 选项,reset 将在此停止。这也是默认选项,所以如果你根本不指定任何选项(在本例中就是 git reset HEAD~),命令就会在此停止。

再花点时间看看这个图,理解发生了什么:它仍然撤销了你最后一次 commit,而且还取消了所有暂存。你回滚到了运行所有 git addgit commit 命令之前的状态。

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

reset 要做的第三件事是使工作目录看起来像索引。如果你使用了 --hard 选项,它将继续进行到这一步。

Hard reset
图 147. Hard reset (硬重置)

所以,让我们想想刚才发生了什么。你撤销了最后一次提交,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*(在此停止)*。

所以它基本上只是将 HEAD 中的 file.txt 复制到索引。

Mixed reset with a path
图 148. 带路径的 Mixed reset

这实际上起到了取消暂存文件的作用。如果我们看看该命令的图,并思考 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. 带路径到特定提交的 Soft reset

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

还有一点也很有趣,与 git add 一样,reset 命令也接受 --patch 选项,允许你逐个 hunks 地取消暂存内容。因此,你可以选择性地取消暂存或还原内容。

合并提交 (Squashing)

让我们看看如何利用这个新发现的能力做一些有趣的事情——合并提交。

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

假设你的项目中的第一次提交只有一个文件,第二次提交添加了一个新文件并修改了第一个文件,第三次提交再次修改了第一个文件。第二次提交是一个未完成的工作,你想把它合并掉。

Git repository
图 151. Git 仓库

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

Moving HEAD with soft reset
图 152. Soft reset 移动 HEAD

然后简单地再次运行 git commit

Git repository with squashed commit
图 153. Git 仓库,合并后的提交

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

检出 (Check It Out)

最后,你可能想知道 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 选项,允许你逐个 hunks 地选择性地还原文件内容。

总结

希望现在你对 reset 命令有了更好的理解和信心,但可能仍然对它与 checkout 的确切区别感到困惑,并且不可能记住所有不同调用的规则。

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

HEAD 索引 (Index) 工作目录 WD Safe?

提交级别

reset --soft [commit]

REF

NO

NO

YES

reset [commit]

REF

YES

NO

YES

reset --hard [commit]

REF

YES

YES

NO

checkout <commit>

HEAD

YES

YES

YES

文件级别

reset [commit] <paths>

NO

YES

NO

YES

checkout [commit] <paths>

NO

YES

YES

NO