我有一个名称为任务的分支。我签出了一天前所做的提交,如下所示:
git checkout 53ba56611358e90d4990c3a8642e46c7bc93e514
然后我写了一些代码并像这样提交它:
git add .
git commit -m "message"
然后我切换了分支:
git checkout dev
现在我不知道如何回到那个分支。 当我移动到分支任务时,HEAD 不是我的最后一次提交,但 HEAD 提交与我之前提交相同:
git checkout 53ba56611358e90d4990c3a8642e46c7bc93e514
您需要在 reflog 中挖掘HEAD
,找到"丢失"提交的哈希 ID,并将哈希 ID 保存在某个地方——可能,在一个新的分支名称中,如果只是暂时的。 那是:
$ git reflog
<inspect the output>
$ git show <hash> # for some hash ID that looks likely
# if that's the right hash:
$ git branch temp-save-branch <hash>
<angle brackets>
中的每个内容都表示您不会在命令提示符下按字面键入的内容,井号#
之后的每个内容都是注释(您根本不会键入)。
要理解这一点,请继续阅读。
Git 是关于提交的
那些刚接触 Git 的人,就像你可能一样,经常认为 Git 是关于文件的。 事实并非如此,尽管提交确实存储文件(但不是文件夹,只是文件! 或者,他们可能认为 Git 是关于分支的。 这也与这些无关,尽管提交被组织成分支,分支名称帮助我们和 Git找到提交。 这意味着了解什么是 Git 提交以及它对您有什么作用对您来说至关重要。
它是什么,是一个编号的东西(内部提交类型,因为实际上有四种内部类型,并不是说你通常会使用任何东西,除了提交和 - 有时 - 标签)。 不过,这些数字并不是简单的计数数字:我们没有提交 #1,然后是 #2,然后是 #3,依此类推。 相反,每个数字都非常大,看起来很随机,通常用十六进制表示,例如53ba56611358e90d4990c3a8642e46c7bc93e514
.
提交为您所做的是存储两件事:
首先,每次提交都会存储所有文件的完整快照。 这些文件采用特殊的只读、仅 Git、压缩和重复数据删除形式。重复数据消除解决了大多数人对每个提交一次又一次地存储每个文件这一事实提出的第一个反对意见:如果您进行包含一千个文件的提交,然后更改一个文件并进行新提交,则新提交将重用999 个文件。 它实际上只需要存储一个新文件。 两个提交仍然存储所有 1000 个文件,只是其中 999 个是共享的。
由于这些文件是只读、仅 Git、压缩和重复数据删除的,因此您实际上无法使用它们。这就是为什么
git checkout
做它所做的事情(我们稍后会看到)。 它们可以被共享,因为它们是只读的:它们实际上无法更改。 事实上,任何提交的任何部分都无法更改,包括......每个提交的另一部分是元数据,或有关此特定提交本身的信息。 例如,元数据是 Git 存储提交人员的姓名和电子邮件地址的地方。 Git 每次提交保留两个名称 + 电子邮件 + 日期时间戳三元组:一个用于作者,一个用于提交者,即使两者通常相同。 Git 还在此处保留了一些其他内部维护项,以及作者/提交者选择包含的任何提交日志消息,以描述他们进行该特定提交的原因。
对于 Git 自己的操作至关重要,每个提交都存储上一次提交(或一定数量的先前提交)的哈希 ID(或一些)的哈希 ID。 大多数提交只存储一个这样的哈希ID,这就是我们将在这里看到的全部内容。 我们和 Git 将此存储的哈希 ID 称为提交的父级。 这让孩子犯了罪。
请注意,子项知道其父项,但父项不认识其子项。 这是因为所有提交都是完全只读的,因为 Git 的提交编号系统必然是完全只读的。 当我们让一个子提交时,它的父级已经存在,所以子级可以存储该哈希 ID。 但是当我们生孩子时,我们不知道它是否会有未来的孩子,更不用说他们的哈希ID会是什么了。 某些未来提交的哈希 ID 不仅取决于该提交的内容,还取决于我们(或任何人)进行的确切日期和时间。 如果我们试图预测未来提交的哈希 ID,我们必须得到正确的时间,精确到秒(而且,由于一些花哨的密码学数学,即使这样也不够)。
所有这些都意味着 Git向后工作。 每个提交都会记住其父级,这意味着提交形成向后看的链。 如果某个链中的最后一个提交有一些哈希 IDH
,我们可能会像这样绘制该提交:
<-H
从H
中出来的向后小箭头指向它的父级,它有一些其他随机的哈希ID;让我们称之为G
并将其绘制:
<-G <-H
当然,G
同样指向其父级。 让我们称之为F
并绘制它:
... <-F <-G <-H
这一直追溯到第一次提交,作为第一次提交,它没有父提交,所以它只是懒得向后指向。 那么,整个链条是:
A--B--C--D--E--F--G--H
我们变得懒惰并停止将箭头绘制为箭头(主要是因为它在纯文本 StackOverflow 答案中太难了)。
这样做的好处是,给定链中最后一个提交的哈希 ID(在这种情况下H
),我们可以让Git 本身找到所有较早的提交。 Git 使用我们(以某种方式)给它的哈希 ID 来定位H
.H
本身定位G
,定位F
,依此类推,一直追溯到A
。git log
在显示提交A
后停止,因为没有什么可显示的了,尽管我们通常会在它回到起点之前很久就退出git log
,在一个有数千次提交的真实存储库中。
但问题是,Git确实需要H
的哈希 ID ,才能快速找到它。 在一个足够小的存储库中,可以遍历每次提交并找出哪些提交"在最后",但这既困难又慢:在一个有数十万次提交的大型存储库中,需要几秒钟,甚至几分钟。 我们喜欢在纳秒内获得答案,或者在最坏的情况下可能是几毫秒。 所以我们需要将H
的哈希ID保存在某处。 我们可以把它写在纸上,或者在工作中把它写在白板上——但这太愚蠢了:我们有一台电脑!让我们让计算机将H
的哈希ID保存在某处。
输入分支名称
Git 中的分支名称只是我们让计算机保存最后一个哈希 ID 的一种方式。 也就是说,如果H
是一个有趣的提交,因为它是最后一次提交——比如说——main
,我们告诉 Git 将H
的哈希 ID 涂鸦到名为main
的某个表中。 我们这样画:
...--G--H <-- main
如果我们想要一个新的分支,例如dev
,现在,我们可以创建第二个名称,并将该名称存储H
哈希 ID:
...--G--H <-- dev, main
乍一看这似乎有点傻,但请继续阅读。
HEAD
确定当前分支
一旦我们有了几个分支名称,我们需要一种方法让 Git 知道我们正在使用哪个名称。 为了让 Git 知道,我们将HEAD
的特殊名称"附加"到一个分支名称,如下所示:
...--G--H <-- dev, main (HEAD)
我们现在on branch main
,正如git status
所说。 艺术
git checkout dev
我们现在on branch dev
,因为情况发生了变化:
...--G--H <-- dev (HEAD), main
无论哪种方式,git checkout
都将提取提交H
的文件,供我们查看和处理。 毕竟,提交H
中的文件是只读的,只能由 Git 本身使用。 它们不是正常的文件夹形式。1因此git checkout
必须将文件从提交中复制出来。 我们不会在这里详细介绍,但这本身就有点复杂。
1Git也可能能够存储我们的操作系统无法处理的名称的文件。 如果我们在 Windows 或 macOS 上,这种情况经常发生:一些 Linux 用户制作一个名为aux.h
,Windows 无法处理,或者schön
但拼写错误的 UTF-8 字节序列,因此我们的 macOS 系统会感到困惑。 或者,他们同时创建readme
和README
,而我们的案例折叠文件系统无法处理它。
HEAD
也决定了当前提交
请注意,在这一点上,只有一个提交可以使用,无论我们告诉 Gitgit checkout main
还是git checkout dev
:
...--G--H <-- dev (HEAD), main
因此,无论我们使用哪种方法,我们都会拥有来自H
. 但是,现在让我们进行新的提交,当我们处于此设置中时,就像这样。 我们编辑一些文件,出于我们尚未涵盖的原因运行git add
,然后运行git commit
. Git:
- 打包每个文件的快照,包括我们更改和添加的文件;
- 从我们这里收集日志消息,如果我们还没有提供它;
- 添加当前提交哈希 ID(无论
H
缩写)作为父级; - 添加其余元数据;和
- 写出一个新的提交。
写出新提交的行为会产生一个新的、随机的哈希 ID,但我们只称这个新提交为I
。 新提交I
H
作为其父级。H
是当前的提交,因为HEAD
说dev
,dev
说H
。 因此,一旦这个新提交进入存储库,提交的顺序现在为:
...--G--H
I
但是因为我们运行了git commit
,并且因为HEAD
附加到dev
,所以git commit
的最后一步是将新提交的哈希ID写入名称dev
。 所以现在我们有:
...--G--H <-- main
I <-- dev (HEAD)
请注意,虽然HEAD
仍附加到名称dev
,但名称dev
现在选择新的提交I
。
新提交I
是现在dev
的最后一次提交。 提交H
仍然是main
上的最后一次提交。 通过H
提交的提交,在两个分支上,仍然在两个分支上。 但是新的提交I
现在才dev
。
如果稍后我们让 Git 将main
名称向前移动一步——这有点棘手,因为 Git 确实可以向后工作,但我们可以做到——我们最终会得到:
...--G--H--I <-- dev, main
并且所有提交将再次位于两个分支上(之后我们可以删除两个名称中的任何一个:另一个名称足以找到提交)。 但是现在,至少现在,我们需要这两个名称,以便知道H
是main
的最后一次提交,I
是dev
的最后一次提交。
分离式头模式
使用提交(为我们永久保存每个文件)完成所有这些操作的要点之一是,我们可以"回到过去",看看我们的软件昨天、上周或去年的情况。 为此,我们使用您运行的命令类型:
git checkout 53ba56611358e90d4990c3a8642e46c7bc93e514
这使用 Git 所谓的分离 HEAD 模式。 在这里,Git 没有将HEAD
附加到某个分支名称,而是将原始哈希 ID 直接存储到HEAD
中。 我们可以这样画:
...--F <-- HEAD
G--H <-- dev
例如。阿拉伯数字Git 从我们之前的任何提交中删除文件(比如说H
),并使用提交F
中的文件填充我们的工作树,现在我们可以看到我们的项目当时的样子。
但是:如果我们在这种模式下进行新的提交怎么办? 这正是你所做的。 Git 的答案是:它以与往常相同的方式进行新提交。 您进行一些更改,git add
更新的文件,然后运行git commit
,Git 打包快照并添加元数据,并进行新的提交。
新提交的父级是当前提交,F
. 因此,让我们现在绘制新的提交I
。 它必须返回以提交F
:
I
/
...--F
G--H
这部分非常简单。 但是名字会怎样呢?好吧,我们不在dev
,所以dev
继续指向H
. Git 通常会将I
的哈希 ID 写入HEAD
附加到的名称中,但HEAD
不会附加到任何名称。 所以 Git 只是直接将新提交的哈希 ID 写入HEAD
本身,如下所示:
I <-- HEAD
/
...--F
G--H <-- dev
如果随后运行git checkout dev
、 Git:
- 从工作树中删除提交
I
的文件; - 用
H
中的文件填充工作树;和 - 将
HEAD
附加到名称dev
。
结果是:
I ???
/
...--F
G--H <-- dev (HEAD)
提交I
在哪里? 它仍然在那里。 问题是,它没有名字。 您必须找到其原始哈希ID,并将其提供给git checkout
或git branch
。 如果实际的哈希 IDabcdabc
,则可以运行:
git checkout abcdabc
你会回到分离的 HEAD 模式,HEAD
指向再次提交I
。 然后,您将运行:
git branch save-this
或其他什么,然后得到:
I <-- HEAD, save-this
/
...--F
G--H <-- dev
或者,您可以走捷径并运行:
git branch save-this abcdabc
并获得:
I <-- save-this
/
...--F
G--H <-- dev (HEAD)
由于我们没有做git checkout
,HEAD
仍然附加到dev
并提交H
仍然是当前的提交,但提交I
(或abcdabc
)现在有一个名称并且很容易找到。
这里的诀窍是你必须以某种方式找到提交的哈希 ID。 这就是git reflog
的用武之地:运行git reflog
,没有参数,溢出特殊名称HEAD
的 reflog 条目。 其中之一将是你所做的提交的哈希 ID。 当然,所有提交哈希ID看起来都像随机垃圾,因此您必须依靠其他东西才能找到正确的ID。 例如,git reflog
的输出包括提交的一小段内容,或者您可以在每个提交哈希 ID 上运行git show
,因为git show
将显示提交。
2我省略了main
,但可能存在main
或master
,因此指向存储库中的某个地方。 这对我们的目的并不重要。 只要您不使用名称main
或master
来记住某些特定的提交,您甚至可以将其完全删除。 我们通常最终会在某个集中式仓库(可能在 GitHub 上)克隆的存储库中main
或master
,因为他们有main
或master
,而我们的 Git 根据他们的main
或master
。 但这意味着我们也有一个origin/main
或origin/master
,并且该名称会记住正确的哈希 ID。 我们不需要自己的main
或master
,我们可以在大多数情况下使用该非分支名称。 只有当我们需要它进行"分支名称-y"操作时,我们才需要自己的分支名称,例如进行新的提交。
旁注:git show
显示差异
我已经说过好几次了,每次提交都是每个文件的完整快照。 然而,如果我们运行git showcommit-specifier
,我们会看到这样的东西:
$ git show HEAD
commit eb27b338a3e71c7c4079fbac8aeae3f8fbb5c687 ...
Author: ...
Date: ...
...
diff --git a/Documentation/RelNotes/2.33.0.txt b/Documentation/RelNotes/2.33.0.txt
index d4c56de5cb..a69531c1ef 100644
--- a/Documentation/RelNotes/2.33.0.txt
+++ b/Documentation/RelNotes/2.33.0.txt
@@ -30,6 +30,9 @@ UI, Workflows & Features
* The userdiff pattern for C# learned the token "record".
+ * "git rev-list" learns to omit the "commit <object-name>" header
+ lines from the output with the `--no-commit-header` option.
[snip]
如果提交是快照(确实如此),为什么我们会看到差异? 答案是:因为 Git 在运行git show
时会找到一个要显示的内容(或git log -p
,就此而言)。 如果我们在某个提交:
...--G--H <-- main (HEAD)
Git 很容易将HEAD
变成main
,从而变成哈希 IDH
。 但是 Git 也很容易使用哈希 IDH
来检索提交H
的元数据,Git 从中查找哈希 IDG
。 Git 现在可以使用G
和H
来检索G
和H
中的整个快照,现在它所要做的就是比较它们。3这种比较的结果通常比快照中的文件集小得多,而且对人类也更有用。 这就是git show
所展示的。
请注意,合并提交是(根据定义)至少有两个父级的提交,而不是通常的父级。 在这种情况下,不清楚哪个父级 Git 应该与子项进行比较。 至少在默认情况下,git log -p
所做的很简单,尽管通常无用:它根本不费心显示差异。git show
所做的更复杂;我们不会在这里介绍这一点。
3"All",我说,好像这只是编程的一个小问题。 但是,提交快照的内部格式及其重复数据消除技巧使 Git 可以轻松判断两个文件是否相同。 因此,Git 可以轻松跳过每个未更改的文件。 Git 只需要在两个快照中对两个不相同的文件运行复杂的差异引擎算法。