Git
章节 ▾ 第二版

7.7 Git 工具 - 重识重置

重识重置

在继续讨论更专业的工具之前,让我们先谈谈 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 命令是“底层”命令,用于更低级的操作,在日常工作中很少使用,但它们有助于我们了解这里发生了什么。

索引

索引是您的**提议的下一个提交**。我们也一直将此概念称为 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,它更像是一个幕后命令,向您展示当前索引的样子。

索引在技术上并不是树形结构——它实际上是作为扁平化的清单实现的——但就我们的目的而言,它足够接近。

工作目录

最后,您有您的工作目录(也常称为“工作树”)。另外两棵树以一种高效但不便的方式存储其内容,位于 .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 命令更有意义。

为了这些示例的目的,假设我们再次修改了 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 选项却无法撤消,因为它会强制覆盖工作目录中的文件。在这种特定情况下,我们仍然在 Git 数据库中的一个提交中拥有文件的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. 带路径的混合重置

这实际上具有取消暂存文件的效果。如果我们查看该命令的图并考虑 git add 的作用,它们是完全相反的。

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

这就是为什么 git status 命令的输出建议您运行此命令以取消暂存文件的原因(有关这方面的更多信息,请参阅 取消暂存已暂存的文件)。

我们可以很容易地不让人们认为我们的意思是“从 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 选项以逐块取消暂存内容。因此,您可以选择性地取消暂存或还原内容。

压缩

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

假设你有一系列提交,其消息类似于“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”列在该命令移动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