为什么 `git 获取 . origin/master:master` 离开分阶段更改?

Why does `git fetch . origin/master:master` leave staged changes?

我想知道为什么以下会留下分阶段更改:

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 中的分支。显然它做得更多?但具体是什么?

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

By default git fetch refuses to update the head which corresponds to the current branch.

This flag disables the check.
This is purely for the internal use for git pull to communicate with git fetch, and unless you are implementing your own Porcelain you are not supposed to use it.

所以:

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

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

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

git fetch
git switch -C master origin/master

要正确理解为什么这会让您留下“待提交”的文件,您需要了解并牢记以下关于 所有 十件事 Git:

  1. 重要的是提交

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

  3. B运行ch 名称和其他名称,仅帮助您(和 Git)找到 提交。

  4. 它的工作方式是每个提交都有一个唯一的编号:一个大的、丑陋的、random-looking 散列 ID 让 Git 在所有Git 对象的大数据库(key-value store)中查找提交对象,包括提交对象和其他支持对象。 A name—b运行ch name, remote-tracking name, tag name, or any other name—holds one hash ID.

  5. 提交本身会找到更早的提交。每个提交都包含一些 previous-commit 个哈希 ID。大多数提交只有一个哈希 ID;我们称其为 提交的父级 。例如,这就是 git log 的工作方式:我们使用 b运行ch 名称找到 last 提交。 b运行ch 名称的哈希 ID 使名称“指向”提交。提交的父项的哈希 ID 导致提交向后指向其父项。它的父级也有一个哈希 ID,它指向另一个步骤,依此类推。

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

  7. 当前 b运行ch 名称因此决定了 当前提交.

  8. 每次提交都会保存每个文件的快照。因为它是由内部 Git 对象组成的(它们是 read-only,并且是其他程序无法读取的格式),所以 Git 必须将这些文件提取到一个作品中在您可以使用它们或更改它们之前。此工作区域称为您的 工作树work-tree。因此,实际上每个文件都有两个副本:当前提交中的已提交副本(read-only 和 Git-only),以及可用副本(read/write 和一个普通可用文件) .

  9. Git 不会从现有提交中创建 new 提交,也不会从您的工作树中提交。相反,它有每个文件的 third 副本。此副本采用内部 Git 格式,即 pre-de-duplicated,因此如果您实际上没有 修改 任何内容并 git add 编辑它,这第三个“副本”实际上只是共享提交的副本。 (提交本身也共享这些 de-duplicated“副本”,这很安全,因为它们都是严格的 read-only。)

  10. git fetch 的作用。

考虑到以上所有内容,让我们看看 git fetch 现在做了什么(并了解为什么您还需要 --update-head-ok 标志)。 绘制一些图表 来说明 Git 提交的工作原理,这也可能会有所帮助,所以我们将从那里开始。

提交链

我们从我们有一些提交系列的想法开始,每个提交都有自己的丑陋的大哈希 ID。我们不想处理真正的哈希 ID,因此我们将使用一个大写字母来代替哈希 ID。该链中的 last 提交有一些哈希 ID,我们将其称为 H。我们使用 b运行ch 名称 找到 这个名称,特殊名称 HEAD 附加到该名称:

            <-H   <--branch (HEAD)

我们通过从 b运行ch 名称中画出箭头来表示名称 branch 指向 提交 H。但是提交 H 本身指向一些较早的提交,所以让我们添加它:

        <-G <-H   <--branch (HEAD)

当然,提交 G 指向一个 even-earlier 提交:

... <-F <-G <-H   <--branch (HEAD)

现在,来自提交的“箭头”(存储在提交中的哈希 ID)与提交中的其他所有内容一样 read-only 和永久性。因为我们不能改变它们,而且我们知道它们指向后方,所以我将把它们画成连接线——部分是出于懒惰,部分是因为我没有很好的文本箭头绘制,我正准备多画一个b运行ch name:

          I--J   <-- br1
         /
...--G--H   <-- main
         \
          K--L   <-- br2

当我们有一个主 b运行ch,其提交在提交 H 处结束时,我们会遇到 这种 情况。然后我们创建了一个 new b运行ch name 也指向提交 H:

...--G--H   <-- main, br1 (HEAD)

当前提交仍然是提交H,我们将HEAD移动到新的名称br1。然后我们做一个新的提交,whih 我们将调用 I; I 将指向 H,因为我们进行了新的提交 I,其中提交 H 是当时的 当前提交 。 Git 因此将 I 的哈希 ID 写入名称 br1HEAD 附加到该名称:

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

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

  • HEAD 附加到 main,
  • 将提交 H 提取到您的工作树 我提到的这个 third-copy-of-every-file。

这给了我们:

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

从这里,我们创建另一个 b运行ch 名称,如 br2,将 HEAD 附加到它(这次保持提交 H),并创建新的提交,以进行最终设置。

索引/staging-area/缓存

请注意 third-copy-of-every-file 将如何匹配我们签出的任何提交。那是因为 Git 小心地 co-ordinates 它,因为我们移动我们的 当前提交 。 checkout 或 switch 命令在内部执行此协调。

这个third-copy-of-every-file有个名字。实际上,它有 三个 个名字,反映了它的使用方式,或者名字的选择有多么糟糕,等等。这三个名称分别是 indexstaging areacache。如今,姓氏主要出现在某些 Git 命令的标志中:例如 git rm --cachedgit diff --cached。其中一些命令允许 --staged(但 git rm 至少不允许,至少从 Git 2.29 开始不允许)。

我喜欢坚持使用无意义的原始术语 index,因为它有多种使用方式。尽管如此,除了它在合并冲突解决期间的扩展作用外,考虑索引 / staging-area 的一个好方法是它充当您的 建议的下一个提交 。通过使用 git checkoutgit switch,您可以让 Git 在您更改 b运行ch 名称时更新其自己的索引:运行ge:

          I--J   <-- br1
         /
...--G--H   <-- main
         \
          K--L   <-- br2 (HEAD)

在这里,我们正在提交 L,因此索引大概与提交 L 匹配,除了您通过 git add 更新的内容。如果所有三个副本都匹配——如果每个文件的索引副本与当前提交的副本匹配,并且每个文件的 work-tree 副本与其他两个副本匹配——我们可以从提交切换到提交,使用 git switchgit checkout。 Git 可以安全地破坏整个索引和 work-tree 内容,因为它们安全地 存储 提交 中,这完全 read-only,而且是永久性的——好吧,大部分是永久性的。它们很难摆脱,但如果你真的努力去做,有时你可以摆脱一些。 (我们不会在这里担心,只会将它们视为 read-only 并且是永久的。)

Remote-tracking 名称与 b运行ch 名称一样好用,可用于查找提交

您在问题中使用了 origin/master 这个名字。这是一个 remote-tracking 名字: 这是你的 Git 对其他 Git 的 master b运行通道。另一个 Git 是你用名字 origin:

与之交谈的人
git fetch origin

例如。短名称 origin 包含一个 URL,使用那个 URL,您的 Git 调用其他 Git。其他 Git 有 自己的 b运行ch 名称,不需要与您的 b运行ch 名称有任何关系。这些 b运行ch 名称在 他们的 存储库中找到提交。

如果您在 您的 存储库中有相同的提交——而且您经常会这样做——您可以拥有自己的 Git 设置一些名称来记住 那些你的 存储库中提交。您不想使用 b运行ch 名称,因为您的 b运行ch 名称是 您的,并且随意移动一些你自己的 b运行ch 名称是不好的。您的 b运行ch 名称可以帮助您找到 想要的提交,而不是其他人的。

因此,您的 Git 取了他们的名字——例如他们的 master,然后 更改了 。最终结果是这个名字缩写为 origin/master.1 我们可以把它们画成:

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

一个b运行ch name的特殊之处在于,如果你使用git checkoutgit switch,你可以得到“on b运行ch”。这就是如何将名称 HEAD 附加到名称 master.

remote-tracking 名称 的特点是它会被某些类型的 git fetch 更新。但是 Git 不会让你“获得”一个 remote-tracking 的名字。如果你 运行 git checkout origin/master,Git 会让你进入它所谓的 分离 HEAD 模式。对于新的 git switch,Git 要求您首先确认此模式:您必须 运行 git switch --detach origin/master 才能进入 detached-HEAD 模式。我将 detached-HEAD 模式排除在这个答案之外,但最终它非常简单:我们只是将特殊名称 HEAD 直接指向提交,而不是将其附加到 b运行ch姓名。亲问题在于,一旦我们做出任何 new 提交,我们所做的任何移动 HEAD——包括将其附加到 b运行ch 名称以脱离模式——这使得 找到 我们所做的新提交的哈希 ID 变得非常困难。


1Git的所有名字都倾向于缩写。你的master其实是refs/heads/master的缩写;你的 origin/masterrefs/remotes/origin/master 的缩写。例如,顶层 refs/ 下方的各种名称提供 name spaces 确保您自己的 b运行ch 名称永远不会与任何 remote-tracking 名称冲突。


正常的方式remote-tracking names help, via git fetch

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

Git 让您做的是克隆 中央存储库。你运行:

git clone <url>

您将获得自己的存储库副本。这会将 它的所有提交 复制到您自己的存储库,但是 - 首先 - none 它的 b运行ches .它执行此操作的方法是使用 git fetchgit clone 命令实际上只是一个方便的包装器,运行 最多可以为您提供六个命令,除了第一个命令外,其他所有命令都是 Git 命令:

  1. mkdir(或您的 OS 的等效项):git clone 将(通常)创建一个新的空目录来保存克隆。其余命令在 currently-empty 文件夹中获取 运行,但之后您必须导航到它。
  2. git init:这会创建一个新的 totally-empty 存储库。空存储库没有提交,也没有 b运行ches。 b运行ch 名称必须包含现有提交的哈希 ID,并且没有提交,因此不能有任何 b运行ch 名称。
  3. git remote add:这将设置一个遥控器,通常命名为 origin,保存您使用的 URL。
  4. git config,如果需要,根据您提供给 git clone.
  5. 的命令行选项
  6. git fetch origin(或您通过 command-line 选项选择的任何其他名称):这会从其他存储库获取提交,然后创建或更新您的 remote-tracking 名称。
  7. git checkout(或在 Git 2.23 或更高版本中,git switch):这会创建一个 new b运行ch name,并将 HEAD 附加到那个 b运行ch 名称。

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

假设您克隆的存储库有八次提交,我们将像以前一样将其称为 AH,还有一个 b运行ch 名称,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并给它一个遥控器的名字:它需要这个来知道规则是什么remote-tracking 个名字。所以你 运行 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存储在名称[=147=下]:

$ git config --get-all remote.origin.fetch
+refs/heads/*:refs/remotes/origin/*

(在这种情况下,只有一条输出线,所以 git config --get-all 做的事情与git config --get 可以,但是当使用 single-branch 克隆时,您可以使用 git remote 使它们成为两个或三个或 whatever-number-branch 克隆,然后 --get-all 得到不止一行。)

refspecs 和 refs

这个东西——这个 +refs/heads/*:refs/remotes/origin/*——就是 Git 所谓的 refspec。 Refspecs 在 the gitglossary 中定义得非常简单,在 fetch 和 push 文档中有更多详细信息,但是描述它们的简短方法是它们有两个部分,用冒号 : 分隔,并且可以选择使用加号作为前缀符号 ++ 前缀表示 force(与作为命令行选项的 --force 相同,但仅适用于由于这个特定的 refspec 而更新的 refs)。

冒号两边的部分是refs,可以按照通常的方式缩写。所以我们可以使用 b运行ch 名称,例如 master 和 运行:

git push origin master:master

(注意这里我已经跳转到了 git push 命令。它就像 git fetch 因为它需要这些 repositoryrefspec 参数,但它对 refspecs 的使用略有不同。)

我们对 origin 的默认提取 refspec 是:

+refs/heads/*:refs/remotes/origin/*

加号打开强制选项,这样我们的 Git 无论如何都会更新我们的 origin/* 名称。左边的refs/heads/*表示匹配所有b运行ch名称。右侧的 refs/remotes/origin/*git fetch 创建或更新我们的 origin/master 而不是我们的 master.

的原因

通过使用 refspec,您可以更改哪些名称 git fetch creates-or-updates。这样做时你至少要小心一点。当我们git fetch更新remote-tracking名称时,我们只是在更新我们的Git对其他一些Git的b运行ch名字的记忆。如果我们的 Git 的记忆以某种方式变得混乱(如果我们以某种方式弄乱了 refspec),那么,我们可以再次 运行 git fetch :大概 他们的 Git 没有搞砸 他们 b运行ch 的名字,所以我们正确地刷新我们的记忆并且一切都是固定的。但是如果我们 git fetch 在我们的记忆中写下 我们自己的 b运行ch 名字,这可能很糟糕: 我们的 b运行ch 名称是我们找到 我们的提交的方式!

由于git fetch可以写any ref,它可以写b运行ch名称,或标签名称,或remote-tracking名称,或 special-purpose 名称,例如用于 git bisectgit stash 的名称。这是一个很大的权力,所以小心使用它:如果你 运行 git fetch origin 你会有很多安全机制,但如果你 运行 git fetch origin <em>refspec</em> 你绕过它们,不管你想不想。

好吧,除了一个。在开始之前,让我们再看看 HEAD,然后再看看 git reset.

HEADgit reset

正如我们之前看到的,HEAD 告诉我们当前的 b运行ch 名称。由于 git fetch 可以写入 any ref——包括 b运行ch 名称——如果我们告诉它 can , 创建或更新任何 b运行ch 名称。其中包括 HEAD 是 attached-to。但是 current b运行ch name 决定了 current commit:

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

这告诉我们提交 H 当前提交

有时我们可能想移动我们当前的 b运行ch 以指向其他一些现有的提交。例如,假设我们做了一个新的提交 I:

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

然后我们立即决定提交 I 完全是垃圾并且想要摆脱它。为此,我们可以使用 git reset.

reset 命令非常复杂。2 我们将忽略其中的大部分,只关注移动 current b[=843 的变体=]频道名称。我们运行:

git reset --hard <hash-ID-or-other-commit-specifier>

和Git:

  • 使 current b运行ch name 指向选定的提交;
  • 使 index / staging-area 匹配选择的提交;和
  • 使我们的 work-tree 与选择的提交匹配。

这基本上就好像我们检查了一些其他的提交,但在这个过程中,把 b运行ch 名称拖到了我们。所以我们可以使用:

git reset --hard origin/master

或:

git reset --hard HEAD~1

或任何其他命名提交的方式 H (可能使用其实际哈希 ID,来自 git log 输出)。最终结果是:

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

提交 I 仍然存在,但现在很难找到。不再有 名称

请注意 git reset 如何交换 Git 的索引和我们的 work-tree 的内容。这样,一切都同步了:当前提交又是 H,暂存区匹配提交 H,我们的 work-tree 匹配提交 H。我们可以使用其他类型的git reset命令,如果我们这样做,情况就会不同。我们稍后会回到这个问题。


2在发t,太复杂了,我觉得,和老的git checkout一样,应该拆分成两条命令:git checkout变成了git switchgit restore。我不清楚 split-up git reset 使用哪两个名称,只是其中一个可能是 git restore


您的特定 git reset 相似

你运行:

git reset --hard master~4

让我们假设你当前的 b运行ch 也是 master(你没有说,但你的问题的其余部分清楚地暗示了这一点)。我们还假设您的 master 最初与您自己的 origin/master 同步,因此您开始于:

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

您的 git reset 这样做了:

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

没有提交更改(没有提交 can 更改,永远)但您现在正在使用提交 D。您的索引 / staging-area 和 work-tree 匹配提交 D。提交 D 当前提交

你的git fetch很不一般

接下来,你运行:

git fetch --update-head-ok . origin/master:master 

在这里,您使用了 . 而不是遥控器的名称。没关系,因为 git fetch 在这里允许的不仅仅是一个远程名称。您可以使用 URL 或路径名; . 算作一个路径名,意思是 这个库 。你的 Git,本质上,调用 itself,并询问 itself 它有哪些提交,以及它的 b运行ch 名称是。

你的 Git 中没有你的 Git 需要来自“其他” Git 的新提交(你的 Git 有它所拥有的那些提交,当然)所以 获取新提交 步骤什么都不做。然后,refspec origin/master:master 适用:您让“他们”查找“他们的”origin/master——那是您自己的 origin/master,它标识提交 H—并将其复制到您的 b运行ch 名称 master.

这是最后一个特殊 safety-check 出现的地方。通常,git fetch 将拒绝更新 current b运行ch name .那是因为当前的 b运行ch 名称决定了当前的提交。但是 --update-head-ok 标志 关闭了 安全检查,因此您的 git fetch 继续并更新当前的 b运行ch 名称。你的名字 master 现在指向提交 H.

没有的是Git没有更新它的索引或者你的work-tree.这两个人被单独留下。他们仍然匹配提交 D。所以当你现在有:

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

你的索引和 work-tree 匹配提交 D.

您可以使用 git reset --soft

获得相同的效果

有没有运行:

git reset --soft origin/master

您的 Git 会移动您当前的 b运行ch 名称 master 以指向提交 H。然而,--soft 告诉 git reset:

  • 不要更新您的索引,并且
  • 不要更新我的work-tree

所以你会和以前一样的情况。

git reset 与您的 git fetch 之间存在细微差别,但在这种特殊情况下完全没有影响。具体来说,当 git fetch 更新 ref 时,它可以执行 fast-forward 规则。这些规则适用于 b运行ch 名称和 remote-tracking 名称。 (1.8.2 之前的 Git 版本也意外地将它们应用于标签名称。)fast-forward 规则要求存储在某个名称中的 new 哈希 ID 是一个更新前存储在名称中的哈希 ID 的后代提交。

git reset 命令从不强制执行 fast-forward 规则。 git fetchgit push 命令会执行,除非强制更新(使用 --force 或 refspec 中的前导 + 字符)。