章节 ▾ 第二版

7.7 Git 工具 - 重置揭秘

重置揭秘

在进入更专业的工具之前,我们先来谈谈 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 的“暂存区”(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 的典型工作流

让我们可视化这个过程:假设你进入一个新目录,其中只有一个文件。我们将该文件称为版本 1 (v1),并用蓝色标记。现在我们运行 git init,它将创建一个带有 HEAD 引用的 Git 仓库,指向尚未创建的 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,会发现没有变更,因为三棵树的内容完全一致。

现在我们要修改该文件并提交。我们重复同样的流程;首先,我们在工作目录中更改文件。我们称此为版本 2 (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 不会显示任何输出,因为三棵树又变得完全一致了。

切换分支或克隆也会经历类似的过程。当你检出(checkout)一个分支时,它会改变 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 将跳过第 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 的变更,即便我们实际上从未在工作目录中将其恢复到 v1 过。

同样有趣的是,像 git add 一样,reset 命令也接受 --patch 选项,允许逐块(hunk-by-hunk)取消暂存内容。这样你就可以有选择地取消暂存或恢复内容。

压缩提交 (Squashing)

让我们看看如何利用这个新技能做一些有趣的事情——压缩提交。

假设你有一系列提交,提交信息分别是“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.txtv3,又添加了 file-b.txt。那个包含 v2 版本文件的提交已不在历史记录中。

检出 (Checkout)

最后,你可能想知道 checkoutreset 的区别。像 reset 一样,checkout 也操纵这三棵树,并且根据你是否提供文件路径,命令的行为会有所不同。

不带路径

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

首先,与 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 [分支] 文件 类似,即用该提交中的该文件更新索引,但它也会覆盖工作目录中的文件。这完全等同于 git reset --hard [分支] 文件(如果 reset 允许你这样执行的话)——它不是工作目录安全的,并且不会移动 HEAD。

此外,和 git reset 以及 git add 一样,checkout 也接受 --patch 选项,允许你逐块有选择地恢复文件内容。

总结

希望通过本文,你现在对 reset 命令有了更深刻的理解,并感到更加自在。不过,可能你对它与 checkout 的精确差异仍有一丝困惑,也不可能记住所有不同调用的规则。

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

HEAD 索引 (Index) 工作目录 工作目录安全?

提交级别

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