Git 在工作区中没有最近的更改的情况下变基

Git rebase without having most recent changes in workspace

今天我遇到了以下情况(注意:所有分支都被推送到远程存储库):

当我尝试使用 GitHub Desktop 将 X 变基到 Y 时,我收到以下消息:“这将通过在 Y 之上应用其 C 提交来更新 X”。但是,A != C,我花了一些时间在谷歌上搜索以了解数字 C 的来源。

后来,我意识到我在分支 Y 中缺少 git merge。执行后,GitHub Desktop rebase 工具给出了与 C == A 相同的消息。

我不确定数字 C 是从哪里来的,也不知道为什么在 git merge 命令之后 C == A。 有什么提示吗?

如果没有确切的具体细节,很难确定为什么会得到特定的结果。但是有一个一般规则你可以在这里使用:git rebase是关于复制(一些)提交new-and-improved(或supposedly-improved)提交。 也就是说,您已经有一些现有的提交,但是有一些您不喜欢关于 那些提交的内容。这可能包括以下内容的任意组合(或您可能对您的提交感到反感的任何其他内容):

  • 其中一条提交消息有错字,and/or
  • 其中一个提交的 changes 有一个错误,and/or
  • 所有提交在消息 and/or 更改方面都很好,但它们从您不希望它们开始的提交开始:您希望它们从其他提交开始。

要了解其工作原理,让我们先快速回顾一下提交和分支名称的基础知识。如果您已经熟悉此部分,请随意跳过此部分。

基础复习

每次提交:

  • 已编号,带有一个丑陋的大哈希 ID,它看起来是随机的(但不是),它是 那个特定的 提交所独有的;
  • 是read-only:哈希ID其实是内容的加密校验和,所以你不能改变一个commit,只能把它拿出来用它来制作一个新的(一个“副本”,至少对其进行了一次更改),一旦制作将获得不同的哈希 ID;
  • 包含两部分:每个文件的完整快照,以及一些元数据。

快照以Git的内部、压缩、read-only和de-duplicated形式保存每个文件,因此如果任何一个提交中的任何一个文件的内容完全匹配任何提交中的任何其他文件的内容(包括同一个文件),这些内容只有一个副本。这使得可以一遍又一遍地重复提交相同的文件,因为每个文件实际上只有一个副本。

每个提交的 元数据 包含提交人的姓名和电子邮件地址,一些 date-and-time-stamps,等等。此元数据中包含以前提交哈希 ID 的列表。通常这个列表只有一个条目长,这个列表中的一个条目是提交的父(单数)。对于普通提交,这个单亲哈希 ID 会产生一个 backwards-looking 提交链,我们可以绘制它。

假设 latest 提交(在某个分支上)有一个哈希 ID,我们简称为 H。提交 H 包含快照和元数据,元数据 for H 包含一些较早提交的哈希 ID,我们将其称为 G简而言之。提交 H 因此 指向 之前的提交 G:

          G <-H

但是G一个提交,所以它有元数据,指向一些更早的提交F,这也是 一个提交,所以它有元数据,这......好吧:

... <-F <-G <-H

这条链永远向后延伸,或者更确切地说,向后延伸,直到我们达到 有史以来第一个提交,这是第一个 -不能 向后指向但没有:

A--B--...--G--H

(假设整个存储库中只有八次提交)。

为了快速找到 last 提交哈希 ID,Git 使用 分支名称 。您的分支名称,无论该名称是什么——我们暂时称它为 main——包含提交 H 的实际原始哈希 ID。所以分支名指向H,此时:

...--G--H   <-- main

如果您有多个分支名称,每个名称都指向一个特定的提交。该提交是 last this branch 的提交,无论这个名称是什么。所以,给定:

...--G--H   <-- develop, main

我们知道提交 H最后 两个 分支的提交。所有提交都在两个分支上。

一旦我们签出(或git switch到)这两个分支之一,我们就“在”那个特定的分支上。 Git 通过将特殊名称 HEAD 附加到一个分支名称来记住我们“在”哪个分支:

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

这里是 on branch main,正如 git status 所说。我们使用 提交H,但我们通过 名称main 使用它。如果我们 运行:

git switch develop

我们得到:

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

我们仍在使用提交H,但现在我们正在使用它通过名称develop.

导致我们想要变基的设置

不用担心我们如何进行新提交的所有细节,现在让我们“在”develop 上进行两个新提交。第一个,我们称之为提交 I,将指向现有的提交 H,而 Git 将 更新当前分支名称 这样 develop 现在指向 I insted H:

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

第二个新提交 J 将指向 当前提交 I 时我们 J 和 Git 将更新 develop 以指向 J:

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

现在,无论出于何种原因,通过何种过程,我们都会让我们自己的 Git 添加一个新的提交 K 到分支 main。也许我们 运行 git switch main 然后 git pull (带来一些新的提交 K 并添加它)然后我们再次 git switch develop ,但无论如何我们现在有:

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

我们现在决定我们喜欢关于提交 IJ 的一切,就他们对提交 H 然后 I 所做的更改而言我们放入其中的日志消息。但是我们喜欢他们spring来自提交H的事实。我们更希望他们 spring 来自提交 K。也就是说,我们希望我们的图片看起来像这样:

          I--J   [abandoned]
         /
...--G--H--K   <-- main
            \
             I'-J'  <-- develop (HEAD)

Commit I'I 的新改进变体:它具有与 I 相同的 更改 K ] 当我们将 IH 进行比较时,它具有与 I 相同的 日志消息 (以及作者和提交者等)。但它必然具有不同的 哈希 ID,这使得它成为 I' 而不是 I。然后提交 J'JI 所做的 更改 I' 进行相同的更改,并具有相同的日志消息等作为原始提交 J。但是提交 J' 具有不同的哈希 ID,因为它是一个不同的提交,具有父级 I',并且提交 I' 指向提交 K。这正是我们想要的!

因为我们放弃了原来的 I-J 序列,我们 find 通过 Git 从分支名称开始提交并向后工作,我们现在只看到我们的 copied 提交。 它是 as if 提交 IJ 以某种方式神奇地改变了。它们不是:它们实际上 仍然存在 ,在存储库中,如果我们能以某种方式找到 J 的哈希 ID,我们就可以看到它们。1

这就是变基的动机。现在让我们来看看机制.


1Git 的 reflogs 使这变得简单,但您通常看不到 reflog 内容,所以你通常不会看到旧的 semi-abandoned 提交。不过,最终每个记住 otherwise-abandoned 提交的 reflog 条目都会过期,然后 Git 最终可能会真正丢弃该提交。在普通的日常存储库中,默认情况下这需要 至少 一个月。


git rebase 如何在粒度级别上工作

要真正一个变基,Git需要:

  1. 列出要复制的提交的原始哈希 ID。
  2. 选择一个地方来放置副本,并检查该提交(作为“分离的 HEAD”)。
  3. 复制每个 to-be-copied 提交,使用 git cherry-pick 或类似的东西一个一个地复制。
  4. 移动我们开始整个事情时所在的分支名称。

(有一个可选的第 0 步,“切换到其他分支”,它也会影响第 4 步,并且它有一个我认为非常糟糕的错误,你永远不应该使用第 0 步:它让你“开”它切换到的分支。也就是说,如果你 运行 这种变基,当你 运行 git rebase torek-does not-recommend-this 时,你在哪个分支上并不重要。相反,Git switches to not-recommend-this and then 运行s git rebase and you end up on branch not-recommend-this. 这太混乱了,所以不要不要那样做。运行 你自己的 git switchgit checkout 命令你自己作为“第 0 步”。但是如果你个人 找到它令人困惑,请随意使用它。)

让我简要谈谈git cherry-pick。我在上面注意到每个提交都是一个 快照 。这不是一组变化!然而,普通 (non-merge) 提交得到 shown 作为更改。 (尝试一下:运行 git show 查看当前提交,显示为自其父项以来的更改,或 git log -p 查看显示为更改的 each 提交. 请注意 git log -p 不会费心将合并显示为更改:这太难了。)

Git 将通过简单地将两个提交提取到两个临时区域(实际上在内存中)来向您显示更改。也就是说,如果我们提交 J:

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

并且我们 运行 git show、Git 提取提交 IJ 的快照。对于这两个快照中 相同 的所有文件,Git 根本不执行任何操作(由于内部 de-duplication,这变得非常快:Git 看到文件 README.txt,比如说,在 IJ 中共享一个底层副本,甚至根本不费心提取它)。但是,对于 不同 的文件,Git 获取两个提取的副本并进行比较,line-by-line,玩一种 Spot the Difference 的游戏. Git 然后向您展示 发生了什么变化 在该文件中。

两个提交都有一个快照,但是你看到一个“差异”,就好像提交J持有changes-since-commit-I。它没有:你看到的是一种视错觉或海市蜃楼。 Git 这样做是因为人们发现这个观点 比真正的 view-as-snapshot.

更有用

git cherry-pick所做的是使用Git的合并机制复制一些view-as-a-diff 从一些 commit-pair,例如 H-vs-I,到一些其他提交中的快照,例如 K。出于 space 原因,我们将跳过所有细节,只是说根据 git merge,这是假装合并,提交 H 作为 合并基础 并提交 I 作为 --theirs 提交,提交 K 作为 --ours 提交。这解释了为什么“我们的”和“他们的”在变基期间似乎颠倒了。 (有些是,有些不是,总体来说很复杂。)

无论如何,让我们回到我们的图表:

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

我们要Git复制的提交是IJ,我们要Git到的地方 副本是“在 K.

之后

我们将这两个东西指定为git rebase的方式是运行:

git rebase main

当我们“在”分支上时 develop。 Git 运行s 的内部等价物:

git log main..develop

找到提交 IJ 的哈希 ID(如果你这样做,它们会倒退,所以 Git 实际上使用 git rev-list --reverse --topo-order 和一堆其他魔术来解决这个问题并完成其他特殊技巧)。现在 Git 有哈希 ID 列表,它保存在某个文件中(因为 git rebase 可能需要退出然后稍后重新启动)。

列出了对 copy 的提交,Git 然后执行内部等效的:

git switch --detach main

这就是我们的照片:

          I--J   <-- develop
         /
...--G--H--K   <-- main, HEAD

特殊名称 HEAD 不再附属于任何分支 。相反,它直接指向我们已签出的提交。

Git 现在 运行s git cherry-pick <em>hash-of-I</em>,或或多或少等价的东西。这会将 H-vs-I 更改复制到我们的工作区和 Git 的索引中,并将更新的文件用于 运行 内部 git commit。此内部提交 re-uses 来自提交 I 的作者和日志消息信息(再次通过保存的哈希 ID),这使得新提交 I':

          I--J   <-- develop
         /
...--G--H--K   <-- main
            \
             I'  <-- HEAD

一旦是一个,Git 运行s git cherry-pick <em>hash-of-J</em> ,将J复制到J'

          I--J   <-- develop
         /
...--G--H--K   <-- main
            \
             I'-J'  <-- HEAD

所有复制现在都完成了,Git只需要从提交J中提取名称develop并使其指向改为 J'。为此,Git 使用 git branch -f develop HEAD 的内部等价物,导致:

          I--J   [abandoned]
         /
...--G--H--K   <-- main
            \
             I'-J'  <-- develop, HEAD

然后执行内部 git switch develop(可能在内部 git branch -f 步骤期间:它们可以合并为 git switch -C)到 re-attach HEAD,给予:

          I--J   [abandoned]
         /
...--G--H--K   <-- main
            \
             I'-J'  <-- develop (HEAD)

这就是我们要求的变基; Git已经做到了。

While I was trying to rebase X onto Y using GitHub Desktop ...

GitHub Desktop 不是 command-line Git,它可以做自己的事情;只有熟悉 GitHub Desktop 的人才能确切地说出它的作用。但是,如果它 自动 到达 GitHub,那么它最终会定期做同样的事情 Git 会在这里做,它会做常规 Git 做的事情:

git switch X
git rebase --onto Y <upstream>

对于 git rebase main 的情况,我没有使用 --onto 标志。我们看到在那种情况下,Git 做了:

git log main..develop

假设我们从这个开始,但是:

            J   <-- feature2 (HEAD)
           /
          I   <-- feature1
         /
...--G--H--K   <-- main

我们已经决定提交 J 独立于 提交 I,我们想将 J 复制到一个K.

之后的新改进 J'

如果我们运行:

git rebase main

我们将复制提交 I J。那太多了。我们只想复制 J。我们如何告诉 Git 到 运行:

git log feature1..feature2

所以它只找到 J,然后 运行:

git switch --detach main

以便 JK 之后被复制?答案是我们使用--onto:

git rebase --onto main feature1

这将“要复制/不复制的内容”部分(在本例中为 feature1..feature2)与“将副本放在哪里”部分分开。

--onto 名称(或提交哈希 ID)指定放置副本的位置。这使 other 名称变得不同:feature1,在我们的例子中。

现在 Git 将只列出一个提交,复制一个提交,然后拉出分支名称:

            J   [abandoned]
           /
          I   <-- feature1
         /
...--G--H--K   <-- main
            \
             J'  <-- feature2 (HEAD)

我们得到了我们想要的。

其他一些需要注意的棘手事情

当您在自己的存储库图中有 合并提交 时,如下所示:

          I--J
         /    \
...--G--H      M   <-- develop (HEAD)
         \    /
          K--L   <-- main

并且此时您选择 运行 git rebase main,您的 Git 将 完全丢弃 合并提交。我:

git log main..develop

将显示提交 IJM,但提交 Mtwo 父提交,即合并提交。 git cherry-pick 命令无法复制合并提交,2 因此 git rebase 甚至 尝试 。 rebase 命令只是 省略了 合并。结果通常是你想要的:

          I--J
         /    \
...--G--H      M   [abandoned]
         \    /
          K--L   <-- main
              \
               I'-J'  <-- develop (HEAD)

在没有看到废弃的合并和顶行的情况下查看时,看起来像:

...--G--H--K--L   <-- main
               \
                I'-J'   <-- develop (HEAD)

(请自行验证这些是相同的图纸,前提是您从未看过我们放弃的三个提交!)。

除了不复制any merge commit外,git rebase还会忽略其他commit:

  • 任何似乎已经被复制到“上游”的提交都被省略了(Git 使用 git patch-id 来决定这些),并且
  • 根据您运行 git rebase 命令的方式,Git 可能会使用 git merge-base --fork-point 来选择将哪个提交用作要复制的第一个提交,而不是比使用 git log <em>upstream</em>..HEAD.
  • 的结果

fork-point 事情变得复杂了;参见,例如 .


2但是,它可以使用 -m 选项伪造它。 -m 选项告诉 git cherry-pick 假装 合并提交只有一个父项(您指定其“父编号”)然后 Git使用父级作为 pseudo-merge-base 进行 cherry-pick 操作。但是,git rebase 命令从不使用此模式,甚至 --rebase-merges.

也不使用