章节 ▾ 第二版

7.7 Git 工具 - Reset 命令解密

Reset 命令解密

在进入更专业的工具之前,我们来谈谈 Git 的 resetcheckout 命令。当你第一次遇到这些命令时,它们是 Git 中最令人困惑的部分。它们做的事情太多了,以至于似乎不可能真正理解它们并正确使用它们。为此,我们推荐一个简单的比喻。

三个树

一个更容易思考 resetcheckout 的方法是通过 Git 作为三个不同树的内容管理器的心理框架。这里的 “树” 实际上指的是 “文件集合”,而不是特定的数据结构。在一些情况下,索引的行为并不完全像一棵树,但为了我们的目的,现在这样思考更容易。

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)” 命令,用于较低级别的操作,日常工作中并不常用,但它们可以帮助我们了解背后发生的事情。

索引

索引(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,它更多的是一个幕后命令,向你展示当前索引的样子。

从技术上讲,索引不是树结构,它实际上是以扁平化的清单实现的,但就我们的目的而言,它已经足够接近了。

工作目录

最后,你有你的工作目录(working directory)(通常也称为“工作树(working tree)”)。 其他两棵树将其内容以高效但不方便的方式存储在 .git 文件夹中。 工作目录将它们解压成实际文件,这使得你更容易编辑它们。 将工作目录视为一个沙箱(sandbox),你可以在将更改提交到暂存区(索引)然后再提交到历史记录之前尝试更改。

$ 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,我们将以红色看到该文件,因为“尚未暂存以进行提交的更改”,因为该条目在索引和工作目录之间存在差异。 接下来,我们在其上运行 git add 以将其暂存到我们的索引中。

Staging change to index
图 142. 暂存对索引的更改

此时,如果我们运行 git status,我们将看到绿色下的“要提交的更改”,因为索引和 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,但也取消暂存(unstaged)了所有内容。 你回滚到你运行所有 git addgit commit 命令之前。

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

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

Hard reset
图 147. 硬重置

所以让我们考虑一下刚刚发生了什么。 你撤消了你的最后一个提交、git addgit commit 命令,以及你在工作目录中所做的所有工作。

重要的是要注意,此标志 (--hard) 是使 reset 命令危险的唯一方法,也是 Git 实际上会破坏数据的极少数情况之一。 任何其他 reset 调用都可以很容易地撤消,但 --hard 选项不能,因为它会强制覆盖工作目录中的文件。 在这种特殊情况下,我们仍然在 Git DB 的提交中保留了文件的 v3 版本,我们可以通过查看我们的 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. 带有路径的混合重置

这具有取消暂存(unstaging)文件的实际效果。 如果我们查看该命令的图表并考虑 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 选项,以逐块(hunk-by-hunk)的方式取消暂存内容。 因此,你可以选择性地取消暂存或恢复内容。

合并提交(Squashing)

让我们看看如何使用这种新发现的力量做一些有趣的事情 - 合并提交。

假设你有一系列提交,其消息类似“oops.”、“WIP”和“忘记了这个文件”。你可以使用 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 master,则 develop 本身现在将指向与 master 相同的提交。如果我们改为运行 git checkout master,则 develop 不会移动,而是 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”列在命令移动 HEAD 指向的引用(分支)时显示“REF”,在命令移动 HEAD 本身时显示“HEAD”。特别注意“WD 安全?”列 — 如果它显示 ,请在运行该命令之前花点时间思考。

HEAD 索引 工作目录 WD 安全?

提交级别

reset --soft [commit]

REF

reset [commit]

REF

reset --hard [commit]

REF

checkout <commit>

HEAD

文件级别

reset [commit] <paths>

checkout [commit] <paths>

scroll-to-top