-
1. 起步
-
2. Git 基础
-
3. Git 分支
-
4. 服务器上的 Git
- 4.1 协议
- 4.2 在服务器上部署 Git
- 4.3 生成 SSH 公钥
- 4.4 架设服务器
- 4.5 Git Daemon
- 4.6 Smart HTTP
- 4.7 GitWeb
- 4.8 GitLab
- 4.9 第三方托管服务
- 4.10 小结
-
5. 分布式 Git
-
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 工具 - Reset 深度解析
Reset 深度解析
在继续讲解更专业的工具之前,我们先来讨论一下 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 命令是用于低层操作的“管道(plumbing)”命令,在日常工作中并不常用,但它们帮助我们理解这里发生了什么。
索引
_索引_是您**建议的下一个提交**。我们也一直将这个概念称为 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 的典型工作流程是通过操作这三棵树,以逐渐更好的状态记录项目的快照。
让我们将这个过程可视化:假设您进入一个只包含一个文件的新目录。我们将此文件称为 v1,并用蓝色表示。现在我们运行 git init,它将创建一个 Git 仓库,其中包含一个指向未出生 master 分支的 HEAD 引用。
此时,只有工作目录树有任何内容。
现在我们想提交这个文件,所以我们使用 git add 将工作目录中的内容复制到索引。
git add 时被复制到索引然后我们运行 git commit,它会获取索引中的内容并将其保存为永久快照,创建一个指向该快照的提交对象,并更新 master 以指向该提交。
git commit 步骤如果我们运行 git status,我们会看到没有变化,因为所有三棵树都是相同的。
现在我们想修改这个文件并提交它。我们将经历相同的过程;首先,我们在工作目录中修改文件。我们称之为文件的 v2 版本,并用红色表示。
如果我们现在运行 git status,我们会看到文件显示为红色的“Changes not staged for commit”,因为该条目在索引和工作目录之间存在差异。接下来我们对其运行 git add,将其暂存到索引中。
此时,如果我们运行 git status,我们会看到文件在“Changes to be committed”下显示为绿色,因为索引和 HEAD 不同——也就是说,我们建议的下一个提交与我们上次的提交不同。最后,我们运行 git commit 来完成提交。
git commit 步骤,文件已更改现在 git status 将不会给出任何输出,因为所有三棵树又都相同了。
切换分支或克隆也经历类似的过程。当您检出一个分支时,它会更改 **HEAD** 以指向新的分支引用,用该提交的快照填充您的**索引**,然后将**索引**的内容复制到您的**工作目录**。
Reset 的作用
在这样的背景下看 reset 命令会更有意义。
为了这些示例的目的,假设我们再次修改了 file.txt 并第三次提交了它。所以现在我们的历史记录看起来像这样
现在我们来详细讲解一下当您调用 reset 时它具体做了什么。它以简单可预测的方式直接操作这三棵树。它最多执行三个基本操作。
步骤 1:移动 HEAD
reset 要做的第一件事是移动 HEAD 所指向的位置。这与更改 HEAD 本身不同(checkout 就是这样做的);reset 移动 HEAD 所指向的分支。这意味着如果 HEAD 设置为 master 分支(即您当前在 master 分支上),运行 git reset 9e5e6a4 将首先使 master 指向 9e5e6a4。
无论您调用哪种带提交的 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 将在此点停止。这也是默认行为,所以如果您根本不指定任何选项(在这种情况下只使用 git reset HEAD~),命令将在此处停止。
现在再花点时间看看这张图,并理解发生了什么:它仍然撤销了您的最后一次 commit,但同时也_取消暂存_了所有内容。您回滚到了运行所有 git add 和 git commit 命令之前的状态。
步骤 3:更新工作目录(--hard)
reset 将做的第三件事是使工作目录看起来像索引。如果您使用 --hard 选项,它将继续到这个阶段。
所以让我们思考一下刚才发生了什么。您撤销了上次提交,git add 和 git commit 命令,**以及**您在工作目录中完成的所有工作。
重要的是要注意,这个标志(--hard)是使 reset 命令变得危险的唯一方式,也是 Git 实际上会销毁数据的极少数情况之一。任何其他 reset 调用都可以很容易地撤销,但 --hard 选项不能,因为它会强制覆盖工作目录中的文件。在这个特定情况下,我们仍然在 Git 数据库中的一个提交中拥有文件 **v3** 版本,我们可以通过查看 reflog 来找回它,但如果我们没有提交它,Git 仍然会覆盖该文件,并且它将无法恢复。
总结
reset 命令以特定顺序覆盖这三棵树,并在您指示时停止
-
移动 HEAD 指向的分支_(如果使用
--soft则在此停止)_。 -
使索引看起来像 HEAD_(除非使用
--hard否则在此停止)_。 -
使工作目录看起来像索引。
带路径的 Reset
这涵盖了 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 复制到索引。
这实际上产生了_取消暂存_文件的效果。如果我们查看该命令的图示并思考 git add 的作用,它们是完全相反的。
这就是为什么 git status 命令的输出建议您运行此命令来取消暂存文件(有关更多信息,请参阅取消暂存已暂存文件)。
我们也可以不让 Git 假设我们是说“从 HEAD 拉取数据”,而是指定一个特定的提交来拉取该文件版本。我们只需运行 git reset eb43bf file.txt 这样的命令。
这实际上与我们在工作目录中将文件内容恢复到 v1,对其运行 git add,然后再次将其恢复到 v3(而无需实际执行所有这些步骤)所做的事情相同。如果我们现在运行 git commit,它将记录一个将该文件恢复到 v1 的更改,即使我们从未在工作目录中再次拥有它。
同样值得注意的是,与 git add 一样,reset 命令将接受 --patch 选项,以逐个 Hunk 的方式取消暂存内容。因此,您可以选择性地取消暂存或恢复内容。
压缩提交
让我们看看如何利用这种新获得的能力做一些有趣的事情——压缩提交。
假设您有一系列提交,消息诸如“oops.”、“WIP”和“forgot this file”。您可以使用 reset 快速轻松地将它们压缩成一个单一的提交,让您看起来非常聪明。压缩提交展示了另一种实现方式,但在本例中,使用 reset 更简单。
假设您的项目中,第一次提交有一个文件,第二次提交添加了一个新文件并修改了第一个文件,第三次提交再次修改了第一个文件。第二次提交是一个正在进行的工作,您想将其压缩。
您可以运行 git reset --soft HEAD~2 将 HEAD 分支移回更旧的提交(您希望保留的最新提交)
然后简单地再次运行 git commit
现在您可以看到,您的可达历史(您将推送的历史)现在看起来就像您有一个包含 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 和 git reset带路径
另一种运行 checkout 的方式是带文件路径,它和 reset 一样,不会移动 HEAD。它就像 git reset [branch] file 一样,更新索引中该提交处的该文件,但它也会覆盖工作目录中的文件。它会完全像 git reset --hard [branch] file(如果 reset 允许您运行该命令)——它不是工作目录安全的,并且不移动 HEAD。
此外,与 git reset 和 git add 一样,checkout 也将接受 --patch 选项,允许您逐个 hunk 地选择性地恢复文件内容。
总结
希望现在您已经理解并对 reset 命令感到更自在,但可能仍然对它与 checkout 的确切区别有点困惑,并且可能无法记住所有不同调用规则。
这里有一份备忘单,说明哪些命令影响哪些树。“HEAD”列显示“REF”表示该命令移动了 HEAD 所指向的引用(分支),而“HEAD”表示它移动了 HEAD 本身。请特别注意“WD Safe?”列——如果显示**NO**,请在运行该命令前花一秒钟思考。
| HEAD | 索引 | 工作区 | 工作区安全? | |
|---|---|---|---|---|
提交级别 |
||||
|
REF |
否 |
否 |
是 |
|
REF |
是 |
否 |
是 |
|
REF |
是 |
是 |
否 |
|
HEAD |
是 |
是 |
是 |
文件级别 |
||||
|
否 |
是 |
否 |
是 |
|
否 |
是 |
是 |
否 |