执行 `git fetch upstream master:master` 和 `git pull upstream master:master` 有什么确切的区别?

4
我知道git fetch和git pull之间的区别。git pull基本上是一个git fetch + git merge的组合命令。
然而,我正在研究如何在不检出主分支的情况下更新我的派生分支(主分支)与上游分支。我发现了这个SO答案 - Merge, update and pull Git branches without checkouts 但是当我已经在主分支上检出后使用git fetch upstream master:master时,我遇到了这个错误 -
fatal: Refusing to fetch into current branch refs/heads/master of non-bare repository

所以,我尝试了git pull upstream master:master,它起作用了。有趣的是,执行git pull upstream master:master会更新我的分支与上游的版本,无论我是否在主分支上。而git fetch upstream master:master只有当我不在主分支上时才有效。
从这里有知识的人那里阅读解释将非常有趣。

1个回答

4

git pull基本上是一个git fetch+git merge的组合命令。

是的,但正如你所猜测的那样,它不止于此。

在你链接到的答案中,Bennett McElwee的评论实际上有其中一个关键内容。他提到你可以:

使用fetch origin branchB:branchB,如果合并不是快进,则会安全地失败。

另一个没有很好记录的功能是-u,也称为--update-head-ok选项,在git fetch中,git pull将其设置。虽然文档定义了它的作用,但有点神秘和可怕:

默认情况下,git fetch拒绝更新与当前分支对应的head。此标志禁用了检查。这纯粹是供git pullgit fetch通信的内部使用,除非您正在实现自己的Porcelain,否则不应使用它。

这让我们想起了你的观察:

所以,我尝试了git pull upstream master:master,它起作用了。有趣的是,无论我是否在主分支上,都会使用git pull upstream master:master将我的fork更新为upstream。而git fetch upstream master:master只有当我不在主分支上时才有效。

这是由于那个-u标志。如果你运行git fetch upstream master:master,那么它会在某种意义上“工作”,但会留下另一个问题。警告出现是有原因的。让我们看看这个原因是什么,并确定警告是否过于严厉。警告:这里有很多内容!以下大部分复杂性都是为了弥补历史错误,同时保持向后兼容性。

分支名称、引用和快进操作

首先,让我们谈谈引用快进操作

在Git中,引用只是一种谈论分支名称(如master)、标签名称(如v1.2)、远程跟踪名称(如origin/master)或其他任何名称的方法,所有这些名称都以一种通用和合理的方式进行分组:我们将每种具体的名称类型分组到一个命名空间中,或者单个词语,即命名空间。 分支名称位于refs/heads/下,标签名称位于refs/tags/下,因此master实际上只是refs/heads/master

所有这些名称都以refs/开头,每个名称都是一个引用。 还有一些不以refs开头的额外引用,尽管Git在决定像HEADORIG_HEADMERGE_HEAD这样的名称是否实际上是引用时内部有点不稳定。1 最终,引用主要作为拥有Git对象哈希ID的有用名称的一种方式。 特别是分支名称具有有趣的属性:它们会以一种Git称为快进的方式从一个提交移动到另一个提交。

也就是说,给定一个具有一些提交(这里表示为大写字母)的分支,以及具有包括第一个分支上所有提交的更多提交的第二个分支:

...--E--F--G   <-- branch1
            \
             H--I   <-- branch2

Git允许将branch1向前滑动,以便指向先前仅通过名称branch2可到达的任何提交。2 相比之下:

...--E--F--G------J   <-- branch1
            \
             H--I   <-- branch2

如果我们将名称branch1从提交J移动到指向提交I,那么提交J会发生什么?3这种移动会使提交留下,是对分支名称的非快进操作。

这些名称可以通过省略refs/部分或者经常省略refs/heads/部分或refs/tags/部分等来缩短。 Git将在其引用名称数据库4中查找第一个匹配项,并使用gitrevisions文档中描述的六步规则。例如,如果您有refs/tags/masterrefs/heads/master,并且输入master,Git将首先匹配refs/tags/master并使用标签。5


1如果引用是具有或可以具有reflog的名称,则HEAD 引用,而ORIG_HEAD和其他*_HEAD名称不是。 不过,在这里边界处有些模糊。

2这些提交可能通过更多名称可达。 重要的是,在快进之前,它们不能通过branch1到达,在之后可以。

3直接的答案实际上是没有任何事情发生,但最终,如果提交I不通过某些名称可达,则Git将垃圾回收该提交。

4这个“数据库”实际上只是目录.git/refs和文件.git/packed-refs的组合,至少目前是这样。 如果Git找到了文件条目路径名,则路径名的哈希值会覆盖packed-refs文件中的哈希值。

5例外:git checkout首先尝试将参数作为分支名称处理,如果成功,则将master视为分支名称。 Git中的其他所有内容都将其视为标签名称,因为在分支名称的步骤4之前,添加前缀refs/tags是步骤3。


Refspecs

现在我们知道引用只是指向提交的名称,分支名称是一种特定类型的引用,其中快进是日常事务,让我们来看一下refspec。首先让我们从最常见和可解释的形式开始,这仅仅是两个引用名称,由冒号分隔,例如master:master或HEAD:branch。
Git在连接两个Git(例如在git fetch期间和git push期间)时使用refspecs。左侧的名称是源,右侧的名称是目标。如果你正在执行git fetch,则源是其他Git存储库,目标是您自己的存储库。如果您正在进行git push,则源是您的存储库,目标是他们的存储库。(在使用"."的特殊情况下,它表示此存储库,源和目标都是你自己,但所有内容仍然正常工作,就像你的Git正在与另一个单独的Git通信一样。)
如果您使用全限定名称(以refs/开头),则可以确定将获取哪一个名称:分支、标签或任何其他名称。如果您使用部分限定或未限定名称,则Git通常仍会理解您的意思。偶尔会出现Git无法理解您的情况;在这种情况下,请使用完全限定名称。
您甚至可以通过省略两个名称中的一个来简化refspec。Git通过冒号消失的哪一侧来知道您省略了哪个名称::dst没有源名称,而src:没有目标名称。如果您编写名称,则Git将其视为名称:一个没有目标的源。
这些意味着什么是不同的。对于git push,空源表示删除:git push origin:branch让您的Git请求他们的Git完全删除该名称。对于git push,空目标表示使用默认设置,通常是相同的分支名称:git push origin branch通过请求他们的Git设置名为branch的分支来推送您的分支。注意,直接向他们的分支进行git push是正常的:您将提交发送给他们,然后请求他们设置其refs/heads/branch。这与正常的fetch非常不同!
对于git fetch,如果目的地为空,则表示不要更新我任何引用。 如果目的地不为空,则表示更新我提供的引用。但与git push不同的是,在这里您可能会使用的常规目标是一个远程跟踪名称:您将他们的refs/heads/master获取到自己的refs/remotes/origin/master中。 这样,您的分支名称master - 您的refs/heads/master - 就不会被修改了。
然而,出于历史原因,通常形式的git fetch只写为git fetch remote branch,省略了目的地。在这种情况下,Git执行了一些看似相互矛盾的操作:
  • 没有将分支名称更新到任何地方。缺少目的地意味着不会更新任何(本地)分支。
  • 它将哈希 ID 写入到.git/FETCH_HEAD中。每次git fetch获取的所有内容都在此处。这就是git pull查找git fetch操作的方法和位置。
  • 它更新远程跟踪名称,如refs/remotes/origin/master,即使没有告诉它这样做。Git将此称为“机会主义更新”。
(实际上,其中大部分是通过在您的.git/config文件中找到的默认refspec来控制的。)
您还可以通过添加前导加号+来使refspec变得复杂。这将设置“强制”标志,该标志覆盖了分支名称运动的默认“快进”检查。这是远程跟踪名称的正常情况:您希望Git将您的refs/remotes/origin/master更新为与他们的Git的refs/heads/master相匹配,即使那是一个非快速前进的更改,以便您的Git始终记住上次访问其Git时,他们的master位于哪里。
请注意,前导加号仅在存在要更新的目标时才有意义。这里有三种情况:
  • 您正在创建新名称。这通常是可以的。7
  • 您没有更改名称:它以前映射到提交哈希H,请求指示将其设置为映射到提交哈希H。这显然没问题。
  • 您正在更改名称。这个问题又分为三个子可能性:
    • 它根本不像分支名称,例如它是标签,不应该移动。您需要一个强制标志来覆盖默认拒绝。8
    • 它是类似分支的名称,并且分支运动是快进的。您不需要强制标志。
    • 它是类似分支的名称,但运动不是快进的。您需要强制标志。

这涵盖了更新引用的所有规则,除了最后一条规则,我们还需要更多背景知识才能理解。


6您可以通过将push.default设置为upstream来使变得复杂。在这种情况下,如果您的分支fred的上游设置为origin/barney,则git push origin fred会要求他们的Git设置名为barney的分支。

7针对各种更新情况,您可以编写挂钩以验证名称和/或更新。

8在Git 1.8.3之前的版本中,Git意外地使用了分支规则来更新标签。因此,这仅适用于1.8.3及更高版本。


HEAD非常特殊

请记住,像master这样的分支名称只是标识某个特定的提交哈希:

$ git rev-parse master
468165c1d8a442994a825f3684528361727cd8c0

您还看到过git checkout branchname的行为与git checkout --detach branchnamegit checkout hash的行为不同,会给出有关“分离的HEAD”的可怕警告。尽管HEAD在大多数方面都像引用一样,但在某些方面,它非常特殊。特别是,HEAD通常是一个符号引用,在其中包含一个分支名称的完整名称。也就是说:
$ git checkout master
Switched to branch 'master'
$ cat .git/HEAD
ref: refs/heads/master

这告诉我们当前分支的名称为master,而HEAD附着于master。但是:

$ git checkout --detach master
HEAD is now at 468165c1d... Git 2.17
$ cat .git/HEAD
468165c1d8a442994a825f3684528361727cd8c0

之后,git checkout master 将我们像往常一样放回到 master 上。

这意味着当我们有一个分离的 HEAD时,Git 知道我们已经检出了哪个提交,因为正确的哈希 ID 正好在名称 HEAD 中。如果我们对存储在 refs/heads/master 中的值做一些任意的更改,Git 仍然会知道我们已经检查出了哪个提交。

但是,如果 HEAD 只包含名称 master,那么 Git 所知道的当前提交是,比如说,468165c1d8a442994a825f3684528361727cd8c0,唯一的方法就是将 refs/heads/master 映射到 468165c1d8a442994a825f3684528361727cd8c0。如果我们做了某些可能 更改 refs/heads/master 到其他哈希 ID 的操作,Git 将认为我们已经检出了该提交。

这有关系吗?有!让我们看看其中的原因:

$ git status
... nothing to commit, working tree clean
$ git rev-parse master^
1614dd0fbc8a14f488016b7855de9f0566706244
$ echo 1614dd0fbc8a14f488016b7855de9f0566706244 > .git/refs/heads/master
$ git status
...
Changes to be committed:
...
        modified:   GIT-VERSION-GEN
$ echo 468165c1d8a442994a825f3684528361727cd8c0 > .git/refs/heads/master
$ git status
...
nothing to commit, working tree clean

改变存储在master中的哈希ID会改变Git对状态的理解!
状态涉及HEAD与索引以及索引与工作树
git status命令运行两个git diffs(内部为git diff --name-status):
比较HEAD与索引
比较索引与工作树
请记住,索引(也称为暂存区或缓存)保存当前提交的内容,直到我们开始修改它以保存下一个提交的内容。 工作树只是这个整个更新索引、然后提交过程的一个小助手。 我们只需要它,因为索引中的文件处于特殊的Git-only格式中,我们系统上的大多数程序都不能使用。
如果HEAD保存当前提交的原始哈希ID,则无论我们如何使用分支名称,比较HEAD与索引的结果都不会改变。 但是,如果HEAD保存一个特定的分支名称,并且我们更改了该特定分支名称的值,然后进行比较,我们将比较一个不同的提交与我们的索引。 索引和工作树不会改变,但是Git对不同的当前提交与索引之间的相对差异的理解会改变。
这就是为什么git fetch默认拒绝更新当前分支名称的原因。 这也是为什么您不能将内容推送到非裸库的当前分支的原因:该非裸库具有索引和工作树,其内容可能旨在与当前提交匹配。 如果通过更改存储在分支名称中的哈希值来更改Git对当前提交的理解,则索引和工作树可能不再与提交匹配。
这并不致命,事实上完全不是。 这正是git reset --soft所做的:它更改附加到HEAD的分支名称,而不触及索引和工作树中的内容。 同时,git reset --mixed更改分支名称和索引,但保持工作树不变,而git reset --hard一次性更改分支名称、索引和工作树。
快进“合并”基本上是一个git reset --hard
当你使用git pull命令运行git fetch,然后运行git merge时,git merge步骤通常能够执行Git称之为“快进合并”的操作。虽然这不是一个合并操作,而是在当前分支名称上执行的快进操作,紧接着立即更新索引和工作树内容到新提交的状态,就像git reset --hard一样。关键区别在于,git pull会检查 - 好吧,理论上应该会检查9 - 这个git reset --hard不会破坏正在进行中的工作,而git reset --hard本身则故意不检查,以便让您扔掉不再需要的正在进行中的工作。

9历史上,git pull经常犯错,并且在有人失去了大量工作后才得以修复。避免使用git pull


将所有这些组合在一起

当你运行git pull upstream master:master时,Git首先运行:

git fetch --update-head-ok upstream master:master

这个命令会让你的Git调用在upstream中列出的URL上的另一个Git,并从它们那里收集提交,这些提交通过它们的名称master找到——即master:master refspec的左侧。然后,你的Git使用refspec的右侧更新你自己的master,通常是refs/heads/master。如果master是你当前的分支——如果你的.git/HEAD包含ref: refs/heads/master,则fetch步骤通常会失败,但-u--update-head-ok标志可以防止此类失败。

(如果一切顺利,你的git pull将运行其第二个git merge步骤:

git merge -m <message> <hash ID extracted from .git/FETCH_HEAD>

但让我们先完成第一步。快进规则确保您的master更新是一个快进操作。如果不是,则提取失败,您的master不会改变,并且pull在此处停止。到目前为止,一切都好:只有当从upstream获取的新提交(如果有)使得快进成为可能时,您的master才会被快进。
此时,如果您的master已更改且它是当前分支,则您的存储库现在不同步:您的索引和工作树不再与您的master匹配。但是,git fetch也将正确的哈希ID留在了.git/FETCH_HEAD中,您的git pull现在继续进行类似于重置的更新。实际上,这使用的是等效于git read-tree而不是git reset的命令,但只要它成功 - 鉴于pull之前的检查,它应该会成功 - 结果就是相同的:您的索引和工作树将与新提交匹配。
或者,也许master不是您当前的分支。也许您的.git/HEAD包含ref: refs/heads/branch。在这种情况下,您的refs/heads/master会像git fetch一样安全地进行快进。您的.git/FETCH_HEAD包含与更新后的master相同的哈希ID,您的git pull运行git merge以尝试合并 - 这可能是快进操作,也可能不是,具体取决于分支名称branch当前指向的提交。如果合并成功,则Git将创建一个提交(真正的合并)或像之前那样调整索引和工作树(快进“合并”),并将适当的哈希ID写入.git/refs/heads/branch。如果合并失败,则Git停止并出现合并冲突,要求您像往常一样清理混乱。
最后一种可能的情况是,您的HEAD处于分离状态,但这与ref: refs/heads/branch的情况相同。唯一的区别是,当所有事情都说完了,新的哈希ID直接进入.git/HEAD而不是.git/refs/heads/branch

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