如何在 Git 中使用多个堆叠分支进行变基?

Tre*_*ron 4 git

我想知道在 Git 中处理分支堆叠的正确方法是什么——我发现我的流程在两次堆叠后就崩溃了。假设我有以下设置:

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 //branch1
                           \
                            c7 - c8 // branch2
                                  \
                                   c9 - c10 // branch3
Run Code Online (Sandbox Code Playgroud)

假设我决定更新branch1。

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 - c11//branch1
                           \
                            c7 - c8 // branch2
                                  \
                                   c9 - c10 // branch3
Run Code Online (Sandbox Code Playgroud)

然后为了更新,我将把branch2重新设置为branch1,将branch3重新设置为branch2,理想情况下会得到以下结果:

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 - c11//branch1
                                \
                                 c7 - c8 // branch2
                                        \
                                         c9 - c10 // branch3
Run Code Online (Sandbox Code Playgroud)

我遇到的一个问题是,当branch1和branch2之间存在合并冲突并且我修复它们时,当我将branch3合并到branch2时,也会出现相同的合并冲突。实际上,出于某种原因,branch3 似乎包含了branch2 的提交,当我 rebase 时,事情就搞砸了,并且我遇到了大量的合并冲突,因为我将branch2 的后续提交合并到了branch2 的早期提交中,由于某种原因,这些提交仍然存在分支3. 事情看起来像这样:

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 - c11//branch1
                                \
                                 c7 - c8  // branch2
                                         \
                                c7  - c8 - c9 - c10 // branch3
Run Code Online (Sandbox Code Playgroud)

变基变成这样:

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 - c11//branch1
                                \
                                 c7 - c8  // branch2
                                         \
                                         c7'  - c8' - c9 - c10 // branch3
Run Code Online (Sandbox Code Playgroud)

我在这里做错了什么?对于堆叠分支是否有不同的变基方法?为什么branch3包含branch2的提交?

tor*_*rek 6

没有好的通用工具可以满足您的需求。有一些特定的技巧可能对您有用。特别是,有时您会需要git rebase --onto并且必须小心使用它。

\n\n

背景

\n\n

这里的问题是 Git 分支不会嵌套、堆叠或任何您想在此处使用的词。

\n\n

更准确地说,分支名称(例如masterbranch1through branch3)只是充当指针或标签。每一个都指向(或粘贴到)一个特定的提交。它们彼此之间没有任何固有关系:您可以随时随地添加、删除或移动任何标签。每个标签的唯一限制是它必须恰好指向一次提交。

\n\n

提交与其说是一个分支上,不如说是包含某些分支集中。给定的一对提交可能具有父/子关系。例如,在您的绘图中, commitc1是 commit 的父级c2。Git 实际上是通过让提交指向其他提交来实现这一点的,类似于分支名称指向提交的方式。但有一个区别:任何一次提交的内容都将永远冻结,包括其指针。这意味着子级指向。当您创建子项时,父项就存在,但反之则不然,因此子项可以指向父项,但反之则不然。

\n\n

(实际上,Git 是向后工作的。您已经绘制了向前的箭头,这对于 Git 来说是向后的:子项向后指向父项。)

\n\n

Git 需要一种方法来找到每个永久冻结的提交。方法是通过它们的哈希 ID:那些又大又丑的字母和数字字符串(实际上是用十六进制表示的 160 位值)。为了指向提交,something\xe2\x80\x94a 分支名称或另一个提交\xe2\x80\x94 只包含指向提交的原始哈希 ID。如果您有一个哈希 ID\xe2\x80\x94,或者如果 Git 有一个\xe2\x80\x94,您可以让 Git 从该哈希 ID 中找到底层对象。1

\n\n

Git 定义分支名称来包含最后一次提交的原始哈希 ID ,该提交被视为提交链的一部分。通过跟随每次提交中出现的向后箭头找到的先前提交位于该分支上或包含在该分支中。所以\xe2\x80\x94这里我将为每个提交切换到通常的大写字母表示法\xe2\x80\x94如果你有:

\n\n
A <-B <-C <-D   <-- master\n             \\\n              E <-F  <-- branch\n
Run Code Online (Sandbox Code Playgroud)\n\n

那么 commitF是的最后一次提交branch,但是E, D, 等等一直回到A包含在 branch. 提交D是上的最后一次提交master,但所有提交都A-B-C-D master

\n\n

请注意,当您第一次创建新分支名称时,它通常指向与某些现有分支名称相同的提交:

\n\n
A--B--C--D   <-- master\n          \\\n           E--F   <-- branch1, branch2\n
Run Code Online (Sandbox Code Playgroud)\n\n

您让 Git 将其附加HEAD到这些分支之一,并进行新的提交,这将获得新的哈希 ID。Git 将提交的哈希 ID 写入到该分支的名称中HEAD提交的哈希 ID 写入所附加的

\n\n
A--B--C--D   <-- master\n          \\\n           E--F   <-- branch1\n               \\\n                G   <-- branch2 (HEAD)\n
Run Code Online (Sandbox Code Playgroud)\n\n

并且所有不变量仍然成立:包含该分支上最后一次branch2提交的名称(哈希 ID) ,包含其最后一次提交的哈希 ID,包含其最后一次提交的名称,等等。没有提交更改(任何提交的任何部分都无法更改),但现在存在新的提交,并且当前分支仍然附加到它,但已被向前拖动。branch1masterHEAD

\n\n
\n\n

1 Git 中的提交是四种内部对象类型之一。另外三个是blobtreetag对象。通常,您每天交互的唯一 Git 哈希 ID\xe2\x80\x94(例如,通过剪切并粘贴到git loggit showgit cherry-pick或在git rebase -i说明表\xe2\x80\x94 中)是提交哈希 ID。提交有一个特殊的属性,即它们的内容始终是唯一的,因此它们的哈希 ID 也始终是唯一的。Git 通过为每次提交添加日期和时间戳来保证这一点。再加上每个提交都保存其父级的哈希 ID,足以产生必要的唯一性。

\n\n
\n\n

Rebase 是关于复制提交

\n\n

如上所述,任何提交的任何部分都不能更改。提交始终被冻结。最多,您可以简单地停止使用提交。Git通过从最后一个 \xe2\x80\x94 分支提示 \xe2\x80\x94 开始并向后工作来查找提交,如果您确实停止使用提交,并进行设置以使 Git 无法找到,Git最终会真正删除它。

\n\n

但是,您可以从任何提交(包括历史提交)\xe2\x80\x94 中取出提交并使用它,然后从中进行新的提交这里可能值得对“分离头”模式进行一点小小的评论。

\n\n

假设我们有这个 \xe2\x80\x94(与您绘制的图表相同),但使用我的单字母样式 \xe2\x80\x94 并具有相同的分支名称:

\n\n
A--B--C--D   <-- master\n          \\\n           E--F   <-- branch1\n               \\\n                G--H   <-- branch2 (HEAD)\n                    \\\n                     I--J   <-- branch3\n
Run Code Online (Sandbox Code Playgroud)\n\n

使用提交的正常方法是:

\n\n
    \n
  • 我们通过选择分支名称来选择一个。
  • \n
  • Git 将特殊名称附加HEAD到该分支名称。
  • \n
  • 分支名称现在是当前分支,该提交现在是当前提交
  • \n
  • Git 将该提交的冻结快照复制到 Git 的索引和工作树(我们不会在此处详细介绍)。
  • \n
\n\n

不过,我们可以G通过 Git 的名称来提取 commit :它的唯一哈希 ID。当我们这样做时,我们会得到一个分离的 HEADHEAD它本身直接指向提交:

\n\n
A--B--C--D   <-- master\n          \\\n           E--F   <-- branch1\n               \\\n                G   <-- HEAD\n                 \\\n                  H   <-- branch2\n                   \\\n                    I--J   <-- branch3\n
Run Code Online (Sandbox Code Playgroud)\n\n

如果我们要在这种状态下进行新的提交,我们实际上会得到一个。我会调用它,X而不是K因为我们会丢弃它并稍后忘记它,但让我们画出结果:

\n\n
A--B--C--D   <-- master\n          \\\n           E--F   <-- branch1\n               \\\n                G--X   <-- HEAD\n                 \\\n                  H   <-- branch2\n                   \\\n                    I--J   <-- branch3\n
Run Code Online (Sandbox Code Playgroud)\n\n

请注意,除了唯一找到它的名称是之外,X它在所有方面都很普通。如果我们给它一个分支名称,这将使提交更加持久:它将持续到我们删除其分支名称,或者以其他方式使提交无法找到。HEAD

\n\n

当然,这不完全是你正在做的事情。相反,您进行一个新的提交,我将以通常的 Attached-HEAD 方式调用K(您称之为c11) :branch1

\n\n
A--B--C--D   <-- master\n          \\\n           E--F--K   <-- branch1 (HEAD)\n               \\\n                G--H   <-- branch2\n                    \\\n                     I--J   <-- branch3\n
Run Code Online (Sandbox Code Playgroud)\n\n

此时,您希望将提交复制G-H-I-J到新的和改进的提交。该git rebase命令可以做到这一点,因为这是它的工作。但让我们看看它是如何工作的。

\n\n

变基如何工作

\n\n

由于 rebase 是关于复制(某些)提交的,因此它的工作分为三个阶段:

\n\n
    \n
  1. 第 1 阶段是决定复制哪些提交

    \n\n

    正如您所看到的,提交通常位于许多分支上。我们想要复制的是我们分支上的那些,但还没有在其他地方。例如,如果我们branch2现在打开并且说git rebase branch1,我们想要复制G-H但不复制E-F或任何早期的提交。

    \n\n

    主要论点git rebase是文档中所谓的upstream. 在这里,那就是branch1复制的提交是那些可以从我们当前分支\xe2\x80\x94fromHEADbranch2; 两者都选择同一组提交\xe2\x80\x94减去从 name 可达的提交branch1。因此,rebase 首先列出当前分支上的所有提交,然后从要复制的提交列表中删除所有位于 target/ 上的提交upstream。该列表最终保存原始提交的原始哈希 ID。

    \n\n

    git rebase文档将此列表描述为:

    \n\n
    \n

    当前分支中提交但不在其中的所有更改都<upstream>将保存到临时区域。git log <upstream>..HEAD这与;显示的同一组提交相同。或通过git log \'fork_point\'..HEAD,如果--fork-point处于活动状态(请参阅下面的说明--fork-point);或通过git log HEAD,如果--root指定了该选项。

    \n
    \n\n

    事实上,这并不是完整的图景,但这是一个好的开始。我们将在下一节中了解更完整的情况。

  2. \n
  3. 第 2 阶段是关于实际复制提交。Git 使用git cherry-pick2 或几乎等效的东西2来进行复制。我们将直接跳过cherry-pick 的工作原理,只是要提一下,正如您所看到的,它可能会导致合并冲突。

    \n\n

    这里我们要注意的是,复制是在分离的 HEAD模式下进行的。Git 首先对目标提交进行分离 HEAD 风格的签出。在这里,既然我们说了git rebase branch1,目标是 commit K,所以复制开始于:

    \n\n
    A--B--C--D   <-- master\n          \\\n           E--F--K   <-- branch1, HEAD\n               \\\n                G--H   <-- branch2\n                    \\\n                     I--J   <-- branch3\n
    Run Code Online (Sandbox Code Playgroud)\n\n

    Git 会记住名称 branch2(在文件中:如果您在部分变基期间在目录中查找.git,您将发现一个充满变基状态的目录)。

    \n\n

    此时要复制的提交列表是 commitsGH,按该顺序,并使用它们的真实哈希 ID,无论它们到底是什么。Git 一次一个地将这些提交复制到新提交,其快照和父项与原始提交略有不同。这给了我们这组新的提交,仍然处于分离头模式:

    \n\n
    A--B--C--D  ...    G\'-H\'  <-- HEAD\n          \\       /\n           E--F--K   <-- branch1\n               \\\n                G--H   <-- branch2\n                    \\\n                     I--J   <-- branch3\n
    Run Code Online (Sandbox Code Playgroud)
  4. \n
  5. 最后一个阶段git rebase是将分支名称拉过来。

    \n\n

    Git 找出保存的分支名称,强制它指向当前( )HEAD提交\xe2\x80\x94(本例为H\'\xe2\x80\x94)并重新附加HEAD。所以现在你有:

    \n\n
    A--B--C--D  ...    G\'-H\'  <-- branch2 (HEAD)\n          \\       /\n           E--F--K   <-- branch1\n               \\\n                G--H\n                    \\\n                     I--J   <-- branch3\n
    Run Code Online (Sandbox Code Playgroud)
  6. \n
\n\n

请注意,此时不再有名称选择提交H3 我们可以理顺图表中的扭结,但为了对称,我将其保留下来,还有另一个原因,我们将在后面的部分中看到。

\n\n
\n\n

2 Rebase 可以使用几个“后端”之一。默认的非交互式后端一直持续git-rebase--am到 Git 2.26.0,但现在已经不再是了。后端am使用git format-patchgit am,因此得名。它会错过某些文件重命名情况,并且无法复制空差异提交,但在一些相对罕见的变基情况下它可能会快得多。

\n\n

3实际上,至少有一个引用日志条目,至少在默认设置中是这样。我们稍后会讨论这个问题。

\n\n
\n\n

更好地了解 rebase 复制的内容

\n\n

我上面提到,在第 1 阶段,当 rebase 列出要复制的提交时,它并没有真正使用该<upstream>..HEAD方法。该文档甚至在这里有警告(关于fork-point模式),但它没有足够的警告。

\n\n

每当你有 Git 复制提交\xe2\x80\x94,无论是通过自己运行git cherry-pick,还是任何其他方法(包括变基\xe2\x80\x94),你最终都会得到可能彼此“做同样的事情”的提交。也就是说,给定提交HH\',我们可以运行:

\n\n
git show <hash-of-H>\n
Run Code Online (Sandbox Code Playgroud)\n\n

查看 commitG和 commit之间的差异H,看看有什么H作用。我们可以运行:

\n\n
git show <hash-of-H\'>\n
Run Code Online (Sandbox Code Playgroud)\n\n

查看 commitG\'和 commit之间的差异H\',看看有什么H\'作用。

\n\n

如果我们删除此差异列表中的行号,我们将得到相同的更改3 Git 包含一个命令 ,git patch-id它读取差异列表,去掉行号\xe2\x80\x94 和一些空格,这样,例如,尾随空格不会影响事物\xe2\x80\ x94并对结果进行哈希处理。这会产生 Git 所说的补丁 ID

\n\n

与提交的哈希ID 不同,它保证对于特定的提交\xe2\x80\x94 是唯一的,因此我们精心挑选的副本是不同的提交\xe2\x80\x94,如果满足以下条件,则补丁 ID 故意相同:提交“做同样的事情”。所以:

\n\n
git show <hash-of-either-H-or-H\'> | git patch-id\n
Run Code Online (Sandbox Code Playgroud)\n\n

从某种意义上说,将表明 和H是“相同的”提交。H\'

\n\n

当你运行时git rebase,Git 实际上会计算一堆提交的哈希 ID。对于那些“相同的提交”,Git 会将这些提交从要复制的提交列表中剔除。

\n\n

(默认情况下,rebase 还会将所有合并提交从列表中剔除。在这些示例中,您没有任何合并提交,因此我们不必在这里担心这些。)

\n\n

因此,如果我们现在运行:

\n\n
git checkout branch3; git rebase branch2\n
Run Code Online (Sandbox Code Playgroud)\n\n

Git 将获取此图:

\n\n
A--B--C--D  ...    G\'-H\'  <-- branch2\n          \\       /\n           E--F--K   <-- branch1\n               \\\n                G--H--I--J   <-- branch3 (HEAD)\n
Run Code Online (Sandbox Code Playgroud)\n\n

并将提交列出A-B-C-D-E-F-G-H-I-Jbranch3列表,但随后将其删除,A-B-C-D-E-F-K-G\'-H\'因为这就是列表branch2。这就是进行补丁 ID 部分之前的G-H-I-J起点。换句话说:

\n\n
branch2..HEAD\n
Run Code Online (Sandbox Code Playgroud)\n\n

G-H-I-J

\n\n

G但现在,Git 计算、HI和的补丁 ID J。然后,它还计算KG\'和的补丁 ID H\'4 变基代码发现上游G已经有一个 patch-ID 等效的提交。G\'所以G\'被从名单中剔除。然后它发现也HH\'上游,因此H被从列表中剔除。

\n\n

此时要复制的最终提交列表是I-J:正是您想要的。Git 现在可以HEAD在提交H\'和复制时分离I-J,然后重新附加HEAD到结果:

\n\n
                        I\'-J\'  <-- branch3 (HEAD)\n                       /\nA--B--C--D  ...    G\'-H\'  <-- branch2\n          \\       /\n           E--F--K   <-- branch1\n               \\\n                G--H--I--J   [abandoned]\n
Run Code Online (Sandbox Code Playgroud)\n\n
\n\n

3更准确地说,我们通常相同的更改。如果我们在挑选过程中发生合并冲突,有时我们不会得到相同的更改。

\n\n

4这个特定列表的原因是这些是由git rev-list branch2...HEAD. 注意这里的三个点:这是 Git 对称差分集操作的语法。这种对称差异包括可从HEAD但不可到达的提交,以及可从但不可branch2到达的提交。一组成为“左侧”提交,一组成为“右侧”提交。复制的提交位于左侧,并且所有内容都经过补丁 ID 编辑;上游中获得补丁 ID 的提交是右侧列表。branch2HEADG-H-I-J

\n\n
\n\n

哪里出了问题

\n\n

脚注 3(上)是问题出在哪里的线索。如果在冲突解决过程中,您最终以某种实质性方式更改了某些提交,则补丁 ID 计算将不再能够删除某些提交。

\n\n

当你去 rebase 时branch3,这一次,Git 选择再次复制G到和/或再次复制到。每个副本几乎肯定会与新替换提交的正在进行的构建中已经存在的副本发生冲突(如合并冲突)。G\'HH\'

\n\n

正确的做法是在复制过程中省略 G和。HRebase 本来可以使用 patch-ID 技巧为您完成此操作,但 patch-ID 技巧失败了。

\n\n

使用--onto

\n\n

在您的情况下,您希望 rebase 复制一些提交,但不是范围内的所有提交<upstream>..HEAD,同时将副本放在正确的位置。你有:

\n\n
A--B--C--D  ...    G\'-H\'  <-- branch2\n          \\       /\n           E--F--K   <-- branch1\n               \\\n                G--H--I--J   <-- branch3 (HEAD)\n
Run Code Online (Sandbox Code Playgroud)\n\n

你想告诉 rebase: Copy Iand Jbut not Hand so not G。将副本放在H\'的顶端之后branch2

\n\n

一个论点无法解决问题,但两个论点却可以。假设你可以说:

\n\n
git rebase --dont <hash-of-H> --onto branch2    # not the actual syntax\n
Run Code Online (Sandbox Code Playgroud)\n\n

例如?幸运的是,git rebase它是内置的。实际语法是:

\n\n
git rebase --onto branch2 <hash-of-H>\n
Run Code Online (Sandbox Code Playgroud)\n\n

--onto参数允许您指定副本的目标upstream,从而释放参数来表示不复制的内容

\n\n

Rebase 仍然会执行所有相同的 patch-ID 工作,但是通过使用 list 启动它G-H,它就没有机会出错。最终的结果就是你想要的。

\n\n

使用引用日志或其他技巧来查找H

\n\n

这里烦人的部分是查找H\ 的哈希 ID。有了这些图表,我可以轻松地说<hash-of-H>,但在真正的 rebase 中,有真实的图表和数十个看起来都很相似的提交,查找哈希 ID 是一件痛苦的事情。如果有一种简单的方法可以解决这个问题就好了。

\n\n

事实证明,确实有。

\n\n

每当 Git移动一个分支名称时,例如,它会留下先前git rebase值的痕迹。这条线索进入了 Git 的reflogs。每个分支名称都有一个 reflog,另外还有一个. 这个非常活跃,但在这里没那么有用,因为它活跃了,但是 的那个是完美的。HEADHEADbranch2

\n\n

还记得我们是怎么画的吗:

\n\n
A--B--C--D  ...    G\'-H\'  <-- branch2 (HEAD)\n          \\       /\n           E--F--K   <-- branch1\n               \\\n                G--H\n                    \\\n                     I--J   <-- branch3\n
Run Code Online (Sandbox Code Playgroud)\n\n

起初。我说过我留下它是为了对称和另一个原因,现在是时候讲这个原因了。我们可以使用该名称branch2@{1}来引用“where was one step / -change ago”的引用日志条目。只要“一步前”是在变基之前,就意味着“提交”。所以:branch2branch2H

\n\n
git checkout branch3\ngit rebase --onto branch2 branch2@{1}\n
Run Code Online (Sandbox Code Playgroud)\n\n

就可以了。

\n\n

如果您branch2自 rebase\xe2\x80\x94 以来已经完成了一些操作,例如,如果您构建、测试并提交了\xe2\x80\x94,则可能需要比 更高的数字@{1}。用于git reflog branch2打印出实际的reflog内容,以进行检查。

\n\n

另一种选择是在变基H 之前删除指向提交的分支或标记名称branch2。例如,如果您起了一个新名字branch2-oldbranch2.0其他什么,您仍然会拥有:

\n\n
A--B--C--D  ...    G\'-H\'  <-- branch2\n          \\       /\n           E--F--K   <-- branch1\n               \\\n                G--H   <-- branch2-old\n                    \\\n                     I--J   <-- branch3\n
Run Code Online (Sandbox Code Playgroud)\n\n

(无论HEAD现在在哪里)。您也可以将提交标记Jbranch3-old开始变之前。

\n\n

(引用日志很方便并且通常工作正常。不过,分支名称很便宜。)

\n\n

还可以考虑一举重设基准

\n\n

假设你有这个图:

\n\n
A--B--C--D   <-- master\n          \\\n           E--F--U   <-- branch1\n               \\\n                G--H   <-- branch2\n                    \\\n                    ...\n                      \\\n                       T   <-- branch9\n
Run Code Online (Sandbox Code Playgroud)\n\n

U您希望在所有祖先中拥有的新提交在哪里branchN。如果你运行:

\n\n
git checkout branch9; git rebase branch1\n
Run Code Online (Sandbox Code Playgroud)\n\n

您将G-H-...--T在一次操作中获得提交的副本。现在,您可以将branch2, branch3, ..., 向上遍历branch8,然后将每一个移动到指向相应的复制提交。将原始提交与其副本相匹配是工具的工作,但不幸的是,该工具不存在。所以如果你按照这种方式走,那就是一种手册。

\n\n

另外,请注意,这在某些情况下不起作用

\n\n
A--B--C--D   <-- master\n          \\\n           E--F--K   <-- branch1\n               \\\n                G--H--L   <-- branch2\n                    \\\n                     I--J   <-- branch3\n
Run Code Online (Sandbox Code Playgroud)\n\n

仅重新branch3建立副本,而不是。所以你可能仍然偶尔需要。(一个合适的工具可以完成这一切。)branch1G-H-I-JLgit rebase --onto

\n

  • 祖先分支现在可以使用“git rebase --update-refs”进行堆栈。 (2认同)