Git拉取非快进式更新

5
我知道git fetch在从远程获取提交后,总是会在分支和其远程跟踪之间进行快进合并。
我的问题涉及到这样一种情况:我们需要让git fetch进行非快进合并。是否可能使git fetch进行非快进合并? 如果不行,我该如何解决以下情况?

我的本地repo(进行了两个本地提交-C和B提交)

...--o--o--A   <-- origin/master
            \
             C--B   <-- master 

之后我运行git fetch(更新我的分支)

...--o--o--A-- D  <-- origin/master (updated)
            \
             C--B   <-- master

这里需要将origin/master合并到master分支,但这不会是快进合并(非线性合并)。使用git fetch命令会失败。我不想使用force fetch,因为我不想丢失我自己提交的CB

我该如何让git fetch执行一个非快进合并,类似于这样:

...--o--o--A-- D --  
            \      \
             \      F <-- master ,origin/master (updated) (my merge commit for non fast forward)
              \    /
               C--B   

1
Fetch 不会合并,只有 Pull 会合并。 - Christoph
fetch通过快进更新合并远程跟踪和分支。pull将当前分支更新后与本地分支合并。https://stackoverflow.com/questions/50545041/git-pull-with-refspec - Number945
git pull --no-ff origin master。在某些情况下,可以省略 originmaster。而且 @Christoph 是正确的。 - ElpieKay
@ElpieKay,那我们仅使用git fetch就不行了吗? - Number945
抱歉,我在您的评论中漏掉了一些内容。如果master不是当前分支,则无法通过git fetch进行非快进式合并。先检查它,然后再合并。 - ElpieKay
显示剩余8条评论
2个回答

17

TL;DR:

git fetch命令从不合并任何内容。它可以更新引用,并且非常愿意以快进方式更新类似分支的引用。但是,如果要以非快进方式更新此类引用,则必须强制更新。

快进 - 一旦与合并的想法分离开来 - 是指引用更改所指向的提交的属性。更具体地说,我们通常关心的是分支名称值或远程跟踪名称值是否以快进方式更改。这意味着我们必须查看提交图,因为引用当前选择的提交和提交图中的新位置共同确定对该引用的更新是否为快进。

Long:

至少在一个重要方面,原始声明是错误的:

我知道git fetch在从远程获取提交后,总是对分支和其远程跟踪执行快进合并。

让我们稍微分解一下,这样我们就有了正确的词语和短语可用。我们需要知道:

  • reference是什么;
  • refspec是什么;最重要的是,
  • fast-forward更新与非fast-forward更新到引用意味着什么。

最后这部分还涉及force flag:每个引用更新都可以被强制或不被强制。你可能熟悉git push --force,它为Git正在推送的每个引用设置了force标志。git fetch命令也有相同的标志,具有相同的效果,但总的来说,“全有或全无”太广泛了,因此Git有一种方法在更个体化的基础上设置force标志。(git push命令在这方面甚至有更多的细化,但我们只会简单提一下。)

referencerefspec的定义

在Git中,reference只是一个名称 - 理想情况下,是一个对某个特定提交或其他Git对象有意义的名称。1 引用始终以refs/开头,并且大多数情况下都有第二个斜杠分隔的组件,声明它们是哪种引用,例如refs/heads/是一个分支名称refs/tags/是一个标签名称,而refs/remotes/是一个远程跟踪名称3

在判断某个更新是否是快进时,我们关心的参考文献是那些我们希望表现出“分支方式”的参考文献:位于refs/heads/refs/remotes/中的参考文献。我们将在接下来讨论的规则可能适用于任何参考文献,但确实适用于这些“分支方式”的参考文献。
如果您在Git需要或可以使用引用的地方使用了未经修饰的名称(如master),Git会使用gitrevisions文档开头概述的六步过程来解析缩写名称以获取完整的引用名称。
在Git中,refspec基本上是一对由冒号(:)字符分隔的引用,其前面可以有一个可选的加号+。左侧的引用是,右侧的引用是目标。我们在使用git fetchgit push与连接两个不同的Git仓库时使用refspecs。源引用意味着将提交和其他Git对象发送到的Git所使用的引用,而目的地引用意味着接收Git所使用的引用。因此,对于git fetch,源是其他 Git,而目标是我们自己。
如果refspec中的引用没有完全限定(不以refs/开头),Git可以使用上面的过程来限定它。如果单个refspec中的{{两个}}引用都未被限定,Git中有一些代码来尝试将它们都放入适当的名称空间中,但我从来不太信任这段代码。例如,在提取期间,究竟是谁真正限定了源和目标并不清楚:涉及两个Git,但另一个Git通常会向我们发送一个所有引用的完整列表,因此我们的Git可以使用此列表进行解析。在这里使用完全限定的引用显然更明智,以防{{他们的}}参考集与您自己的期望不匹配:如果他们只有一个refs/tags/xyz,而您希望xyz扩展为refs/heads/xyz,则当它未能实现时,您可能会感到惊讶。
在任何refspec中,可以省略源或目标部分。要省略目标,请编写不带冒号的refspec,例如refs/heads/br。要省略源,请编写具有冒号但源部分为空的refspec,例如:refs/heads/br。当您执行这些操作时,它们的含义与用途各不相同:与git push相比,git fetch会对它们进行不同的处理。目前,请注意存在源和目标部分,并可选择省略它们。
如果您选择使用加号,那么它始终放在前面。因此,git push origin +:refs/heads/br 是一个推送操作,强制标志已设置,源为空,目的地为 refs/heads/br,是完全合格的。由于这是一次推送操作,源表示我们 Git 的名称(没有),目的地表示他们 Git 的名称(名为 br 的分支)。类似的字符串 +refs/heads/br 设置了强制标志,具有完全合格的源,但没有目的地。如果我们关心 git push,我们可以查看这两个推送 refspecs 的含义,但现在让我们继续。

1任何类似分支的引用必须指向一个提交。标签名称可以指向任何对象。其他引用名称可能具有其他约束。

2Git本身存在一些内部分歧,即是否每个引用都必须以与refs/*匹配的完整名称形式拼写出来。如果是这样,HEAD将永远不会是一个引用。实际上,像HEADORIG_HEADMERGE_HEAD这样的特殊名称有时像普通引用一样工作,有时则不然。对我来说,我大多数情况下都将它们排除在引用的概念之外,除非在方便包含它们的时候。每个Git命令都会自己决定如何以及是否更新这些*_HEAD名称,因此没有像refs/风格引用那样的正式系统方法。

3还有更多广为人知的子空间:例如,refs/replace保留给git replace使用。这里的想法很简单:refs/后面跟着另一个可读字符串,告诉我们这个特定引用是什么类型的引用。根据种类,我们可能需要另一个子空间,就像在refs/remotes/中一样,我们接下来想知道:哪个远程?

4一些Git命令知道或假设缩写引用必须是分支名称或标签名称。例如,git branch在某些地方不允许您拼出refs/heads/:它只是粗鲁地将refs/heads/推入其中,因为它仅适用于分支名称。当没有明确的“必须是分支名称”或“必须是标签名称”的规则时,通常使用这个六步过程。


提交图

在定义什么是快进更新之前,我们需要查看提交图。快进与非快进只有在提交和提交图的情况下才有意义。因此,它只适用于特定于提交的引用。类似分支的名称-那些在refs/heads/refs/remotes/中的名称-总是指向提交,并且这些是我们在此关心的。

提交通过其哈希ID唯一标识。5每个提交还存储一些提交哈希ID的集合。大多数提交存储单个父ID;我们说这样的提交指向其父提交。这些指针组成一个向后查找的链,从最新提交到最旧提交:

A  <-B  <-C

例如,在一个只有三个提交的小仓库中。提交C将提交B作为其直接父项,因此C指向B。提交B将提交A作为其直接父项,因此B指向AA是第一个提交,因此它没有父项:它是一个根提交,并且没有指向任何地方。
这些指针形成了祖先/后代关系。我们知道这些指针总是向后看,因此我们不需要绘制内部箭头。我们确实需要某些东西来标识数据结构的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}}之间没有父子关系!它们彼此都不是后代,也不是父母;只有在我们回溯到足够久远的时间/历史时,它们才有一些共同的祖先。
实际上,每个 Git 对象都通过其哈希 ID 唯一标识。例如,这就是 Git 存储某些文件内容的唯一副本时所使用的方法,即使该文件的特定版本存储在数十个或数千个提交中:不更改文件内容的提交可以重复使用现有的 blob 对象。

{{“快进”}}的定义

{{“快进”}}是{{“移动标签”}}的属性。我们可以移动现有的名称({{master}}和{{develop}}),但让我们暂时避免这样做。相反,假设我们添加一个新名称,并将其指向提交{{C}}。让我们为其余提交添加单字母哈希ID:

        ............ <-- temp
       .
A--B--C--D--E--F--G   <-- master
       \
        H--I--J   <-- develop

现在我们可以要求Git将新名称从提交移动到任何其他提交。

这样做时,我们可以问一个关于此移动的问题。具体来说,temp当前指向提交C。我们从A到J可能提交的宇宙中选择另一个ID,并告诉Git将temp移动,以使其指向此新选择的提交。我们的问题很简单:新提交是指向标签指向的提交的后代吗?

如果此标签移动导致名称temp指向的提交是C的后代,则此移动是快进。如果不是-如果我们选择提交B或A-则此移动不是快进。

就是这样-这就是快进的全部。这是对我们即将进行的此标签的此更新是否导致该标签沿着我们指向后面的提交链向前移动的答案。

这对于{{分支}}名称尤其有趣——在refs/heads/空间中的名称——是因为git commit创建一个新的提交,其父提交是当前提交,并将此新提交添加到图形中,然后更新当前分支名称以指向它刚刚创建的新提交。因此,一系列重复的git commit操作会导致分支标签向前单步移动。例如,如果我们检出develop并进行两个新提交,我们会得到:
A--B--C--D--E--F--G   <-- master
       \
        H--I--J--K--L   <-- develop

现在,名为develop的指针指向这些新提交中的第二个。

如果我们在调整temp时,将分支名称temp指向提交J,那么我们现在可以将temp 快进到指向提交L。因为L指向KK又指向J,所以跟随这些链的所有Git操作都将把提交K视为仍然“在”分支temp上。因此,快进是有趣的,因为它意味着我们不会“丢失”提交。

另一方面,如果我们改为让temp指向E,现在将temp移动到指向K将从分支temp中“丢失”提交DE。这些提交仍然安全地存储在master上,因此它们在这里得到了保护。如果由于某种原因它们不再在master上——例如,如果我们对master进行了奇怪或不寻常的操作,比如删除分支名称——那么提交DE将通过名称temp得到保护,直到我们以非快进方式移动temp。如果temp是保护这些提交免受垃圾回收器攻击的唯一名称,它们就会变得脆弱。

将快进与作为动词的“合并”进行比较

Git确实有一些称之为“快进合并”的东西。我不喜欢“快进合并”这个短语,因为它根本不是真正的合并——它更像是运行git checkout,只是分支名称移动了。但是git merge文档使用了这个短语,在更正式地说出某些合并解决为快进之后,我们必须能够解释它。

在Git中,快进合并是通过运行git merge other来实现的,其中other是严格超前于(即是当前或HEAD提交的后代)图表中的提交。这意味着附加到HEAD的分支名称可以以快进方式移动。例如,对于指向提交C的分支名称temp,我们可以运行:

git checkout temp
git merge <hash-of-commit-E>

Git会意识到将标签temp从提交C移动到提交E是对该标签的快速转发操作。我们能够在此处使用动词合并的主要事项是我们刚刚使用git merge来实现它:因此,git merge命令更新我们的索引和工作树以及执行快速转发操作。
但这只是git merge借用了快速转发概念。快速转发本身并不是一个“合并”概念。如果您运行不同的git merge other,其中其他不是当前提交的后代,而是某个公共祖先(即合并基)的后代,则在这种情况下,git merge执行真正的合并,使用您的索引和工作树作为合并区域。这是一次合并,是一种真正填补动词短语“合并”的操作。
(我们的图形中没有这样的提交-我们必须使A或B的子级成为合并基础,之后提交A或提交B将是合并基础。)
git fetch和git push都从不合并。
正如我们刚才提到的,真正的合并至少可能需要使用索引和工作树。git fetch命令不会触及索引和工作树。git push通常用于一个--bare存储库,这甚至没有工作树!git fetchgit push操作可以进行快进操作。由于快进不是合并,因此这并不与我们的“永远不合并”声明相矛盾。git fetchgit push操作还可以对引用名称(包括分支名称)执行非快速转发操作,但要这样做,必须在特定操作上启用强制标志。 (git push命令不仅提供“普通”和“强制”,还提供“带租约的强制”,类似于多线程编程中的比较和交换或CAS指令。fetch命令没有此CAS选项,它只有普通或强制。)

git fetch如何使用refspecs更新引用

git fetch命令有(至少,取决于您如何计算)两个部分:
  • 从另一个Git传输提交(和其他Git对象)到我们的Git,增强我们的提交图;
  • 可选地,更新我们存储库中的一些引用。
它的副作用是将其对新提交的所有了解写入.git/FETCH_HEAD中,这是一个特殊文件,绝对不是引用——这方面永远不会有任何歧义,与HEAD不同——但确实包含哈希ID(以及关于我们的Git从其他Git中看到的额外信息)。即使git fetch不更新任何引用,Git的其余部分也可以使用留在此文件中的数据。
现在,请记住,refspec可以列出源引用和目标引用,或仅列出源引用或仅列出目标引用。它还可以有一个前导+符号,表示“必要时强制执行”。
具体看git fetch,则在处理后半部分应该发生的事情时,我们有以下三种可能的情况:
1.既有源又有目标的refspec:使用源在其他Git存储库中定位名称;使用目标选择要在我们自己的存储库中更新的名称。 2.只有源而没有目标的refspec:使用源在其他Git存储库中定位名称,但不更新任何本地名称(但请参见下文)。 3.只有目标而没有源的refspec:错误。
在Git版本1.8.4之前的旧版本中,git fetch操作只是遵循命令行中给出的任何refspecs。如果没有指定refspecs,则使用并遵守配置中的remote.remote.fetch指令。也就是说,在这些旧版本的Git中,运行git fetch origin xyz会获取与xyz匹配的任何引用,并且由于没有目标,因此这不会更新我们自己存储库中的任何引用!(该命令仍会像往常一样将信息写入.git/FETCH_HEAD。)请注意,xyz可能是一个标签:其他Git可能会找到refs/tags/xyz而不是refs/heads/xyz。我们没有指定;如果要确保获取一个分支,需要指定refs/heads/
然而,如果您的Git至少是1.8.4版本,当git fetch获取一个分支名称时,Git会使用您的remote.remote.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

假设另一个Git上有一个名为refs/heads/xyz的分支,而且我们的Git至少是1.8.4,并且在remote.origin.fetch中有通常的refspec。那么我们的Git会:
  1. 如果需要,将与他们Git的refs/heads/xyz相关的提交带过来。
  2. 尝试更新我们的refs/heads/abc。此更新不是强制性的。该更新是由我们在命令行上告诉我们的Git导致的。
  3. 尝试更新我们的refs/remotes/origin/xyz。此更新是强制性的。该更新是由我们通过remote.origin.fetch告诉我们的Git导致的。
由于refs/heads/refs/remotes/都是分支样式的名称空间,我们的Git(我们知道至少是1.8.4)遵循此处的分支更新规则。这些规则告诉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设置了force标志,因此将发生对refs/remotes/origin/xyz的更新。如果更改是快进,它将是一个正常的快进非强制更新。如果更改不是快进,则会进行非快进强制更新。
在Git 1.8.2及更早版本中,Git错误地将分支更新“必须是快进操作”的规则应用于标签名称。在Git 1.8.4中,此问题已得到修复。然而,某个时间点引入了一个新的错误。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其他分支之后,例如将HEAD附加到develop上,然后git fetch愿意更新master了,现在您可以运行git fetch origin master:master(假设您更喜欢较短、稍微有风险、未经修饰的拼写)。

这个特殊限制的原因与我们上面提到的关于git merge如何解决快进合并的区别有关:git merge会像运行git checkout一样更新索引和工作树,而git fetch命令从不更新索引和工作树。如果git fetch允许你将master快进到一个新的提交,你的索引和工作树可能会失控
问题在于,你的索引和工作树应该与你当前的提交匹配,除了你自从运行git checkout更改索引和工作树以来所做的任何工作。如果git fetch更新了HEAD所附加的refs/heads/空间分支名称,你的索引和工作树就不再与你当前的提交匹配,因为你当前的提交是存储在该分支名称中的哈希 ID。(如果你确实设法进入了这种状态,那么修复起来很麻烦,但是它是为什么Git允许向已添加的工作树的已检出分支推送?我该如何恢复?

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,我用它来完成这些操作。


1
关于问题1:是的。(请记住,我们假设默认的fetch设置。如果您将其更改为其他内容,则行为可能会发生变化。)关于问题2:同样,我最近没有测试过这个(也没有在每个Git版本中测试过),并且更新的控制顺序可能不存在。 Git的内部提取代码在Git的演变过程中的几个点上发生了相当大的变化,包括1.8.4之后。一般来说,未能更新任何一个引用并不排除Git继续进行其他引用的可能性,但我不确定在某些边角情况下会发生什么。 - torek
1
关于第三个问题:假设使用标准的remote.origin.fetch,是的。如果您想尝试非标准的fetch设置,可以将例如refs/heads/xyz映射为源到refs/heads/hellorefs/heads/world作为目标,或者将多个源映射到单个目标,以查看会发生什么。(这也是随着时间变化而改变的代码,因此您从Git版本中观察到的可能特定于您的Git版本。) - torek
1
关于Q4的问题:是的,index = staging-area(它也被称为cache,三个名称指的是同一件事)。是的,在git merge继续之前,索引/暂存区通常必须是“干净的”(即与HEAD提交匹配)。 (我认为至少有一条代码路径不必清除,但我不确定如何触发它。) - torek
嗨,torek,想要验证一件事情:如果我的HEAD在分支C上,并运行git pull origin A:B(A、B都是分支且成功执行),那么我看到A的远程跟踪分支正在合并到C中。我没有看到B的远程跟踪分支被合并到C中?这总是这种情况吗? - Number945
虽然-m消息可能对未来的读者很重要,但对于git merge实际上重要的是原始提交哈希ID(从FETCH_HEAD中提取)和此时的提交图。假设git merge进行合并提交,则合并提交的两个父提交将是当前的HEAD提交和此时由FETCH_HEAD标识的提交。任何(本地)分支名称所标识的提交都不相关——refs/heads/B是否匹配存储在FETCH_HEAD中的提交哈希ID的事实对于合并过程和结果并不重要。 - torek
显示剩余9条评论

1
在这里,需要将 origin/master 合并到 master 分支,但这不会是快进合并。使用 git fetch 将会失败。我不想使用强制获取,因为我不想丢失我的提交 C 和 B。
因此,您不应该使用 git fetch 来更新当前分支。可以使用带有合并或变基的 git pull。
...--o--o--A   <-- origin/master
            \
             C--B   <-- master 

你运行了 git pull origin master 命令,结果如下所示:

...--o--o--A-----D   <-- origin/master
            \     \
             C--B--M   <-- master 

使用git pull --rebase origin master命令,你就能到达那里:
...--o--o--A--D   <-- origin/master
              \
              C'--B'   <-- master 

(Rebase 重写提交记录 CBCB)。

我更喜欢总是使用 rebase,所以我有这个配置:

git config --global branch.autosetuprebase always

这使得 git 配置了每个新分支的 rebase。对于现有分支,更改是

git config branch.master.rebase true

这就是为什么你不应该使用git fetch来更新当前分支的原因。我从未说过我的主分支是当前分支。另外,从你的回答中我得出结论,fetch将始终导致快进式更新,因此似乎没有其他方法可以使用fetch。因此,在这种情况下我必须只能使用git pull。 - Number945

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接