【问题标题】:Why does `git fetch . origin/master:master` leave staged changes?为什么`git fetch . origin/master:master` 离开阶段性变化?
【发布时间】:2021-04-04 04:56:58
【问题描述】:

我想知道为什么下面的叶子阶段性变化:

git reset --hard master~4 # reset in prupose of the next command 
# fetch from this repository... src: origin/master to destination: master
git fetch --update-head-ok . origin/master:master 
git status # -> Shows various staged files?

分支master 似乎与origin/master 同步。 但是: 现在我在master 上有各种暂存文件? 为什么会有这样的行为?我认为git fetch . origin/master:master 将我的本地分支 HEAD 更新为origin/master 中的分支。显然它做得更多?但究竟是什么?

【问题讨论】:

  • tl;dr: git status 告诉您头部提交的内容与索引中的内容之间存在差异。这并不奇怪,因为您检查了一个提交,然后将您的分支提示重置为另一个。
  • @jthill 本质上就是我在回答中写的。

标签: git git-fetch


【解决方案1】:

要正确理解为什么这会让您的文件“暂存以待提交”,您需要了解并牢记以下关于 Git 的十件事中的全部

  1. 重要的是提交

  2. 所有提交——事实上,任何类型的所有内部 Git 对象——都是严格只读的。

  3. 分支名称和其他名称只是帮助您(和 Git)找到提交。

  4. 它的工作方式是每个提交都有一个唯一的编号:一个大的、丑陋的、看起来随机的 hash ID,它让 Git 在大型数据库中查找提交对象(@987654321 @) 的所有 Git 对象,包括提交对象和其他支持对象。 名称——分支名称、远程跟踪名称、标签名称或任何其他名称——拥有一个哈希 ID

  5. 提交自己找到更早的提交。每个提交都包含一些先前提交的哈希 ID。大多数提交只有一个哈希 ID;我们称之为提交的。例如,git log 的工作原理是这样的:我们使用分支名称找到 last 提交。分支名称的哈希 ID 导致名称“指向”提交。提交的父级的哈希 ID 导致提交向后指向其父级。它的父节点也有一个哈希 ID,它指向另一个步骤,依此类推。

  6. 控制哪个分支名称是当前分支名称的是特殊名称HEAD。这通常“附加到”分支名称。如果您在没有分支名称或其他起点的情况下运行 git log,Git 将使用 HEAD 查找您当前的分支,然后使用分支名称查找最后一次提交。

  7. 因此,当前分支名称决定了当前提交

  8. 每个提交都包含每个文件的快照。因为这是由内部 Git 对象(它们是只读的,并且采用其他程序无法读取的格式)组成,所以 Git 必须先将这些文件提取到工作区中,然后才能使用或更改它们。此工作区称为您的工作树工作树。因此,实际上每个文件都有两个副本:当前提交中的已提交副本(只读和仅 Git),以及可用副本(读/写和普通可用文件)。

  9. Git 不会根据现有提交进行 new 提交,也不会根据工作树中的内容进行提交。相反,它有每个文件的第三 个副本。这个副本是内部 Git 格式,它是预先去重的,所以如果你实际上没有修改任何东西并且git add-ed 它,这第三个“副本”实际上只是共享提交的副本。 (提交本身也共享这些去重的“副本”,这是非常安全的,因为它们都是严格只读的。)

  10. 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。然后我们进行一个新的提交,我们称之为II 将指向 H,因为我们做了新的提交 I,而提交 H 是当时的当前提交。因此,Git 将 I 的哈希 ID 写入名称 br1HEAD 附加到名称中:

          I   <-- br1 (HEAD)
         /
...--G--H   <-- main

然后我们继续进行新的提交J。然后我们再次使用git switchgit checkoutHEAD 附加到main。 Git 会:

  • HEAD附加到main
  • 将提交 H 提取到您的工作树我提到的这个每个文件的第三个副本。

这给了我们:

          I--J   <-- br1
         /
...--G--H   <-- main (HEAD)

从这里,我们创建另一个分支名称,例如 br2,将 HEAD 附加到它(这次继续提交 H),然后进行新的提交,以进行最终设置。

索引/暂存区/缓存

注意每个文件的第三个副本将如何匹配我们已签出的任何提交。那是因为 Git 在我们移动我们的 当前提交 时仔细协调它。 checkout 或 switch 命令在内部进行这种协调。

每个文件的第三个副本都有一个名称。实际上,它有 三个 名称,反映了它的使用方式,或者名字的选择有多糟糕,或者其他什么。 ? 这三个名称分别是 indexstaging areacache。如今,姓氏主要出现在某些 Git 命令的标志中:例如 git rm --cachedgit diff --cached。其中一些命令允许--staged(但至少git rm 不允许,至少从Git 2.29 起不允许)。

我喜欢坚持使用无意义的原始术语 index,因为它有多种使用方式。尽管如此,除了它在合并冲突解决期间的扩展作用之外,考虑索引/暂存区域的一个好方法是它充当您提议的下一次提交。通过使用 git checkoutgit 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/master1我们可以把它们画在:

...E--F--G--H   <-- master (HEAD), origin/master

分支名称的特殊之处在于,如果您使用git checkoutgit 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/masterrefs/remotes/origin/master 的缩写。例如,顶层refs/ 下方的各种名称提供name spaces,以确保您自己的分支名称永远不会与任何远程跟踪名称冲突。


通过git fetch 远程跟踪名称帮助的正常方式

假设您和一位朋友或同事正在从事某个大项目。有一些 Git 存储库的集中副本,可能存储在 GitHub 或其他一些存储库托管站点(可能是公司或大学主机而不是 GitHub)上。无论如何,您和您的朋友都希望使用此存储库。

Git 让您做的是克隆集中式存储库。你跑:

git clone <url>

然后您将获得自己的存储库副本。这会将它的所有提交复制到您自己的存储库中,但是——起初——没有它的任何分支。这样做的方法是使用git fetchgit clone 命令实际上只是一个方便的包装器,它可以为您运行多达六个命令,除了第一个是 Git 命令:

  1. mkdir(或您的操作系统的等效项):git clone(通常)将创建一个新的空目录来保存克隆。其余命令将在这个当前为空的文件夹中运行,但您必须在之后导航到该文件夹​​。
  2. git init:这会创建一个新的、完全空的存储库。一个空的存储库没有提交也没有分支。分支名称必须包含现有提交的哈希 ID,并且没有提交,因此不能有任何分支名称。
  3. git remote add:这会设置一个远程,通常命名为 origin,保存您使用的 URL。
  4. git config,如果需要,根据您提供给git clone 的命令行选项。
  5. git fetch origin(或您通过命令行选项选择的任何其他名称):这会从其他存储库获取提交,然后创建或更新您的远程跟踪名称。
  6. git checkout(或在 Git 2.23 或更高版本中,git switch):这将为您创建一个新分支名称,并将HEAD 附加到该分支名称。

在第 6 步中创建的分支是您使用 -b 选项选择的分支 git clone。如果你没有选择-b 之一,你的 Git 会询问他们的 Git 他们推荐哪个分支名称,并使用那个。 (克隆一个完全空的存储库的特殊情况有一些紧急回退,因为现在你不能有一个分支名称,他们也不能推荐一个,但我们将在这里忽略这些极端情况。)

假设您克隆的存储库有八个提交,我们将像以前一样将它们称为AH,以及一个分支名称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-allgit 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 一样,它需要这些repositoryrefspec论点,但它对 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

HEADgit 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 switchgit 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 fetchgit push 命令可以,除非强制更新(在 refspec 中使用 --force 或前导 + 字符)。

【讨论】:

  • 哇(没什么可补充的)
  • 哇,令人难以置信的答案,我需要时间来阅读这篇文章,而且它包含了很多很难在其他地方获得的好内在函数
  • 它可能很好补充,我使用这个不寻常的fetch,因为我只想fast-forward更新我当前的主人以匹配远程origin/master。我不想执行git pull,因为它会更新我不想要的origin/master。所以我决定不理会这个不寻常的获取并使用git merge --ff-only origin/master。非常感谢这个很棒的答案。
  • 要将您的 current 分支快进更新到某个给定的提交,请使用git merge --ff-only &lt;commit&gt;。 (我经常这样做,以至于我有一个别名,git mff = git merge --ff-only。)这在命令方面比git fetch 技巧简单,并且不会让您的索引和工作树陷入困境,同时做所有事情适当的安全检查。 :-)
【解决方案2】:

--update-head-ok 手册页提到:

默认git fetch拒绝更新当前分支对应的head。

此标志禁用检查。
这纯粹是为了内部使用 git pull 与 git fetch 进行通信,除非您正在实现自己的 Porcelain,否则您不应该使用它。

所以:

  • 您已将索引重置为master~4
  • 然后,您已将 master 重置为 origin/master(这不是 master~4,而是其他一些提交)

Git 向您显示索引中的内容,但 HEAD 中没有:这些是已经暂存的文件(因为第一次重置),而不是 HEAD 中(指的是origin/master

如果您的目标是将 master 重置为 origin/master,请执行以下操作:

git fetch
git switch -C master origin/master

【讨论】:

  • 所以这里的误解是fetch 以某种方式更新索引,而实际上它没有?
  • @matt 是的,除了远程跟踪分支,我从未见过git fetch 更新任何内容。一个简单的git fetch 不应该改变任何关于索引或工作树的东西。
猜你喜欢
  • 2016-07-12
  • 2019-10-30
  • 2018-09-21
  • 2014-01-18
  • 2011-02-10
  • 2018-04-11
  • 2016-08-06
  • 2010-12-17
相关资源
最近更新 更多