首页 > 解决方案 > GitHub 新手:您的分支和“origin/master”已经分歧,分别有 1 和 2 个不同的提交。我该怎么办?

问题描述

我对 GitHub 很陌生(我坚持添加、提交和推送,并且没有玩过新的分支),今天试图推送一些更改。但是,我提交了一些文件并意识到我搞砸了,并试图通过运行来取消提交:

git reset --mixed HEAD~;

我尝试了几次推送和重置。我不确定我做了什么,但在检查 git 状态时我最终来到了这里:

Your branch and 'origin/master' have diverged,
and have 1 and 2 different commits each, respectively.

当我尝试推动时,它指出:

hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

所以我认为我现在落后了很多,因为有一些文件在最后两次提交中被跟踪,或者 git status 现在说是未跟踪的。此外,我不想丢失我在本地计算机上取得的任何进展。我怎样才能快进并推动我想做的改变,理想情况下不会丢失任何过去的提交或当前的进展?

标签: gitgithubmergebranch

解决方案


所以我认为我现在落后了很多,因为有一些文件在最后两次提交中被跟踪,或者 git status 现在说是未跟踪的。此外,我不想丢失我在本地计算机上取得的任何进展。我怎样才能快进并推动我想做的改变,理想情况下不会丢失任何过去的提交或当前的进展?

让我们将它们分成不同的组件,以便您可以正确理解发生了什么。我们在这里关心的组件是:

  • 存储库:您的计算机上有一个,GitHub 上有一个。
  • 在每个存储库中:
    • 提交
    • 分行名称

提交实际上是共享的,而分支名称则没有——它们以更奇特的方式处理。但是只有当你的 Git 在 GitHub 上调用 Git 时,才会在特定的连接点共享提交;然后你的 Git 和他们的 Git 就这些分支名称和提交进行一些对话。让我们稍后再说,首先关注 Git 存储库中内容:提交和名称。

名称

通常我先从提交开始,但这次,让我们从名称开始。除了分支名称之外,还有更多种类的名称,我们稍后再讨论,但现在,让我们关心一下分支名称是什么以及做什么。

分支名称只是一个名称 - 一系列字母,最好是和/或数字,有一些规则阻止您使用诸如br..an..ch分支名称之类的东西,但允许bra.nch. 该分支名称的主要功能是保存一个提交哈希 ID。这是最新提交的哈希 ID。所以没有commits,名字实际上对你没有任何好处。

提交

提交是 Git 真正的核心特性。几乎所有内容都与提交有关。提交保存文件的版本,永远——或者至少只要这些提交继续存在——但理解提交是如何做到这一点的,以及 Git 如何找到提交是很重要的。

让我们从一个简单的想法开始这部分:每个提交都有编号。然而,这些数字并不是简单的数数。它们不是提交 1、2 和 3。相反,每个数字都是一个丑陋的大哈希 ID。它看起来完全随机(尽管实际上它完全是非随机的)。4a0fcf9f760c9774be77f51e1e88a7499b53d2e2最新的提交(其编号为 )与其先前的提交没有明显的联系。

找到一个提交,您需要知道它的编号。但是这些数字看起来是随机的,而且对于人类来说太大太丑了以至于无法记住。这就是我们有分支名称的原因:它们记住最后一次提交的编号。但是等等:只知道最后一次提交有什么好处?好吧,让我们看一下每个提交的内容。

每个提交都有两个部分:它有它的数据,它是你所有文件的完整快照。我们稍后会回到这个话题。然后它有一些元数据,或者关于提交本身的信息。在此元数据中,您将找到提交人的姓名、提交时间以及提交原因:他们的日志消息。但 Git 还会存储并查找 Git 本身想要的另一条信息,那就是该提交的提交的编号(哈希 ID)。

任何提交的父级是该提交之前的提交。因此,对于普通的单亲提交——往往是其中的大多数——这意味着 Git 可以从最后一次提交开始并简单地向后工作。这正是 Git 所做的,我们可以这样绘制它:

A <-B <-C   <--master

在这里,我们有一个简单的存储库,其中只有三个提交,都在一个名为master. 该名称 master保存了最后一次提交的哈希 ID——我们正在调用C它,即使它有一些又大又丑的哈希 ID——并且该提交C保存了之前提交的哈希 ID B。所以 Git 可以使用名称来查找C,然后使用C来查找B

同时B持有较早commit的hash ID A,所以找到了B,Git就可以找到了AA是任何人的第一个提交,所以它只是没有父级。这让 Git 停止向后工作。

提交和分支

这里还有一个更有趣的皱纹,一旦我们有多个分支,就会出现这种情况。假设我们的存储库中有多达 8 个提交:

...--G--H   <-- master

我已经不再费心在提交之间绘制反向箭头了。没关系,因为所有提交都必然指向后退,而且提交还有另一件关键的事情:一旦你做出了提交,其中的任何内容都不会改变。1 因此,后向箭头被冻结,无法添加前向箭头。但是,分支名称并非如此:记住,master用于包含 commit 的真实哈希 ID C;现在它包含 commit 的实际哈希 ID H

如果我们现在创建一个的分支名称,新名称也将指向 commit H2 让我们画出:

...--G--H   <-- develop, master

我们实际使用的是哪个名称?Git 为我们提供了一个答案:我们应该将特殊的 name 附加到我们想要使用的分支上HEAD,像这样用大写3写成。所以:

...--G--H   <-- develop, master (HEAD)

表示我们使用的是 name master,它选择了 commitH,而:

...--G--H   <-- develop (HEAD), master

表示我们使用的是 name develop,它选择了 commitH


1它被冻结的原因是提交的编号——它的哈希 ID——是通过计算数据和元数据中所有位的安全哈希来构建的。这意味着实际上不可能更改提交。如果你拿出一个,把它变成普通数据,稍微改变一下,然后写回去,你就会得到一个新的不同的提交。原始提交保留在存储库中;您只是添加了一个新的、不同的提交。

2实际上,我们可以选择任何现有的提交来命名。但是,我们必须选择一些现有的提交:不允许有一个不指向某个现有提交的分支名称。

3在某些系统上,您可以输入head小写字母并使其正常工作。这是一个坏习惯,因为:

  • 它不适用于所有系统,并且
  • 当您开始使用时它会中断git worktree

如果您不喜欢输入单词HEAD,请考虑使用单字符同义词@


进行新的提交,第 1 部分

一个HEAD非常重要的地方是我们何时进行的提交。假设我们有:

...--G--H   <-- develop (HEAD), master

无论哪种方式,我们都在使用commit H。但是如果我们更改了一些文件和git add它们git commit,这会告诉 Git 进行的提交。让我们调用新的提交I并将其绘制进去,如下所示:

...--G--H
         \
          I

请注意,新提交的I级是现有提交H。那是因为我们是从 commit 开始的H

分支名称会发生​​什么变化? 答案是:Git 会自动更新HEAD附加的名称。由于HEAD曾经并且仍然附加到develop,这就是现在指向新提交的名称I

...--G--H   <-- master
         \
          I   <-- develop (HEAD)

如果我们现在git checkout master回到master,Git 将返回给我们现有的 commit H,将特殊名称附加HEADmaster,然后给我们:

...--G--H   <-- master (HEAD)
         \
          I   <-- develop

提交是只读的,那么文件如何工作?

我们之前提到过,提交是所有文件的实时快照。为了使这项工作顺利进行,Git 以特殊的、只读的、仅 Git 的、冻结的和去重复的格式存储每个文件。只有 Git 本身可以使用这些文件。因此,当我们选择要使用的提交时,Git 会将文件从提交中复制到工作区。那个工作区,里面有普通的日常文件,就是你的工作树工作树

重复数据删除意味着即使每次提交都有每个文件的完整快照,但大多数快照只是重新使用现有文件。也就是说,当我们提交时I,我们可能更改了一两个文件,而其他所有文件都保持不变。所以 commitI和 commitH实际上共享了他们的大部分文件。在某种程度上,他们可能也与早期提交共享大部分内容。(事实上​​,如果您将文件更改之前提交时的状态,新文件会自动与旧提交共享。)

这就是每次提交中的数据:所有文件的完整、冻结的快照——或者更确切地说,是你告诉 Git 放入该快照的所有文件的快照。那么这些是哪些文件?

进行新的提交,第 2 部分

每个提交都包含这些冻结格式的文件,这些文件需要扩展到您的工作树中。那么,您可能会假设,这git commit会获取您工作树中的任何内容并提交它。但这实际上并不是 Git 的工作方式。

除了原始的、提交中的、冻结的文件和工作树中的日常文件之外,Git 还保留每个文件的中间副本4 这个额外的副本位于一个非常重要的领域,或者最初命名如此糟糕,以至于它有三个名字。Git 称它为indexstaging area,或者有时——现在很少见—— cache。我将在这里使用术语索引,但请记住,暂存是指这些额外的副本。

每个文件的索引副本都处于冻结格式,准备好进入下一次提交。所以这意味着考虑 Git 的索引的一个好方法是它包含建议的下一次提交。该git add命令告诉 Git:将工作树、文件的普通格式副本复制到或返回到索引中,替换任何以前的副本。 这也将其准备为冻结格式(也对其进行重复删除),以便为下一个git commit.

当你运行时git commit,Git 会收集元数据所需的任何额外信息——例如你的姓名和日志消息——然后构建一个新的提交。然后它写出索引中的任何内容作为快照,添加元数据(包括当前提交作为新提交的父级)并进行新提交,然后更新HEAD附加到的任何分支名称。

如果您喜欢使用git commit -a,请注意这只会使索引中已经存在的git commit更新文件。它几乎等同于运行(更新已知文件)后跟. 5git add -ugit commit

您无法直接看到索引,6git status会隐含地告诉您索引中的内容。工作方式git status也很简单:

  • 你有一个当前的提交。那就是您的分支名称(通过查看找到HEAD)所说的是最后一次提交。该提交中有一堆文件,采用 Git 特殊的、内部的、冻结的格式。Git 也将此称为HEAD提交。

  • Git 有它的索引。其中有一堆文件,采用 Git 的特殊冻结格式——但与提交中的文件不同,它们可以用新副本替换。

  • 而且,你有你的工作树,你可以在其中做任何你想做的事情——包括创建全新的文件。

git status命令进行两个单独的比较:

  • 首先,它将HEAD提交中的所有文件与索引中的所有文件进行比较。对于每个相同的文件,它什么也没说。对于每个不同的文件(包括新文件或已消失文件),它表示该文件已暂存以进行提交

  • 然后git status将 Git 索引中的所有文件与工作树中的文件进行比较。对于每个相同的文件(从冻结形式展开后),它什么也没说。对于每个不同的文件,它表示该文件不是为提交而暂存的

这意味着您可以查看索引中可以更新的内容,而不必查看索引中与索引中的副本相同的每个文件


4从技术上讲,索引中的内容不是文件的副本,而是对冻结格式的 Git 内部blob 对象的引用。但是你通常不需要知道这一点——只有当你开始使用和使用 Git 的低级索引时才重要。git ls-files --stagegit update-index

5这里的主要区别是,如果新提交失败,该git commit -a方法会回滚索引。使用git add -u是一个单独的步骤,因此如果添加有效且提交失败,索引仍会更新。还有许多更微妙的区别,但我们将在这里忽略所有棘手的极端情况。重要的一点是 Git 从索引进行提交,通常只有一个索引——<em>索引——而其他一切都来自该索引。

6实际上,您可以看到索引中的内容:运行git ls-files --stage。请注意,这会将大量输出转储到大型存储库中!这个命令不是你通常使用的命令:它是供 Git 程序在内部使用的,而不是供用户使用的。


跟踪和未跟踪的文件

现在您知道 Git 从其索引进行提交,您终于可以正确理解已跟踪和未跟踪的文件了。跟踪文件的定义非常简单,但仍然很复杂:跟踪文件是目前在 Git 索引中的文件。

您可以随时将文件添加到索引中:. 现在跟踪该文件。您也可以随时从索引中删除文件:. 阻止删除您的工作树副本,以便您仍然可以看到该文件,但它不再在 Git 的索引中:该文件现在未跟踪。git add newfilegit rm --cached oldfile--cachedgit rm

但请记住:告诉 Git从某个现有提交中填充它的索引和您的工作树!所以 Git 会自己更新它的索引。如果 Git 的索引和你的工作树中现在有文件,而你的提交没有这些文件,Git将从它的索引和你的工作树中删除这些文件,这样你就会看到保存了什么在那个提交中。git checkout branchgit checkout

跟踪的文件是工作树中的任何文件,但不在 Git 的索引中。当你有这样一个文件——一个不在 Git 索引中的文件——以及git checkout其他一些也没有该文件的提交时,该文件继续不在 Git 的索引中,因此继续未被跟踪。

(当您有一个未跟踪的文件时,会出现一个偷偷摸摸的情况,然后要求切换到确实具有该文件的某个提交。我们不会在这里担心,但您可能会看到这可能是一个问题。)

摆脱提交

提交实际上很难摆脱(没有删除整个.git目录,这会丢失所有内容并且很少是一个好主意)。那是因为 Git 是为添加新提交而不是删除它们而构建的。但实际上你可以摆脱提交。

假设一些分支名称和一些提交系列:

...--G--H   <-- master (HEAD)

现在进一步假设我们可以说服 Git应该保留名称 ,而不是 commit 的哈希 ID ,而是commit 的哈希 ID ,如下所示:masterHG

       H
      /
...--G   <-- master (HEAD)

请注意,提交H实际上仍然存在,在存储库中。但是 Git从 ID 存储在名称中的提交开始向我们展示提交,例如. 现在的名字说commit是最新的 commit。提交点回到一些较早的提交(可能)。因此,如果我们向 Git 询问此存储库中的提交,我们将不会再看到提交。masterGGFH

(当我们绘制这些时,我们可以向上或向下推动“丢弃的”提交以将它们排除在外。我受 StackOverflow 文本约定的限制,但如果您在纸或白板上绘制这些,请随意以任何方式绘制它们,包括从分支名称到提交的长箭头。)

请注意,这仅适用于“尾”提交。也就是说,假设我们有:

...--G--H   <-- master (HEAD)
         \
          I   <-- develop

如果我们强制名称master指向commit ,那么GcommitI仍然指向 commit H,所以我们得到的是:

...--G   <-- master (HEAD)
      \
       H--I   <-- develop

也就是说,现在看起来我们H在 branch 上提交了develop。尽管如此,分支master现在以 commit 结束G,所以我们肯定做了一些事情

这就是这样git reset做的。当你跑的时候:

git reset --mixed HEAD~

你告诉你的 Git:找到与HEAD当前分支上的最后一次提交相比退一步的提交。然后,强制当前分支名称识别该提交。 如果你有:

...--G--H--I   <-- master

你这样做一次,你最终得到:

...--G--H   <-- master
         \
          I

如果你再做一次,名字master指向 commit G,并且H-I悬空。默认情况下,在您的存储库中,它们会保留一段时间——用户存储库至少有 30 天的时间来取回这些提交。(这里的机制是 Git 称为reflogs的东西,但我们不会详细介绍。)

--mixed论点git reset告诉 Git在移动事物时保持工作树不变。因此,工作树中的文件副本将被单独保留。随着--hard,git reset也调整那些。使用--soft,git reset单独保留 Git 的索引,但使用--mixed, Git 清空旧索引并从您选择的提交中填充它。

这 - 替换索引,但不理会工作树 - 很容易导致未跟踪文件的情况。特别是,假设 commitI添加了一个不在 commit 中的H文件。然后reset上面从 Git 的索引中删除新文件,将新文件留在你的工作树中。该文件现在在您的工作树中,但不在 Git 的索引中,这就是未跟踪文件的定义。

请记住,只要您仍然可以找到这些提交,所有提交的文件在这些提交中都是安全的。 通过使提交之类的提交I难以找到,您已经进行了设置,因此您可能无法轻松地获取文件的这些版本。但是任何git log向你展示的提交,嗯,这些提交都很容易找到。(我们跳过了使用 Git 的分离 HEAD模式来查看历史提交的想法,以便不必覆盖该模式,但这是查看历史版本的一种方式。)

添加更多 Git 存储库

既然您知道了存储库中的提交和分支名称是如何工作的——包括添加新提交和重置一些提交——是时候将 GitHub Git 添加到组合中了。

为了让您的 Git 调用其他 Git,您需要有一个 URL — 类似于ssh://git@github.com/...https://github.com/.... 你的 Git 会为你保存这个 URL,用一个简短易记的名字。Git将此称为远程。许多 Git 存储库只有一个远程,称为origin,我假设您的也是这种情况。

要让您的 Git 与另一个 Git 连接,您将运行以下三个命令之一:git fetchgit pushgit pull. 该git pull命令只是一个方便的包装器,它git fetch首先运行,然后是第二个 Git 命令,最好——嗯,认为最好——git fetch单独学习。所以这给了我们两个让 Git 互相交流的命令。

这两个命令本身就比较简单:

  • git fetch让你的 Git 调用他们的 Git,然后问他们有什么,而你没有。他们列出了他们的分支名称(和其他名称)和他们的提交哈希 ID。您的 Git 可以立即判断您是否有这些提交,因为每个Git 存储库中的哈希 ID 都是相同的(再次参见脚注 1)。如果您没有提交,您的 Git 会要求他们的 Git 将它们发送过来。他们这样做了,现在你也有了提交。

    现在您已经拥有了他们拥有的所有提交(加上您自己的任何未共享的提交),您的 Git 将创建或更新您的origin/*名称,以记住它们作为分支名称的内容。你的每个origin/*名字都是一个远程跟踪的名字7 这些只是您的 Git 对在您运行时在哪些分支名称中具有哪些哈希 ID 的记忆git fetch

    如果他们不更改他们的分支名称(永远或经常),您git fetch将每次都正确设置您的远程跟踪名称。如果他们确实经常更改它们,那么您需要git fetch经常运行以获取任何新名称和不同的提交哈希 ID。

    你可以git fetch像这样运行,完全没有参数。

  • git push让你的 Git 调用他们的 Git,然后在需要时给他们新的提交。这比 稍微复杂一点git fetch,因为为了让他们记住任何新的提交,他们必须更新他们的分支名称!他们没有为您提供相当于远程跟踪的名称。

    与 一样git fetch,您的 Git 会列出提交哈希 ID。他们检查是否有这些 ID。如果没有,他们会让你的 Git 发送这些提交(以及他们的文件,如果有必要的话——这里有很多花哨的东西来避免发送他们已经有副本的文件)。和以前一样,每个 Git 使用相同的哈希 ID 进行相同的提交这一事实使这很容易。

    然后,一旦他们的 Git 需要任何提交,您的 Git 就会发送一个或多个请求:如果可以,请将您的分支名称 ______(填写名称)设置为 ______(填写哈希 ID)。是否服从这个礼貌的要求取决于他们。或者,您的 Git 可以发送命令:将您的名字 _____ 设置为 _____! 是否服从仍然取决于他们。

    git push命令需要输入8origin因为很久以前,有人说语法将是这样,因此您必须在输入分支名称之前将其放入其中。然后,需要分支的名称。这告诉你的 Git 要发送哪个提交——你的 Git 像往常一样从你的分支名称中找到最后一个提交——并填写两个空白。也就是说,对于礼貌请求或强制命令,我们必须将分支名称和哈希 ID 都放在两个空格中。您的 Git 从您在此处输入的名称中获取这两者。9git push remote branchorigingit push

    他们告诉我们的 Git 他们是否遵守了命令。如果是这样,我们的 Git 会更新与他们的分支名称相对应的一个远程跟踪名称。也就是说,如果我们让他们更新他们master的 .git 文件,我们的 Git 会更新我们存储在origin/master. 由于我们没有找到他们的任何其他分支名称,因此我们的其他origin/*名称都没有得到更新。


7 Git 把这些东西叫做remote-tracking branch names,但是我发现这里的分支这个词让事情变得更加混乱,而不是更少;所以现在我把它排除在外,只称它们为远程跟踪名称

8可以设置git push默认推送当前分支,然后你可以忽略它——Git 会找出正确的远程和当前分支——但我喜欢显示显式版本。

9还有其他可用的选项,让您可以变得更漂亮。例如,如果你愿意,你可以从你的名字推grandpa-simpson送到他们的名字onion-on-my-belt。可以在每一侧使用完全不同的名称。但是不要在没有强烈需求的情况下这样做:它很快就会变得非常混乱。


快进和非快进

现在让我们想象一下,我们是一个 Git,它正在接收一个git push. 其他一些 Git 打电话给我们,问我们是否有 commit a123456。我们没有,所以他们给了我们。 a123456有 parent 9876543,我们确实有,所以这是我们唯一需要的提交。现在他们说:请,如果可以,请将您的设置mastera123456.

让我们画出我们所拥有的:

...--G--H   <-- master

假设提交的哈希 IDH 9876543. 那么新的提交a123456显然是一个新的提交I,它只是添加到我们现有的master中,我们可以像这样把它放进去:

...--G--H--I   <-- master

但是如果父母 , 9876543,不是commitH呢?如果是 commitG怎么办?也就是说,我们有:

...--G--H   <-- master

他们给了我们:

...--G--H
      \
       I

他们现在要求我们设置我们master要记住的提交I?如果我们这样做,我们将失去我们的承诺H。我们最终会得到:

       H
      /
...--G--I   <-- master

我们将无法再找到提交H。所以我们会对礼貌的请求说不因为这个操作不只是向我们的 中添加提交master,它还会删除提交。

如果他们向我们发送了一个强有力的命令——<em>将你的设置mastera123456!——我们可能会服从它,并放弃 commit H。如果他们不保留 commit 的副本H,它可能会很快消失。服务器端存储库通常没有 reflog,并且几乎可以立即删除被放弃/悬空的提交。

你自己的情况

我们可以画出你的情况,其中你masterahead 1, behind 2你的——你的Git 对他们的 Gitorigin/master的记忆。它可能看起来像这样:master

          I--J   <-- origin/master
         /
...--G--H
         \
          K   <-- master (HEAD)

如果愿意,您可以使用git push --force origin master向他们发送您的提交K并告诉他们放弃他们的I-J提交。但是,如果您想保留这些提交,请不要​​这样做。

如果愿意,您可以放弃自己的提交K

git reset [options] origin/master

会给你:

          I--J   <-- master (HEAD), origin/master
         /
...--G--H
         \
          K   [abandoned]

你的提交K会持续一段时间,尽管发现它会有点困难。如果您毕竟不想要它,那可能没问题。

您可以使用git merge将 commit 与 commit 的更改以及 commitH与 commitKH更改组合在一起J,以进行新的提交。您可以使用git rebase将现有提交复制K到添加到 commit 的新的不同提交J你可以做很多事情。每个都有一组不同的结果提交。记住提交是什么,为你做了什么,分支名称如何找到提交,并决定你想要哪些提交,你想假装哪些提交从未发生过——删除git reset和/或git push --force——并在你自己的本地存储库中进行设置你想要的方式。然后使用git push, 有或没有--force, 将新提交发送到 GitHub Git,并让他们设置分支名称以指向新提交,以匹配您自己在Git中的设置。


推荐阅读