-
A1. 附录 A:其他环境中的 Git
- A1.1 图形界面
- A1.2 Visual Studio 中的 Git
- A1.3 Visual Studio Code 中的 Git
- A1.4 IntelliJ/PyCharm/WebStorm/PhpStorm/RubyMine 中的 Git
- A1.5 Sublime Text 中的 Git
- A1.6 Bash 中的 Git
- A1.7 Zsh 中的 Git
- A1.8 PowerShell 中的 Git
- A1.9 总结
-
A2. 附录 B:将 Git 嵌入你的应用程序
-
A3. 附录 C:Git 命令
7.7 Git 工具 - 重识重置
重识重置
在继续讨论更专业的工具之前,让我们先谈谈 Git 的 reset
和 checkout
命令。当您第一次遇到这些命令时,它们是 Git 中最令人困惑的部分之一。它们的功能非常多,以至于似乎无法真正理解它们并正确地使用它们。为此,我们建议使用一个简单的隐喻。
三棵树
思考 reset
和 checkout
的一种更简单的方法是,将 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-file
和 ls-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](/book/en/v2/images/reset-workflow.png)
让我们可视化这个过程:假设您进入一个新目录,其中包含一个文件。我们将此文件称为v1,并以蓝色表示。现在我们运行 git init
,它将创建一个 Git 仓库,其中包含一个指向未出生 master
分支的 HEAD 引用。
![Newly-initialized Git repository with unstaged file in the working directory](/book/en/v2/images/reset-ex1.png)
此时,只有工作目录树包含任何内容。
现在我们想提交此文件,因此我们使用 git add
将工作目录中的内容复制到索引中。
![File is copied to index on `git add`](/book/en/v2/images/reset-ex2.png)
git add
时将文件复制到索引然后我们运行 git commit
,它获取索引的内容并将其保存为永久快照,创建一个指向该快照的提交对象,并将 master
更新为指向该提交。
![The `git commit` step](/book/en/v2/images/reset-ex3.png)
git commit
步骤如果我们运行 git status
,我们将看不到任何更改,因为所有三个树都是相同的。
现在我们想更改该文件并提交它。我们将经历相同的过程;首先,我们在工作目录中更改文件。我们将此文件称为v2,并以红色表示。
![Git repository with changed file in the working directory](/book/en/v2/images/reset-ex4.png)
如果我们现在运行 git status
,我们将看到该文件以红色显示为“Changes not staged for commit”,因为该条目在索引和工作目录之间存在差异。接下来,我们对其运行 git add
以将其暂存到我们的索引中。
![Staging change to index](/book/en/v2/images/reset-ex5.png)
此时,如果我们运行 git status
,我们将看到该文件以绿色显示在“Changes to be committed”下,因为索引和 HEAD 不同——也就是说,我们提出的下一个提交现在与我们的上一次提交不同。最后,我们运行 git commit
以完成提交。
![The `git commit` step with changed file](/book/en/v2/images/reset-ex6.png)
git commit
步骤现在 git status
将不会输出任何内容,因为所有三个树再次相同。
切换分支或克隆会经历类似的过程。当您检出一个分支时,它会更改HEAD 以指向新的分支引用,用该提交的快照填充您的索引,然后将索引的内容复制到您的工作目录中。
重置的作用
在这样的上下文中,reset
命令更有意义。
为了这些示例的目的,假设我们再次修改了 file.txt
并第三次提交了它。所以现在我们的历史记录如下所示
![Git repository with three commits](/book/en/v2/images/reset-start.png)
现在让我们详细了解一下当您调用 reset
时它所做的操作。它以一种简单且可预测的方式直接操作这三棵树。它最多执行三个基本操作。
步骤 1:移动 HEAD
reset
将执行的第一件事是移动 HEAD 指向的内容。这与更改 HEAD 本身(这是 checkout
所做的)不同;reset
移动 HEAD 指向的分支。这意味着如果 HEAD 设置为 master
分支(即您当前位于 master
分支上),运行 git reset 9e5e6a4
将首先使 master
指向 9e5e6a4
。
![Soft reset](/book/en/v2/images/reset-soft.png)
无论您调用哪种形式的带有提交的 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](/book/en/v2/images/reset-mixed.png)
如果您指定 --mixed
选项,reset
将在此处停止。这也是默认值,因此如果您根本没有指定任何选项(在这种情况下仅为 git reset HEAD~
),命令将在此处停止。
现在再次花点时间查看该图并了解发生了什么:它仍然撤消了您的上一次 commit
,但也取消暂存了所有内容。您回滚到运行所有 git add
和 git commit
命令之前。
步骤 3:更新工作目录(--hard
)
reset
将执行的第三件事是使工作目录看起来像索引。如果您使用 --hard
选项,它将继续到此阶段。
![Hard reset](/book/en/v2/images/reset-hard.png)
因此,让我们考虑一下刚刚发生了什么。您撤消了上一次提交、git add
和 git commit
命令,以及您在工作目录中所做的所有工作。
需要注意的是,此标志(--hard
)是使 reset
命令变得危险的唯一方法,也是 Git 实际上会破坏数据的极少数情况之一。其他任何 reset
调用都可以很容易地撤消,但 --hard
选项却无法撤消,因为它会强制覆盖工作目录中的文件。在这种特定情况下,我们仍然在 Git 数据库中的一个提交中拥有文件的v3版本,我们可以通过查看我们的 reflog
来找回它,但如果我们没有提交它,Git 仍然会覆盖该文件,并且将无法恢复。
回顾
reset
命令按特定顺序覆盖这三棵树,并在您告诉它停止时停止
-
移动 HEAD 指向的分支(如果为
--soft
,则在此处停止)。 -
使索引看起来像 HEAD(除非为
--hard
,否则在此处停止)。 -
使工作目录看起来像索引。
带路径的重置
这涵盖了 reset
在其基本形式下的行为,但您也可以为其提供要作用于的路径。如果您指定路径,reset
将跳过步骤 1,并将其余操作限制在特定文件或一组文件上。这实际上是有道理的——HEAD 只是一个指针,您不能指向一个提交的一部分和另一个提交的一部分。但索引和工作目录可以被部分更新,因此 reset 继续执行步骤 2 和 3。
因此,假设我们运行 git reset file.txt
。此表单(因为您没有指定提交 SHA-1 或分支,并且您没有指定 --soft
或 --hard
)是 git reset --mixed HEAD file.txt
的简写,它将
-
移动 HEAD 指向的分支(已跳过)。
-
使索引看起来像 HEAD(在此处停止)。
所以它基本上只是将 file.txt
从 HEAD 复制到索引。
![Mixed reset with a path](/book/en/v2/images/reset-path1.png)
这实际上具有取消暂存文件的效果。如果我们查看该命令的图并考虑 git add
的作用,它们是完全相反的。
![Staging file to index](/book/en/v2/images/reset-path2.png)
这就是为什么 git status
命令的输出建议您运行此命令以取消暂存文件的原因(有关这方面的更多信息,请参阅 取消暂存已暂存的文件)。
我们可以很容易地不让人们认为我们的意思是“从 HEAD 中拉取数据”,而是指定要从中拉取文件版本的特定提交。我们只需运行类似 git reset eb43bf file.txt
的命令即可。
![Soft reset with a path to a specific commit](/book/en/v2/images/reset-path3.png)
这实际上与我们在工作目录中将文件的内容还原到v1、对其运行 git add
,然后将其还原回v3的效果相同(无需真正执行所有这些步骤)。如果我们现在运行 git commit
,它将记录一个将该文件还原到v1的更改,即使我们实际上从未在工作目录中再次拥有它。
还需要注意的是,与 git add
一样,reset
命令将接受 --patch
选项以逐块取消暂存内容。因此,您可以选择性地取消暂存或还原内容。
压缩
让我们看看如何利用这个新获得的能力做一些有趣的事情——压缩提交。
假设你有一系列提交,其消息类似于“oops.”、“WIP”和“forgot this file”。你可以使用reset
快速轻松地将它们压缩成一个提交,让你看起来非常聪明。 压缩提交展示了另一种执行此操作的方法,但在本例中,使用reset
更简单。
假设你有一个项目,其中第一个提交包含一个文件,第二个提交添加了一个新文件并修改了第一个文件,第三个提交再次修改了第一个文件。第二个提交是工作进度,你想将其压缩。
![Git repository](/book/en/v2/images/reset-squash-r1.png)
你可以运行git reset --soft HEAD~2
将HEAD分支移回较旧的提交(你想要保留的最新提交)
![Moving HEAD with soft reset](/book/en/v2/images/reset-squash-r2.png)
然后只需再次运行git commit
![Git repository with squashed commit](/book/en/v2/images/reset-squash-r3.png)
现在你可以看到,你的可访问历史记录(你将推送的历史记录)看起来像是你有一个包含file-a.txt
v1版本的提交,然后第二个提交同时将file-a.txt
修改为v3并添加了file-b.txt
。包含文件v2版本的提交不再存在于历史记录中。
试试看
最后,你可能想知道checkout
和reset
之间有什么区别。与reset
类似,checkout
操作三个树,并且根据你是否为命令提供文件路径而略有不同。
无路径
运行git checkout [branch]
与运行git reset --hard [branch]
非常相似,因为它们都会更新所有三个树,使其看起来像[branch]
,但有两个重要的区别。
首先,与reset --hard
不同,checkout
是工作目录安全的;它会检查是否正在删除已修改的文件。实际上,它比这更智能——它尝试在工作目录中执行一个简单的合并,因此你没有更改的所有文件都将被更新。另一方面,reset --hard
将简单地替换所有内容,而无需检查。
第二个重要区别是checkout
如何更新HEAD。reset
将移动HEAD指向的分支,而checkout
将移动HEAD本身以指向另一个分支。
例如,假设我们有master
和develop
分支,它们指向不同的提交,我们目前在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`](/book/en/v2/images/reset-checkout.png)
git checkout
和 git reset
带路径
运行checkout
的另一种方法是使用文件路径,这与reset
一样,不会移动HEAD。它就像git reset [branch] file
一样,它使用该提交中的该文件更新索引,但它还会覆盖工作目录中的文件。这将与git reset --hard [branch] file
完全相同(如果reset
允许你运行该命令)——它不是工作目录安全的,并且不会移动HEAD。
此外,与git reset
和git add
一样,checkout
将接受--patch
选项,允许你逐块选择性地恢复文件内容。
总结
希望现在你已经理解并对reset
命令有了更多了解,但可能仍然对它与checkout
到底有何不同感到有点困惑,并且可能无法记住所有不同调用的规则。
以下是一个备忘单,说明哪些命令会影响哪些树。“HEAD”列在该命令移动HEAD指向的引用(分支)时显示“REF”,在移动HEAD本身时显示“HEAD”。请特别注意“WD 安全?”列——如果显示否,请在运行该命令之前先思考一下。
HEAD | 索引 | 工作目录 | WD 安全? | |
---|---|---|---|---|
提交级别 |
||||
|
REF |
否 |
否 |
是 |
|
REF |
是 |
否 |
是 |
|
REF |
是 |
是 |
否 |
|
HEAD |
是 |
是 |
是 |
文件级别 |
||||
|
否 |
是 |
否 |
是 |
|
否 |
是 |
是 |
否 |