要正确理解为什么这会让您的文件“暂存以待提交”,您需要了解并牢记以下关于 Git 的十件事中的全部:
-
重要的是提交。
-
所有提交——事实上,任何类型的所有内部 Git 对象——都是严格只读的。
-
分支名称和其他名称只是帮助您(和 Git)找到提交。
-
它的工作方式是每个提交都有一个唯一的编号:一个大的、丑陋的、看起来随机的 hash ID,它让 Git 在大型数据库中查找提交对象(@987654321 @) 的所有 Git 对象,包括提交对象和其他支持对象。 名称——分支名称、远程跟踪名称、标签名称或任何其他名称——拥有一个哈希 ID。
-
提交自己找到更早的提交。每个提交都包含一些先前提交的哈希 ID。大多数提交只有一个哈希 ID;我们称之为提交的父。例如,git log 的工作原理是这样的:我们使用分支名称找到 last 提交。分支名称的哈希 ID 导致名称“指向”提交。提交的父级的哈希 ID 导致提交向后指向其父级。它的父节点也有一个哈希 ID,它指向另一个步骤,依此类推。
-
控制哪个分支名称是当前分支名称的是特殊名称HEAD。这通常“附加到”分支名称。如果您在没有分支名称或其他起点的情况下运行 git log,Git 将使用 HEAD 查找您当前的分支,然后使用分支名称查找最后一次提交。
-
因此,当前分支名称决定了当前提交。
-
每个提交都包含每个文件的快照。因为这是由内部 Git 对象(它们是只读的,并且采用其他程序无法读取的格式)组成,所以 Git 必须先将这些文件提取到工作区中,然后才能使用或更改它们。此工作区称为您的工作树 或工作树。因此,实际上每个文件都有两个副本:当前提交中的已提交副本(只读和仅 Git),以及可用副本(读/写和普通可用文件)。
-
Git 不会根据现有提交进行 new 提交,也不会根据工作树中的内容进行提交。相反,它有每个文件的第三 个副本。这个副本是内部 Git 格式,它是预先去重的,所以如果你实际上没有修改任何东西并且git add-ed 它,这第三个“副本”实际上只是共享提交的副本。 (提交本身也共享这些去重的“副本”,这是非常安全的,因为它们都是严格只读的。)
-
git fetch 做了什么。
考虑到以上所有内容,让我们看看git fetch 现在做了什么(看看为什么还需要--update-head-ok 标志)。 画几张图表来了解 Git 提交的工作原理,特别是如果你是一个视觉学习者,这也可能会有所帮助,所以我们将从它开始。
提交链
我们从有一系列提交的想法开始,每个提交都有自己的又大又丑的哈希 ID。我们不想处理真正的哈希 ID,所以我们将使用一个大写字母来代替哈希 ID。此链中的 last 提交具有一些哈希 ID,我们将其称为 H。我们找到这个名字使用一个分支名称,特殊名称HEAD附加到它:
<-H <--branch (HEAD)
我们通过绘制从分支名称出来的箭头来指示名称branch指向提交H。但是commit H 本身指向一些更早的提交,所以让我们添加它:
<-G <-H <--branch (HEAD)
当然,提交 G 指向更早的提交:
... <-F <-G <-H <--branch (HEAD)
现在,来自提交的“箭头”(存储在提交中的哈希 ID)与提交中的其他所有内容一样是只读的,并且是永久的。因为我们不能改变它们,而且我们知道它们指向后,我要把它们画成连接线——部分是因为懒惰,部分是因为我没有很好的文字箭头,我即将绘制多个分支名称:
I--J <-- br1
/
...--G--H <-- main
\
K--L <-- br2
当我们有一个提交结束于提交H 的主分支时,我们会遇到这种的情况。然后我们创建了一个新的分支名称,它也指向了提交H:
...--G--H <-- main, br1 (HEAD)
当前提交仍然是提交H,我们将HEAD移动到新的名称br1。然后我们进行一个新的提交,我们称之为I; I 将指向 H,因为我们做了新的提交 I,而提交 H 是当时的当前提交。因此,Git 将 I 的哈希 ID 写入名称 br1,HEAD 附加到名称中:
I <-- br1 (HEAD)
/
...--G--H <-- main
然后我们继续进行新的提交J。然后我们再次使用git switch 或git checkout 将HEAD 附加到main。 Git 会:
- 将
HEAD附加到main,
- 将提交
H 提取到您的工作树和我提到的这个每个文件的第三个副本。
这给了我们:
I--J <-- br1
/
...--G--H <-- main (HEAD)
从这里,我们创建另一个分支名称,例如 br2,将 HEAD 附加到它(这次继续提交 H),然后进行新的提交,以进行最终设置。
索引/暂存区/缓存
注意每个文件的第三个副本将如何匹配我们已签出的任何提交。那是因为 Git 在我们移动我们的 当前提交 时仔细协调它。 checkout 或 switch 命令在内部进行这种协调。
每个文件的第三个副本都有一个名称。实际上,它有 三个 名称,反映了它的使用方式,或者名字的选择有多糟糕,或者其他什么。 ? 这三个名称分别是 index、staging area 和 cache。如今,姓氏主要出现在某些 Git 命令的标志中:例如 git rm --cached 或 git diff --cached。其中一些命令允许--staged(但至少git rm 不允许,至少从Git 2.29 起不允许)。
我喜欢坚持使用无意义的原始术语 index,因为它有多种使用方式。尽管如此,除了它在合并冲突解决期间的扩展作用之外,考虑索引/暂存区域的一个好方法是它充当您提议的下一次提交。通过使用 git checkout 或 git switch,您可以安排 Git 在您更改分支名称时更新其自己的索引:
I--J <-- br1
/
...--G--H <-- main
\
K--L <-- br2 (HEAD)
在这里,我们正在提交L,因此索引可能与提交L 匹配,除非您通过git add 更新了任何内容。如果所有三个副本都匹配——如果每个文件的索引副本匹配当前提交的副本,并且每个文件的工作树副本匹配其他两个副本——我们可以从提交切换到提交,使用 git switch 或 @ 987654381@。 Git 可以安全地破坏整个索引和工作树的内容,因为它们安全地存储在 commits 中,它们是完全只读的,并且是永久的——嗯,大多是永久性的。它们很难摆脱,但如果你真的努力,你有时可以摆脱一些。 (我们不会在这里担心这个,只会认为它们是只读的和永久的。)
远程跟踪名称与查找提交的分支名称一样好
您在问题中使用了名称origin/master。这是一个远程跟踪名称:它是你的 Git 对其他 Git 的 master 分支的记忆。这里的另一个 Git 是您使用名称 origin 与之交谈的那个:
git fetch origin
例如。短名称 origin 包含一个 URL,使用该 URL,您的 Git 会调用其他 Git。其他 Git 有 它自己的 分支名称,这与您的分支名称没有任何关系。这些分支名称在他们的存储库中找到提交。
如果您在 您的 存储库中有相同的提交(而且您经常会这样做),您可以让自己的 Git 设置一些名称来记住 那些 中的提交您的 存储库。你不想使用分支名称,因为你的分支名称是你的,随意移动一些你自己的分支名称是不好的。您的分支名称可以帮助您找到您的所需的提交,而不是其他人的。
所以,你的 Git 会取他们的名字——例如他们的master——然后更改他们。最终结果是这个名字被缩写为origin/master。1我们可以把它们画在:
...E--F--G--H <-- master (HEAD), origin/master
分支名称的特殊之处在于,如果您使用git checkout 或git switch,您可以获得“在分支上”。这就是您将名称 HEAD 附加到名称 master 的方式。
remote-tracking name 的特殊功能是它会被某些类型的git fetch 更新。但是 Git 不会让你“启用”远程跟踪名称。如果您运行git checkout origin/master,Git 会将您置于它所谓的分离 HEAD 模式。使用新的 git switch,Git 要求您首先确认此模式:您必须运行 git switch --detach origin/master 才能进入 detached-HEAD 模式。我将在这个答案之外保留 detached-HEAD 模式,但最终它非常简单:我们只需将特殊名称 HEAD 直接指向提交,而不是将其附加到分支名称。这样做的问题是,一旦我们进行任何 new 提交,我们所做的任何移动 HEAD 的操作(包括将其附加到分支名称以退出模式)都会使 找到我们所做的新提交的哈希 ID。
1Git 的所有名称都倾向于缩写。您的master 实际上是refs/heads/master 的缩写;你的origin/master 是refs/remotes/origin/master 的缩写。例如,顶层refs/ 下方的各种名称提供name spaces,以确保您自己的分支名称永远不会与任何远程跟踪名称冲突。
通过git fetch 远程跟踪名称帮助的正常方式
假设您和一位朋友或同事正在从事某个大项目。有一些 Git 存储库的集中副本,可能存储在 GitHub 或其他一些存储库托管站点(可能是公司或大学主机而不是 GitHub)上。无论如何,您和您的朋友都希望使用此存储库。
Git 让您做的是克隆集中式存储库。你跑:
git clone <url>
然后您将获得自己的存储库副本。这会将它的所有提交复制到您自己的存储库中,但是——起初——没有它的任何分支。这样做的方法是使用git fetch。 git clone 命令实际上只是一个方便的包装器,它可以为您运行多达六个命令,除了第一个是 Git 命令:
-
mkdir(或您的操作系统的等效项):git clone(通常)将创建一个新的空目录来保存克隆。其余命令将在这个当前为空的文件夹中运行,但您必须在之后导航到该文件夹。
-
git init:这会创建一个新的、完全空的存储库。一个空的存储库没有提交也没有分支。分支名称必须包含现有提交的哈希 ID,并且没有提交,因此不能有任何分支名称。
-
git remote add:这会设置一个远程,通常命名为 origin,保存您使用的 URL。
-
git config,如果需要,根据您提供给git clone 的命令行选项。
-
git fetch origin(或您通过命令行选项选择的任何其他名称):这会从其他存储库获取提交,然后创建或更新您的远程跟踪名称。
-
git checkout(或在 Git 2.23 或更高版本中,git switch):这将为您创建一个新分支名称,并将HEAD 附加到该分支名称。
在第 6 步中创建的分支是您使用 -b 选项选择的分支 git clone。如果你没有选择-b 之一,你的 Git 会询问他们的 Git 他们推荐哪个分支名称,并使用那个。 (克隆一个完全空的存储库的特殊情况有一些紧急回退,因为现在你不能有一个分支名称,他们也不能推荐一个,但我们将在这里忽略这些极端情况。)
假设您克隆的存储库有八个提交,我们将像以前一样将它们称为A 到H,以及一个分支名称master。因此,他们建议您的 Git 创建 master。您的 Git 创建您的 master 指向与 他们的 Git 以 他们的 名称 master 进行的相同提交,您的 Git 现在正在调用 origin/master。所以最终的结果是这样的:
...--E--F--G--H <-- master (HEAD), origin/master
一个普通的git fetch,以及底层机制
让我们回顾一下git fetch——git clone 的第 5 步——做了什么:
- 它从他们的 Git 中获得了他们有的、你没有的、你需要的任何提交;
- 它创建了(因为它还不存在)你的
origin/master。
一般来说,这就是git fetch 的用途:获得他们拥有的我没有的新提交,但我想要,然后,创建或更新一些名称。
机制是你运行git fetch 并给它一个远程的名称:它需要知道远程跟踪名称的规则是什么。所以你运行git fetch origin 来实现这一点(或者只是git fetch,最终推断origin,尽管这个推断的过程有点复杂)。这让我们进入 refspecs。
git fetch 的实际语法,如 its documentation 的 SYNOPSIS 部分所述,是:
git fetch [<options>] [<repository> [<refspec>...]]
(从技术上讲,这只是运行git fetch 的四种 方式中的第一种:这是一个非常复杂的命令)。在这里,我们没有使用任何选项,而是指定了一个 repository (origin) 并且没有使用 refspec 参数。这使得 Git 从远程名称中查找 default refspec。 远程不只是记住一个 URL,它还记得一个或多个 refspec。origin 的默认 refspec 存储在名称 remote.origin.fetch 下:
$ git config --get-all remote.origin.fetch
+refs/heads/*:refs/remotes/origin/*
(在这种情况下,只有一个输出行,所以git config --get-all 和git config --get 做的事情一样,但是当使用单分支克隆时,您可以使用git remote 将它们变成两个或三个- 或任何编号的分支克隆,然后 --get-all 得到不止一行。)
参考规范和参考
这个东西——这个+refs/heads/*:refs/remotes/origin/*——就是 Git 所说的 refspec。 Refspecs 在the gitglossary 中的定义非常简短,在 fetch 和 push 文档中有更多详细信息,但描述它们的简短方法是它们有两个部分,由冒号 : 分隔,并且可以选择以加号为前缀 + . + 前缀表示 force(与作为命令行选项的 --force 相同,但仅适用于由于这一特定 refspec 而更新的 refs)。
冒号两边的部分是refs,可以用通常的方式缩写。所以我们可以使用像master 这样的分支名称并运行:
git push origin master:master
(请注意这里我已经跳转到git push 命令。就像git fetch 一样,它需要这些repository 和refspec论点,但它对 refspecs 的使用略有不同。)
origin 的默认获取 refspec 是:
+refs/heads/*:refs/remotes/origin/*
加号打开强制选项,因此我们的 Git 无论如何都会更新我们的 origin/* 名称。左侧的refs/heads/* 表示匹配所有分支名称。右侧的refs/remotes/origin/* 是为什么git fetch 创建或更新我们的origin/master,而不是我们的master。
通过使用 refspec,您可以更改 git fetch 创建或更新的名称。这样做时你至少要小心一点。 当我们有 git fetch 更新 远程跟踪名称 时,我们只是在更新 Git 对一些其他 Git 分支名称的记忆.如果我们的 Git 的内存不知何故混淆了(如果我们以某种方式弄乱了 refspec),那么我们可以再次运行 git fetch:大概 他们的 Git 并没有搞砸他们的 branch 名称,所以我们只要正确地刷新我们的记忆,一切就都解决了。但是如果我们有git fetch 写在我们对我们自己的 分支名称的记忆中,这可能会很糟糕:我们的 分支名称是我们找到我们的提交的方式!
由于git fetch 可以写any ref,它可以写分支名称,或标签名称,或远程跟踪名称,或用于git bisect 或@ 的专用名称987654478@。那是很大的力量,所以小心使用它:如果你运行git fetch origin,你会有很多安全机制,但如果你运行git fetch origin <em>refspec</em>,不管你想不想,你都会绕过它们。
嗯,除了一个。在我们开始之前,让我们再看看HEAD,然后看看git reset。
HEAD 和 git reset
正如我们之前看到的,HEAD 告诉我们我们当前的分支名称。由于git fetch 可以写入任何 ref——包括分支名称——它可以,如果我们告诉它,创建或更新任何分支名称。这包括附加的HEAD。但是当前分支名称决定了当前提交:
...--E--F--G--H <-- master (HEAD), origin/master
这告诉我们提交H 是当前提交。
有时我们可能希望移动当前分支以指向其他现有的提交。例如,假设我们做了一个新的提交 I:
I <-- master (HEAD)
/
...--E--F--G--H <-- origin/master
然后我们立即决定提交 I 完全是垃圾,并希望摆脱它。为此,我们可以使用git reset。
重置命令非常复杂。2我们将忽略其中的大部分内容,只关注移动当前分支名称的变体。我们运行:
git reset --hard <hash-ID-or-other-commit-specifier>
和 Git:
- 使当前分支名称指向选择的提交;
- 使 index / staging-area 匹配选择的提交;和
- 使我们的 工作树 匹配所选的提交。
这基本上就像我们检查了一些其他提交,但在此过程中,与我们一起拖动了分支名称。所以我们可以使用:
git reset --hard origin/master
或:
git reset --hard HEAD~1
或任何其他命名提交H 的方式(可能使用来自git log 输出的实际哈希ID)。这样做的最终结果是:
I ???
/
...--E--F--G--H <-- master (HEAD), origin/master
提交I 仍然存在,但现在很难找到。它已经没有名字了。
注意git reset 是如何替换掉 Git 索引和我们的工作树的内容的。这样,一切都是同步的:当前提交再次是H,暂存区匹配提交H,我们的工作树匹配提交H。我们可以使用其他种类的git reset 命令,如果我们这样做了,事情会有所不同。我们稍后再讨论这个问题。
2其实很复杂,我觉得应该和老的git checkout一样,拆分成两个命令:git checkout变成git switch和git restore。我不清楚使用哪两个名称来拆分git reset,除了其中一个可能是git restore。 ?
你的 git reset 很相似
你跑了:
git reset --hard master~4
假设您当前的分支也是master(您没有说,但您的问题的其余部分清楚地暗示了这一点)。我们还假设您的 master 最初与您自己的 origin/master 同步,因此您开始:
...--D--E--F--G--H <-- master (HEAD), origin/master
您的git reset 这样做了:
...--D <-- master (HEAD)
\
E--F--G--H <-- origin/master
没有任何提交改变(没有提交可以改变,永远),但你现在正在使用提交D。您的索引/暂存区和工作树匹配提交D。提交D 是当前提交。
你的git fetch 很不寻常
接下来,你跑了:
git fetch --update-head-ok . origin/master:master
在这里,您使用了. 而不是遥控器的名称。没关系,因为git fetch 在这里允许的不仅仅是远程名称。您可以使用 URL 或路径名; . 算作路径名,表示此存储库。本质上,您的 Git 会调用 itself,并询问 itself 它有哪些提交,以及它的分支名称是什么。
您的 Git 中没有您的 Git 需要来自“其他”Git 的新提交(当然,您的 Git 拥有它所拥有的那些提交),因此 获取新提交 步骤什么也不做.然后,refspec origin/master:master 适用:你让“他们”查找“他们的”origin/master——这是你自己的origin/master,它标识了提交H——并将其复制到你的分支姓名master。
这是最后一次特殊安全检查的用武之地。通常,git fetch 将拒绝更新当前分支名称。这是因为当前分支名称决定了当前提交。但是--update-head-ok 标志关闭安全检查,所以你的git fetch 继续更新当前分支名称。你的名字master 现在指向提交H。
没有发生的是 Git 没有更新它的索引或你的工作树。这两个被单独留下。他们仍然匹配提交D。因此,虽然您现在拥有:
...--D
\
E--F--G--H <-- master (HEAD), origin/master
您的索引和工作树匹配提交D。
你可以用git reset --soft得到同样的效果
你跑了吗:
git reset --soft origin/master
您的 Git 会将您当前的分支名称 master 移动到提交 H。然而,--soft 告诉git reset:
所以你会和以前一样。
git reset 和您的git fetch 之间存在细微差别,但在这种特殊情况下根本没有影响。具体来说,当git fetch 更新引用时,它可以强制执行快进 规则。这些规则适用于分支名称和远程跟踪名称。 (早于 1.8.2 的 Git 版本也意外地将它们应用于标记名称。)快进规则要求存储在某个名称中的 new 哈希 ID 是存储在更新前的名字。
git reset 命令从不强制执行快进规则。 git fetch 和 git push 命令可以,除非强制更新(在 refspec 中使用 --force 或前导 + 字符)。