对于刚接触 Git 的人来说,这是可怕的。但别担心:所有的提交都还在。
包括 Visual Studio 在内的各种 GUI 会阻止对 Git 的访问(这可能是好是坏,取决于您的观点),因此您无法看到真正发生的事情,我也不使用 这些 GUI,因为它们使您无法看到正在发生的事情,所以我不能准确地说,您的 GUI 中的每个单击按钮都做了什么。然而,Git 是这样工作的:
你可能——事实上,你应该——在这一点上提出异议:我们如何知道 HEAD 是指 commit 还是 分支名称? Git 的答案是:我会根据我目前想要的任何一个来选择一个或另一个。有些东西需要一个分支名称,在这种情况下,HEAD 变成分店名称。有些事情需要一个提交,在这种情况下HEAD变成了提交。基本上,Git 有两种内部方式来询问HEAD 现在是什么。一个给出分支名称的答案,例如 master 或 main 或其他任何东西,另一个给出原始提交哈希 ID。
好的,所以,考虑到这一点,我们现在记得git log 打印出这样的日志:
commit eb27b338a3e71c7c4079fbac8aeae3f8fbb5c687 (...)
Author: ...
...
commit fe3fec53a63a1c186452f61b0e55ac2837bf18a1
...
也就是说,我们看到所有这些奇怪的哈希 ID 一次一个地溢出。哈希 ID 是每个提交的真实姓名。每个提交都有一个全局唯一的哈希 ID:没有两个 不同 提交 永远 允许拥有相同的一个。这就是为什么哈希 ID 如此之大和丑陋的原因。它们看起来是随机的。它们实际上并不是随机的,但它们是不可预测的。3
像main 这样的分支名称会转换为提交哈希ID。原始哈希 ID 已经是一个哈希 ID。无论哪种方式,只要给定正确的哈希 ID,Git 都可以找到提交。
每个提交都包含每个文件的完整快照,4 加上一些关于提交本身的元数据: 信息,例如提交者、提交时间和日志他们当时可以写的信息。对于 Git 本身来说至关重要的是,此元数据中的一项是上一次提交的原始哈希 ID。
这里还有一个关于提交的随机事实需要记住:一旦提交,任何提交的任何部分都不能更改。这就是哈希 ID 的实际工作方式,这对于 Git 作为一个 分布式 版本控制系统至关重要。但这也意味着任何 Git 提交都不能包含其未来 children 提交的原始哈希 ID,因为当我们创建提交时,我们不知道那些会是什么。提交可以存储他们父母的“名字”(哈希 ID),因为我们在创建孩子时确实知道他们的祖先。
这对我们来说意味着 commits 记住他们的 parents,这形成了一种向后看的链。我们所要做的就是记住 latest 提交的原始哈希 ID。当我们这样做时,我们最终会得到一个可以像这样绘制的链:
... <-F <-G <-H <--main
这里,name main 保存了 最新提交 的真实哈希 ID,出于绘图目的,我们将其称为 H。提交H 又持有较早提交G 的哈希ID,后者持有仍较早提交F 的哈希ID,依此类推。
我们现在可以看到git log 是如何工作的:它从当前提交 H 开始,由当前分支 main 选择。为了使main成为当前分支,我们附加特殊名称HEAD到名称main:
...--F--G--H <-- main (HEAD)
Git 使用HEAD 查找main,使用main 查找H,并显示H。然后 Git 使用H 找到G 并向我们显示G;然后它使用G 找到F,以此类推。
当我们想要查看任何历史提交时,我们通过哈希 ID 将其挑选出来,然后告诉 Git:将 HEAD 直接附加到该提交。我们可以这样画:
...--F <-- HEAD
\
G--H <-- main
当我们现在运行git log 时,Git 将HEAD 转换为一个哈希ID——这一次它直接找到了;没有附加分支名称——向我们展示了提交F。然后git log 从那里继续前进,向后。提交G 和H 在哪里?他们无处可寻!
但是没关系:如果我们运行 git log main,git log 以名称 main 开头,而不是名称 HEAD。找到提交H,git log 显示;然后git log 移动到G,依此类推。或者,我们甚至可以运行:
git log --branches
或:
git log --all
让git log找到所有分支或所有引用(“引用”包括分支和标签,但也包括其他种类的名称)。
(这带来了另一个单独的蠕虫罐,这完全是关于git log 如何处理“想要”“同时”显示多个提交的情况。我不会去那里在这个答案中。)
这种“查看历史提交”模式在 Git 中称为分离 HEAD 模式。这是因为特殊名称 HEAD 不再附加到分支名称。要重新附加您的HEAD,您只需选择一个分支名称,使用git checkout 或(Git 2.23 或更高版本)git switch:
git switch main
例如。您现在已经检查了分支名称 main 选择的提交,并且 HEAD 现在重新附加到名称 main。
在我们停下来之前,还有一件非常重要的事情要学习,那就是:树枝是如何生长的。但让我先把脚注弄明白。
1这个规则有一个例外,在一个完全没有提交的新的、完全空的存储库中是必需的。以后可以在非空存储库中以一种奇怪的方式使用该异常。不过你不会使用它。
2小写变体 head 通常在 Windows 和 macOS 上“有效”(但在 Linux 和其他系统上无效)。但是,这是具有欺骗性的,因为如果您开始使用 git worktree 功能,head(小写)不能正常工作——它有时会让你错误提交!——而 HEAD(大写)确实。如果您不喜欢全大写,请考虑使用简写字符@,您可以使用它来代替HEAD。
3Git 在这里使用加密哈希:与加密货币中发现的相同类型的东西,虽然没有那么严格(Git 目前仍在使用 SHA-1,它在加密术语中已经过时了)。
4快照以一种特殊的、只读的、仅限 Git 的、压缩的和去重复的格式存储。 Git 显示提交为“自上次提交以来的更改”,但存储提交为快照。
Git 分支如何成长
假设我们有以下情况:
...--G--H <-- main (HEAD)
我们现在想进行一个新的提交,但我们想把它放在一个新的分支上。所以我们首先以 Git 的身份创建一个新的分支名称,并将该名称也指向提交 H:
git branch develop
导致:
...--G--H <-- develop, main (HEAD)
现在我们选择develop 作为名称以附加HEAD,并使用git checkout 或git switch:
...--G--H <-- develop (HEAD), main
请注意,我们仍在使用 commit H。我们现在只是通过另一个 name 来使用它。通过H 并包括H 的提交都在两个分支上。
我们现在进行一个新的提交,这是我们在 Git 中的常用方式。准备就绪后,我们运行 git commit 并给 Git 一条日志消息以放入新提交的 元数据。立即使用:
- 保存每个文件的快照(照常去重);
- 使用当前提交作为新提交的父,这样我们的新提交——我们称之为
I——将向后指向现有提交H;
- 将我们配置的
user.name 和user.email 添加为这个新提交的作者和提交者,使用“现在”作为日期和时间;
- 使用我们的日志信息;和
- 实际上将所有这些都写为提交,并为其分配唯一的哈希 ID。 (唯一性部分来自日期和时间戳,部分来自输入哈希 ID
H,部分来自我们保存的快照:everything新提交用于组成新的随机哈希 ID,这就是我们无法预测它的原因。)
所以现在我们有了这个新的提交I,指向现有的提交H:
...--G--H
\
I
现在,Git 做了另一件神奇的事,让这一切都正常工作:git commit 将 I 的哈希 ID 写入当前分支名称。也就是说,Git 使用HEAD 查找当前分支的名称,并更新存储在该分支名称中的哈希ID。所以我们现在的照片是:
...--G--H <-- main
\
I <-- develop (HEAD)
名称HEAD 仍然附加到分支名称develop,但分支名称develop 现在选择提交I,而不是提交H。
是提交 I 导致返回提交 H。 name 只是让我们找到 commit。 commits 才是真正重要的:分支名称只是为了让我们找到 last 提交。无论该分支名称中的哈希 ID 是什么,Git 都会说 that commit is last 该分支上的提交。所以既然main 现在说H,H 是main 上的last 提交;因为develop 现在说I,I 是develop 上的最后 次提交。通过H 向上提交仍然在两个分支上,但I 仅在develop 上。
稍后,如果我们愿意,我们可以让 Git 移动名称 main。一旦我们将main 移动到I:
...--G--H--I <-- develop, main
然后所有提交再次在两个分支上。 (这次我省略了HEAD,因为如果两者都选择I,我们可能不在乎我们“在”哪个分支。事实上,我们可以删除任何一个名称——但不能同时删除——因为两个名称都选择了相同的提交,这就是我们需要找到正确的哈希ID。如果我们将这个哈希ID写在某个地方,我们可能不需要任何名称。但这将是......糟糕,充其量。我们有一台 计算机; 让我们 它 为我们保存大而丑陋的哈希 ID,以漂亮整洁的名称。)