(注意:我今天早上开始写这篇文章,今天晚上很晚才完成;问题在中间得到了回答,但是完成了所有这些工作后,我仍然要发布答案。:-))
TL;DR
git fetch 命令从不合并任何内容。它可以更新引用,并且非常愿意以快进的方式更新类似分支的引用。更不愿意以非快进方式更新此类引用;为此,您必须强制更新。
快速转发——一旦正确地脱离了合并的想法——是 对引用提交的引用的更改的属性。更具体地说,我们通常对分支名称值或远程跟踪名称值是否以快进方式更改感兴趣。这意味着我们必须查看提交图,因为它是提交图中的新位置,结合提交当前选择参考,确定对该参考的更新是否为快进。
长
这里的原始声明至少在一个重要方面是错误的:
我知道git fetch 在从远程获取提交后总是在分支和远程跟踪之间进行快进合并。
让我们把它拆开一点,这样我们就可以使用正确的单词和短语了。我们需要知道:
- 什么是参考;
-
refspec 是什么;最重要的是,
- 对引用进行快进更新与非快进更新意味着什么。
最后一点还涉及到强制标志:每个引用更新可以是强制的,也可以是非强制的。您可能熟悉git push --force,它为 Git 正在推送的 每个 引用设置强制标志。 git fetch 命令具有相同的标志,具有相同的效果——但通常“全有或全无”过于宽泛,因此 Git 有一种方法可以在更个性化的基础上设置强制标志。 (git push 命令在这里有更多的改进,但我们只是顺便提一下。)
reference和refspec的定义
reference,在 Git 中,只是一个名称——理想情况下,是一个对人类有意义的名称——用于某些特定的提交或其他 Git 对象。1 always2 以 refs/ 开头,并且大部分情况下都有第二个斜线分隔的组件,用于声明它们是哪种引用,例如,refs/heads/ 是一个分支名称,refs/tags/ 是一个标签名称,refs/remotes/ 是一个远程跟踪名称。3
(我们在这里关心的参考,用于确定某些更新是否是快进,是我们希望以“分支方式”表现的那些:refs/heads/ 中的那些和refs/remotes/。我们稍后将讨论的规则可以应用于任何引用,但肯定应用于这些“分支-y”引用。)
如果您在 Git 需要或可以使用引用的地方使用像 master 这样的非限定名称,Git 将使用the gitrevisions documentation 开头附近概述的六步过程来确定完整的引用来解析缩写名称改为全名。4
refspec,在 Git 中,主要是一对由冒号 (:) 字符分隔的引用,带有可选的前导加号 +。左边的引用是source,右边的引用是destination。我们使用带有git fetch 和git push 的refspecs,它们连接了两个不同的Git 存储库。源引用是供任何 Git 发送提交和其他 Git 对象使用的,目标是供接收 Git 使用的。特别是git fetch,那么源是other Git,目的是我们自己。
如果 refspec 中的引用不是完全限定的(不以 refs/ 开头),Git 可以使用上述过程来限定它。如果单个 refspec 中的 both 引用都是不合格的,Git 会在其中包含一些代码来尝试将它们都放入适当的名称空间,但我从未非常信任此代码。例如,我不清楚在获取期间谁真正限定了源和目标:涉及两个 Git,但另一个 Git 通常会向我们发送所有引用的完整列表,因此我们的 Git 可以使用这个清单。不过,在这里使用完全限定的引用显然更明智,以防 他们的 引用集不符合您自己的期望:如果他们只有一个 refs/tags/xyz 并且您期望 xyz 扩展refs/heads/xyz,如果没有,你会感到惊讶。
在任何 refspec 中,您可以省略源部分或目标部分。要省略目标,请编写不带冒号的 refspec,例如 refs/heads/br。要省略源代码,您可以使用冒号编写 refspec,但不包含源代码部分的位置,例如 :refs/heads/br。当你做这些事情时意味着:git fetch 对待它们的方式与git push 非常不同。现在,只需注意有源部分和目标部分,可以选择省略它们。
领先的加号,如果你选择使用它,总是在前面。因此,git push origin +:refs/heads/br 是一个设置了强制标志的推送,将一个空源推送到完全限定的目标refs/heads/br。因为这是一个推送,所以源代表我们的 Git 的名称(无),目标代表他们的 Git 的名称(一个名为 br 的分支)。外观相似的字符串+refs/heads/br 设置了强制标志,具有完全限定的源,并且没有目的地。如果我们关心git push,我们可以看看这两个refspecs for push的含义,但现在让我们继续。
1任何类似分支的引用必须指向一个提交。标签名称可以指向任何对象。其他参考名称可能有其他限制。
2Git 内部存在一些内部分歧,是否 每个 引用必须以其全名形式拼写为匹配 refs/* 的内容。如果是这样,HEAD 将永远不会成为参考。事实上,像HEAD 和ORIG_HEAD 和MERGE_HEAD 这样的特殊名称有时表现得像普通引用,有时则不然。就我自己而言,我主要将这些排除在参考概念之外,除非方便时包含它们。每个 Git 命令都决定如何以及是否更新这些 *_HEAD 名称,因此没有正式的系统方法,或者大多数情况下,考虑到 other 奇怪的特殊情况在一些命令中向上 - 用于refs/ 样式参考。
3还有更多众所周知的子空间:例如,refs/replace 保留给 git replace。不过,这里的想法很简单:refs/ 后面跟着另一个人类可读的字符串,它告诉我们这个特定的引用是什么类型的引用。根据种类,我们可能需要另一个子空间,就像refs/remotes/ 中的情况一样,我们接下来想知道:哪个遥控器?
4一些 Git 命令知道或假定缩写引用必须是分支名称或标签名称。例如,git branch 不会让您在某些地方拼出 refs/heads/:它只是粗鲁地将 refs/heads/ 自己推入,因为它仅适用于分支名称。当没有明确的必须是分支名称或必须是标签名称规则时,通常使用六步过程。
提交图
在定义快进更新意味着什么之前,我们需要查看提交图。快进与非快进仅在提交和提交图的上下文中才有意义。因此,它只对专门引用 commits 的引用有意义。类似分支的名称——refs/heads/ 和 refs/remotes/ 中的名称——总是指向提交,而这些是我们在这里关心的。
提交由其哈希 ID 唯一标识。5 每个提交还存储一些 父 提交哈希 ID。大多数提交存储一个父 ID;我们说这样的提交指向它的父提交。这些指针构成了一个向后看的链,从最近的提交到最旧的提交:
A <-B <-C
例如,在一个只有三个提交的小型存储库中。提交C 将提交B 作为其直接父级,因此C 指向B。提交B 将提交A 作为其直接父级,因此B 指向A。 A 是第一个提交,所以它没有父提交:它是一个 root 提交,它没有指向任何地方。
这些指针形成了祖先/后代关系。我们知道这些指针总是向后看,所以我们不需要绘制内部箭头。不过,我们确实需要一些东西来识别数据结构的tip提交,以便Git可以找到这些链的ends:
o--o--C--o--o--o--G <-- master
\
o--o--J <-- develop
这里master 指向某个提交G,develop 指向J。跟随J 向后,或G 向后,最终导致提交C。因此,提交C 是提交G 和J 的祖先。
请注意G 和J 彼此之间没有父/子关系!两者都不是另一个的后代,也不是另一个的父母;一旦我们回到足够远的时间/历史,它们只是有一些共同的祖先。
5事实上,每个 Git 对象都由其哈希 ID 唯一标识。例如,即使该文件的特定版本存储在数十或数千次提交中,Git 也仅存储某个文件内容的一份副本:不更改文件内容的提交可以重用现有的 blob对象。
快进的定义
快进是移动标签的属性。我们可以移动现有的名称(master 和 develop),但我们暂时避免这样做。相反,假设我们添加一个新名称,并使其指向提交C。让我们也为其余的提交添加一个字母的哈希 ID:
............ <-- temp
.
A--B--C--D--E--F--G <-- master
\
H--I--J <-- develop
我们现在可以要求 Git 将新名称从提交 C 移动到任何其他提交。
当我们这样做时,我们可以问另一个问题关于这个动作。具体来说,temp当前指向提交C。我们从A-through-J 可能的提交范围中选择另一个 ID,并告诉 Git 移动 temp,以便它指向这个新选择的提交。我们的问题很简单:新提交是标签当前指向的提交的后代吗?
如果这个标签移动导致名称temp 指向一个提交是C 的后代,那么这个移动是快进。如果不是——如果我们选择提交 B 或 A——这个动作不是快进。
就是这样——这就是所有快进。这是对此更新到此标签的问题的答案,我们即将现在,结果标签沿着我们向后指向的提交链向前移动。
这对于 branch 名称(refs/heads/ 空间中的名称)特别有趣的原因是 git commit 创建了一个 new 提交,其父项是 current 提交,并将这个新提交添加到图表中——然后更新当前分支名称以指向它刚刚进行的新提交。因此,一系列重复的git commit 操作会导致分支标签一次一步向前运动。例如,如果我们检查 develop 并进行两次新提交,我们会得到:
A--B--C--D--E--F--G <-- master
\
H--I--J--K--L <-- develop
名称 develop 现在指向这些新提交中的第二个。
如果在摆弄temp 时,我们将分支名称temp 指向提交J,我们现在可以快进 temp 指向提交@987654419 @。因为L 指向K,而J 又指向J,所以遵循这些链的所有Git 操作都会将提交K 视为仍在“开启”分支temp。所以快进很有趣,因为它意味着我们不会“丢失”提交。
另一方面,如果我们让temp 指向E,现在移动temp 指向K 将“丢失”来自分支D 和E 的提交temp。这些提交仍然安全地在master 上,所以它们仍然在这里受到保护。如果他们因为某种原因不再在 master 上——例如,如果我们对 master 做了一些奇怪或不寻常的事情,例如删除分支名称——然后提交 D 和 @ 987654436@ 将通过名称temp 受到保护,直到我们以非快进方式拉动temp。如果temp 是唯一保护这些提交免受垃圾收集器影响的名称,它们就会变得易受攻击。
将快进与合并作为动词的含义进行比较
Git 确实有一种叫做快进合并的东西。我不喜欢“快进合并”这个短语,因为它根本不是真正的合并——它更像是运行git checkout,除了分支名称移动的事实。但是the git merge documentation 在更正式地说一些合并解析为快进之后使用了这个短语,所以我们必须能够解释它。
Git 中的 快进合并 由运行 git merge <em>other</em> 产生,其中 other 是严格提前的提交(即,是) 图中的当前或HEAD 提交。这意味着HEAD 所附加的分支name 可以以快进方式移动。例如,分支名称temp 指向提交C,我们可以运行:
git checkout temp
git merge <hash-of-commit-E>
Git 将意识到将标签 temp 从提交 C 移动到提交 E 是对该标签的快进操作。允许我们在这里使用动词 merge 的主要因素是我们只是使用git merge 来实现它:git merge 命令因此更新了我们的 索引和工作树 以及执行快进操作。
但这只是git merge借用了快进的概念。快进本身并不是一个“合并”概念。如果您运行不同的git merge <em>other</em>,其中 other 是 不是 当前提交的后代,但 is 是某些 的后代>当前提交的共同祖先 - 即合并基础 - 然后,在这种情况下,git merge 执行真正的合并,使用您的索引和工作树作为进行合并的区域。 那是一个合并,一个真正填补动词短语合并的鞋的操作。
(我们的图中没有这样的提交——我们必须创建 A 或 B 的子节点,之后提交 A 或提交 B 将是合并基础。)
git fetch 和 git push 都不会合并
正如我们刚刚提到的,真正的合并需要——至少可能是——使用索引和工作树。 git fetch 命令不触及索引和工作树。 git push 通常用于 --bare 存储库,它甚至没有工作树!
git fetch 或 git push 操作可以进行快进。由于快进不合并,这与我们的“从不合并”声明并不矛盾。 git fetch 或 git push 操作也可以对引用名称(包括分支名称)执行非快进操作,但要这样做,强制标志必须是在该特定操作上启用。
(git push 命令不仅提供“plain”和“force”,还提供“force-with-lease”,这类似于多线程编程中的比较和交换或 CAS 指令。fetch 命令确实没有这个 CAS 选项,它只有普通或强制。)
git fetch 如何使用 refspec 更新引用
git fetch 命令有(至少,取决于你如何计算)两部分:
- 将提交(和其他 Git 对象)从另一个 Git 转移到我们的 Git 中,扩充我们的提交图;
- (可选)更新我们存储库中的一些引用。
它的副作用是将它知道的关于新提交的所有内容写入.git/FETCH_HEAD,这是一个绝对不是参考的特殊文件——与@987654475 不同,这永远不会有任何歧义@——但确实包含哈希 ID(以及关于我们的 Git 从另一个 Git 看到的额外信息)。即使git fetch 没有更新任何引用,Git 的其余部分也可以使用该文件中留下的数据。
现在,请记住,refspec 可以同时列出源引用和目标引用,或者只是一个源,或者只是一个目标。它还可以有一个前导的+ 符号来表示“必要时强制”。
具体看git fetch,那么,在处理下半年要发生的事情时,我们有这三种可能的情况:
- 带有源和目标的引用规范:使用源在另一个 Git 存储库中定位名称;使用目标选择要在我们自己的存储库中更新的名称。
- 带有源但没有目标的引用规范:使用源在其他 Git 存储库中查找名称,但不要更新任何本地名称(但请参见下文)。
- 带有目的地但没有来源的参考规范:错误。
在非常旧的 Git 版本(Git 1.8.4 之前的版本)中,git fetch 操作只会遵循您在命令行中提供的任何 refspec。如果你没有给它 refspecs,它会使用并遵守配置中的 remote.<em>remote</em>.fetch 指令。也就是说,在这些旧版本的 Git 中,运行 git fetch origin xyz 会获取与 xyz 匹配的任何引用,并且由于存在 no 目标,这会更新我们自己的 no 引用存储库! (该命令仍然像往常一样将信息写入.git/FETCH_HEAD。)注意xyz 可能是一个标签:另一个Git 可能会找到refs/tags/xyz 而不是refs/heads/xyz。我们没有具体说明;如果我们想确保获取一个分支,我们需要指定refs/heads/。
如果您的 Git 至少是 1.8.4 版,那么当 git fetch 引入 分支 名称时,Git 会使用您的 remote.<em>remote</em>.fetch 进行一次机会性更新获取设置。所以,假设正常的remote.origin.fetch 设置,git fetch origin refs/heads/xyz:
- 没有更新,因为目标部分为空;
- 但随后会更新
refs/remotes/origin/xyz,因为 fetch 设置。
一旦git fetch 开始进行所有更新,每次更新:
- 可以成功,因为这种引用的规则允许更新,或者
- 可能会失败,因为规则不允许并且未设置强制标志;或
- 可以成功,因为即使规则不允许,也会设置强制标志。
然后,假设我们运行:
git fetch origin refs/heads/xyz:refs/heads/abc
并且在origin 的另一个Git 上有一个refs/heads/xyz。进一步假设我们的 Git 至少为 1.8.4,并且我们在 remote.origin.fetch 中有通常的 refspec。然后是我们的 Git:
- 如有必要,将提交与他们的 Git 的
refs/heads/xyz 一起提交。
- 尝试更新我们的
refs/heads/abc。此更新不是强制的。这次更新是由于我们在命令行中告诉我们的 Git。
- 尝试更新我们的
refs/remotes/origin/xyz。此更新是强制的。这次更新是由于我们通过 remote.origin.fetch 告诉我们的 Git。
由于refs/heads/ 和refs/remotes/ 都是分支样式的命名空间,我们的Git(我们知道它至少是1.8.4)在此处遵循分支更新规则。6 这些告诉 Git 更新是自动允许的如果它是快进。
对于此处的第 2 项,要更新的名称是 refs/heads/abc(因为它位于命令行 refspec 的右侧)。同样,fast-forward 这里与合并无关:Git 只是检查 refs/heads/abc 的当前值是否是 refs/heads/abc 提议的新值的祖先。如果是这样,则允许此更新。如果没有,那就不是。
对于第 3 项,要更新的名称是 refs/remotes/origin/xyz(因为左侧匹配的名称是 refs/heads/xyz,默认 refspec 读取为 +refs/heads/*:refs/remotes/origin/*)。此 refspec设置了强制标志,因此将更新到 refs/remotes/origin/xyz。如果更改是快进,这将是正常的、快进的、非强制的更新。如果更改是非快进,则将是非快进强制更新。
6在 Git 1.8.2 及更早版本中,Git 意外地将分支更新“必须是快进操作”规则也应用于标记名称。在 Git 1.8.4 中,此问题已得到修复。但是,a new bug was introduced at some point。 Git 中用于在git fetch 期间更新引用的代码既可怕又曲折,我认为可能应该丢弃并从头开始重新编码,但实际上这样做本身就是一场噩梦。
git fetch 中还有一个特殊约束
我们在上面顺便提到,可能不是参考的特殊名称HEAD 通常附加到某个分支名称。当您的 HEAD 连接到某个分支时,该分支就是您的 当前分支。这是拥有该分支作为当前分支的内部定义:分支的名称必须在.git/HEAD 文件中。
默认情况下,git fetch拒绝更新此分支名称。也就是说,如果HEAD 附加到master,git fetch 根本不会更新refs/heads/master。运行git fetch origin refs/heads/master:refs/heads/master 将无法更新您的refs/heads/master。在您git checkout 某个other 分支之后,例如将HEAD 附加到develop,然后 git fetch 愿意更新master,并且现在如果您愿意,您可以运行git fetch origin master:master(假设您更喜欢较短、风险稍高、不合格的拼写)。7
这个特殊约束的原因与我们上面提到的关于git merge如何进行快进解决的合并的差异有关:git merge 更新索引和工作树,就好像你跑了git checkout。 git fetch 命令从不 更新索引和工作树。如果git fetch 允许您将master 快进到新的提交,您的索引和工作树可能会得到out of whack。
这里的问题是您的索引和工作树旨在匹配您当前的提交,除了您已经完成的任何工作因为您运行git checkout 来更改您的索引和工作-树。如果git fetch 更新refs/heads/ 附加到HEAD 的空间分支名称,则您的索引和工作树不再匹配您当前的提交,因为您当前的提交是哈希ID 存储在该分支中的那个-姓名。 (如果你确实设法进入这种状态,那么修复它会很烦人,尽管它是可能的。请参阅Why does Git allow pushing to a checked-out branch in an added worktree? How shall I recover?)
git fetch 命令有一个标志--update-head-ok,专门覆盖此检查。你不应该使用它。 git pull 代码确实使用它,因为 git pull 会立即运行第二个 Git 命令,即使在这些特殊情况下也会更正索引和工作树。此外,git pull 会在fetch 之前进行一些检查,以确保第二个命令不会破坏一切。但是,除非您确切知道自己在做什么,否则您不应该使用它。
7如果你这样做这样做,一般来说,你只是在为自己做额外的脑力劳动。我建议不要将其作为日常练习。相反,请使用git fetch origin && git checkout master && git merge --ff-only。我定义了一个别名git mff,它运行git merge --ff-only,我用它来做这些事情。