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

4

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

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 //branch1
                           \
                            c7 - c8 // branch2
                                  \
                                   c9 - c10 // branch3

假设我决定更新branch1分支。

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 - c11//branch1
                           \
                            c7 - c8 // branch2
                                  \
                                   c9 - c10 // branch3

然后,要进行更新,我会将branch2基于branch1进行变基,将branch3基于branch2进行变基,以实现以下理想状态:

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 - c11//branch1
                                \
                                 c7 - c8 // branch2
                                        \
                                         c9 - c10 // branch3

我遇到的问题是,当分支1和分支2之间存在合并冲突时,我解决了这些冲突,但当我将分支3合并到分支2时,同样的合并冲突又出现了。实际上,由于某种原因,分支3似乎包含了分支2的提交记录,当我进行变基时,事情就会变得混乱,因为我正在将分支2的后期提交合并到分支3的早期提交中。情况看起来像这样:

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 - c11//branch1
                                \
                                 c7 - c8  // branch2
                                         \
                                c7  - c8 - c9 - c10 // branch3

然后rebase变成了这样:

c1 -> c2 -> c3 -> c4 //master
                   \
                    c5 - c6 - c11//branch1
                                \
                                 c7 - c8  // branch2
                                         \
                                         c7'  - c8' - c9 - c10 // branch3

我在这里做错了什么?有没有不同的重置基础分支的方法?为什么branch3包含了branch2的提交记录?

2个回答

6
没有一个好的通用工具可以做到你想要的。有一些特定的技巧,可能适用于你。特别地,有时你会需要使用 git rebase --onto 命令,并且必须小心使用。
背景
问题在于,Git 分支不能嵌套或堆叠,或者您想在这里使用的任何其他单词。
更准确地说,分支名称(例如 masterbranch1branch3)只是像指针或标签一样的东西。每个名称都指向(或粘贴到)一个特定的提交。它们与彼此之间没有任何固有关系:您可以随时添加、删除或移动任何标签。每个标签的唯一约束条件是必须指向恰好一个提交。
提交并不是如同某一个分支上的提交,而是包含在某些分支集合中的。例如,在您的图示中,提交 c1 是提交 c2 的父提交。Git 实际上是通过让提交指向其他提交来实现这一点,类似于分支名称指向提交的方式。然而,存在一些区别:任何一个提交的内容(包括其指针)都被冻结了。这意味着子提交指向父提交。当您创建子提交时,父提交已经存在,但反之则不成立。因此,子提交可以指向父提交,但父提交不能指向子提交。
(实际上,Git 是向后工作的。您画的箭头是从前往后,对于 Git 来说是相反的:子提交向后指向父提交。)
Git 需要一种方法来查找每个永久冻结的提交。这种方法是通过它们的哈希 ID 实现的:那些又长又丑的字母和数字字符串(实际上是用十六进制表示的 160 位值)。为了指向一个提交,某个东西(例如分支名称或另一个提交)只需包含所指向提交的原始哈希 ID。如果您有哈希 ID,或者 Git 有,您可以让 Git 从该哈希 ID 找到底层对象。1 Git 定义分支名称包含最后一个要考虑为提交链的一部分的提交的原始哈希 ID。通过跟随每个提交中指向后面的箭头找到的先前的提交都在该分支上。因此,我将使用大写字母表示每个提交,如果您有以下内容:
A <-B <-C <-D   <-- master
             \
              E <-F  <-- branch

Fbranch最后一个提交,但是ED等一直回溯到A的所有提交都包含在branch中。D提交是master最后一个提交,但A-B-C-D的所有提交都包含在master中。

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

A--B--C--D   <-- master
          \
           E--F   <-- branch1, branch2

你需要将Git的HEAD附加到其中一个分支上,并创建一个新的提交,此提交将获得一个新的哈希ID。 Git将提交的哈希ID写入与HEAD绑定的分支名称中:
A--B--C--D   <-- master
          \
           E--F   <-- branch1
               \
                G   <-- branch2 (HEAD)

所有不变量仍然保持不变:branch2包含该分支上最后一个提交的名称(哈希ID),branch1包含其最后一个提交的哈希ID,master包含其最后一个提交的名称等等。没有提交已经被更改(任何提交的任何部分无法更改),但是现在存在新的提交,当前分支仍然附加有HEAD,但是已经向前移动。


1在Git中,提交是四种内部对象类型之一。另外三种是blobtreetag对象。通常情况下,您每天与之交互的Git哈希ID只有提交哈希ID——例如,使用剪切和粘贴到git loggit showgit cherry-pick,或在git rebase -i 指令表中——提交具有特殊属性,即其内容始终是唯一的,因此它们的哈希ID也始终是唯一的。 Git通过为每个提交添加日期和时间戳来保证这一点。再加上每个提交都持有其父提交的哈希ID,这就足以产生必要的唯一性。


Rebase是关于 复制 提交的

正如上面所述,任何提交的任何部分都无法更改。提交被永久地冻结。最多,您只能停止使用提交。Git通过从最后一个提交——分支末端——开始向后工作来查找提交,如果您停止使用提交,并设置Git无法查找它,则Git将最终真正删除它。

但是,您可以取出提交——包括历史提交——并对其进行操作,然后从其中创建新的提交。这里可能值得简要提一下“分离的HEAD”模式。

假设我们拥有这个——您绘制的相同的图形,但是使用我的单字母样式——并具有相同的分支名称:

A--B--C--D   <-- master
          \
           E--F   <-- branch1
               \
                G--H   <-- branch2 (HEAD)
                    \
                     I--J   <-- branch3

使用提交(commit)的正常方式是:

  • 我们选择一个分支名称来进行提交。
  • Git将特殊名称HEAD与该分支名称相关联。
  • 该分支名称现在是当前分支(current branch),而该提交(commit)现在是当前提交(current commit)。
  • Git将该提交的冻结快照复制到Git的索引(index)和工作树(work-tree)中(我们不会在这里详细介绍)。

我们可以通过选择其名称(即其唯一的哈希值)来提取提交G。 这时我们会得到一个分离的 HEAD(detached HEAD),其中HEAD指向该提交本身:

A--B--C--D   <-- master
          \
           E--F   <-- branch1
               \
                G   <-- HEAD
                 \
                  H   <-- branch2
                   \
                    I--J   <-- branch3

如果我们现在对代码进行新的提交,实际上会得到一个提交记录。为了方便,我将它称为X而不是K,因为我们马上就会放弃它并忘掉它,但让我们把这个结果画出来:
A--B--C--D   <-- master
          \
           E--F   <-- branch1
               \
                G--X   <-- HEAD
                 \
                  H   <-- branch2
                   \
                    I--J   <-- branch3

请注意X在所有方面都是普通的,唯一不同之处在于只有 HEAD 才能找到它。如果给它一个分支名称,那么该提交将变得更加固定:除非我们删除其分支名称,否则它会一直存在,或者以其他方式使提交无法找到。
当然,这并不完全是你所做的。相反,你会以通常的附加 HEAD 的方式,在 branch1 上创建一个新的提交,我将其称为K(你称其为 c11)。
A--B--C--D   <-- master
          \
           E--F--K   <-- branch1 (HEAD)
               \
                G--H   <-- branch2
                    \
                     I--J   <-- branch3

在这一点上,你想要将提交记录 G-H-I-J 复制到新的、改进的提交记录中。命令git rebase可以做到这一点,因为这是它的工作。但让我们看看它是如何完成任务的。

rebase的工作原理

由于rebase涉及复制(某些)提交记录,它的工作被分成三个阶段:

  1. Phase 1 is to decide which commits to copy.

    As you've seen, commits are often on many branches. The ones we want to copy are those that are on our branch, but aren't also already somewhere else. For instance, if we are on branch2 now and we say git rebase branch1, we want to copy G-H but not E-F or any of the earlier commits.

    The main argument to git rebase is what the documentation calls the upstream. Here, that's branch1. The commits to copy are those reachable from our current branch—from HEAD or branch2; both select the same set of commits—minus those reachable from the name branch1. So rebase first lists all the commits on our current branch, but then knocks out of the list of commits to copy, all those that are on the target/upstream. This list ends up holding the raw hash IDs of the original commits.

    The git rebase documentation describes this listing as:

    All changes made by commits in the current branch but that are not in <upstream> are saved to a temporary area. This is the same set of commits that would be shown by git log <upstream>..HEAD; or by git log 'fork_point'..HEAD, if --fork-point is active (see the description on --fork-point below); or by git log HEAD, if the --root option is specified.

    This is, in fact, not the complete picture, but it's a good start. We'll get to the more complete picture in the next section.

  2. Phase 2 is about actually copying the commits. Git uses git cherry-pick, or something mostly equivalent,2 to do the copying. We'll skip right over how cherry-pick works, except to mention that, as you have seen, it can get merge conflicts.

    What we will note here is that the copying takes place in detached HEAD mode. Git first does a detached-HEAD style checkout of the target commit. Here, since we said git rebase branch1, the target is commit K, so the copying starts with:

    A--B--C--D   <-- master
              \
               E--F--K   <-- branch1, HEAD
                   \
                    G--H   <-- branch2
                        \
                         I--J   <-- branch3
    

    with Git remembering the name branch2 (in a file: if you poke around inside the .git directory during a partial rebase, you'll find a directory full of rebase state).

    The list of commits to copy at this point is commits G and H, in that order, and using their real hash IDs, whatever those really are. Git copies these commits, one at a time, to new commits whose snapshots and parents are slightly different from the originals. That gives us this new set of commits, still in detached-HEAD mode:

    A--B--C--D  ...    G'-H'  <-- HEAD
              \       /
               E--F--K   <-- branch1
                   \
                    G--H   <-- branch2
                        \
                         I--J   <-- branch3
    
  3. The last phase of git rebase is to yank the branch name over.

    Git fishes out the saved branch name, forces it to point to the current (HEAD) commit—in this case H'—and re-attaches HEAD. So now you have:

    A--B--C--D  ...    G'-H'  <-- branch2 (HEAD)
              \       /
               E--F--K   <-- branch1
                   \
                    G--H
                        \
                         I--J   <-- branch3
    
请注意,此时已经没有选择提交H名称。我们可以将图形中的折线拉直,但为了对称性和稍后部分将要看到的另一个原因,我将其保留在其中。
2Rebase 可以使用多个“后端”之一。默认的非交互式后端一直是git-rebase--am,直到Git 2.26.0,但现在不再是了。 am后端使用git format-patchgit am,因此得名。它会错过某些文件重命名情况,并且无法复制空差异提交,但在某些相对较少的rebase情况下可能会更快。

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


更好地理解 rebase 复制的内容

如前所述,在第1阶段中,当rebase列出要复制的提交时,它并没有真正使用<upstream>..HEAD方法。文档甚至在这里有详细说明(关于fork-point模式),但它并没有足够的警告。

每当您让Git复制提交——无论是通过自己运行git cherry-pick还是通过任何其他方法,包括rebase——您最终都会获得可能“执行相同操作”的提交。也就是说,给定提交HH',我们可以运行:

git show <hash-of-H>

要查看提交 G 和提交 H 之间的差异,以了解 H 的变更。我们可以运行:

git show <hash-of-H'>

为了查看提交G'和提交H'之间的差异,以查看H'的作用。
如果从此差异清单中删除行号,我们将得到相同的更改3。Git包含一个名为git patch-id的命令,它读取差异列表,去掉行号和一些空格——例如,结尾的空格不会影响结果——然后对其进行哈希处理。这就产生了Git所谓的补丁ID
与提交的哈希 ID不同,后者保证在该特定提交中是唯一的,因此我们复制的cherry-pick提交是一个不同的提交。如果提交“做相同的事情”,则补丁ID有意地相同
git show <hash-of-either-H-or-H'> | git patch-id

执行git rebase命令时,Git会计算一系列提交的哈希值。对于那些“相同的提交”,Git会从将要复制的提交列表中删除它们。

(默认情况下,rebase还会将所有的合并提交从列表中删除。在这些示例中,我们没有需要担心的合并提交。)

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

将显示HH'是“相同的”提交,从某种意义上讲。

git checkout branch3; git rebase branch2

Git会生成以下这张图表:

A--B--C--D  ...    G'-H'  <-- branch2
          \       /
           E--F--K   <-- branch1
               \
                G--H--I--J   <-- branch3 (HEAD)

列出提交记录A-B-C-D-E-F-G-H-I-J作为branch3,但因为那是branch2列表,所以去掉了A-B-C-D-E-F-K-G'-H'。这使得在执行补丁ID部分之前,G-H-I-J成为起点。换句话说:

branch2..HEAD

G-H-I-J

现在,Git为GHIJ计算补丁ID。然后它还为KG'H'计算补丁ID。4 接下来的变基代码发现G已经有一个等价于补丁ID的提交G'在上游了。所以G'被从列表中剔除了。然后它发现H也有H'在上游,所以H也被从列表中剔除了。

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

                        I'-J'  <-- branch3 (HEAD)
                       /
A--B--C--D  ...    G'-H'  <-- branch2
          \       /
           E--F--K   <-- branch1
               \
                G--H--I--J   [abandoned]

3 更确切地说,“通常”会得到相同的更改。如果在 cherrypick 过程中出现了合并冲突,我们有时候将不会得到相同的更改。

4 这个特定的列表是由 git rev-list branch2...HEAD 命令生成的。注意这里有三个点:这是 Git 的对称差异(symmetric difference)集合操作语法。该对称差异包括来自 HEAD 但不是来自 branch2 的提交,以及来自于 branch2 但不是来自于 HEAD 的提交。一组成为“左侧”提交,另一组成为“右侧”提交。要复制的提交是左侧的 G-H-I-J,所有被打入 patch-ID 的上游提交都是右侧的列表。


何处容易出错

脚注 3(上面的)指出出错的原因。如果在合并冲突解决过程中,您以某些实质性的方式修改了某个提交,那么 Patch ID 计算将无法正常工作,从而无法摒除某些提交。

当您重新对 branch3 进行变基时,Git 会选择再次将 G 复制到 G' 和/或将 H 复制到 H'。每个复制都几乎肯定与已经存在于新替换提交的正在进行中的构建上的复制发生冲突(如合并冲突)。

正确的操作是在复制过程中“省略” GH。而 rebase 将使用 Patch ID 技巧为您完成此操作,但 Patch ID 技巧失败了。

使用 --onto

对于您的情况,您想要 rebase 复制 <upstream>..HEAD 范围内的一些提交但不是全部提交,同时将这些提交放置在正确的位置。


A--B--C--D  ...    G'-H'  <-- branch2
          \       /
           E--F--K   <-- branch1
               \
                G--H--I--J   <-- branch3 (HEAD)

如果你想告诉rebase: 复制IJ但不包括H,因此也不包括G。将这些副本放在branch2H'之后。

一个参数无法完成这项工作,但是两个可以。假设你可以这样说:

git rebase --dont <hash-of-H> --onto branch2    # not the actual syntax

比如说?幸运的是,git rebase 内置了此功能。其实际语法为:

git rebase --onto branch2 <hash-of-H>
--onto 参数允许您指定复制的目标,从而使 upstream 参数可以表示不要复制的内容
重定位仍将执行相同的补丁 ID 工作,但通过使用列表 G-H 启动它,它就没有机会出现错误了。最终结果正是您想要的。
使用 reflog 或其他技巧查找 H 烦人的部分在于找到 H 的散列 ID。虽然在这些图表中,我可以轻松地说 <hash-of-H>,但在实际的重定位中,由于具有真正的图形和数十个看起来都很相似的提交,查找散列 ID 是一件麻烦的事情。如果有一种简单的方法可以保证正确性就好了。事实证明确实如此。
每当 Git移动分支名称时,例如 git rebase 命令时,它都会留下一系列先前值的痕迹。这条痕迹进入了 Git 的reflog。每个分支名称都有一个 reflog,另外还有一个用于 HEAD。其中 HEAD 的 reflog 是非常活跃但在这里不是很有用,但 branch2 的 reflog 则非常完美。
还记得我们如何绘制:
A--B--C--D  ...    G'-H'  <-- branch2 (HEAD)
          \       /
           E--F--K   <-- branch1
               \
                G--H
                    \
                     I--J   <-- branch3

起初我说我为了对称性而将其保留和另一个原因,现在是时候说明这个原因了。我们可以使用名称branch2 @ {1}来引用reflog条目,从而确定" branch2向前移动一步之前的位置"。只要“向前一步”是在变基之前,那就意味着“提交H”。因此:

git checkout branch3
git rebase --onto branch2 branch2@{1}

这样做能解决问题。

如果您在 rebase 之后在 branch2 上进行了其他操作,例如构建、测试和提交,您可能需要比 @{1} 更高的数字。使用 git reflog branch2 打印实际的 reflog 内容以进行检查。

另一种选择是在完全进行 rebase branch2 之前删除指向提交 H 的分支或标签名称。例如,如果您创建一个新名称为 branch2-oldbranch2.0 等,您仍将拥有:

A--B--C--D  ...    G'-H'  <-- branch2
          \       /
           E--F--K   <-- branch1
               \
                G--H   <-- branch2-old
                    \
                     I--J   <-- branch3

(无论现在 HEAD 在哪里)。在开始 变基之前,您可以将提交记录标记为 branch3-old

(引用日志很方便,通常也能正常工作。分支名称很便宜。)

考虑一次性完成变基

假设您有以下图形:

A--B--C--D   <-- master
          \
           E--F--U   <-- branch1
               \
                G--H   <-- branch2
                    \
                    ...
                      \
                       T   <-- branch9

U 是你想要在所有 branchN 祖先中拥有的新提交。如果你运行:

git checkout branch9; git rebase branch1

您将在一次操作中获得提交的副本G-H-...--T。您现在可以将branch2branch3,...,branch8移动到相应的复制提交处。将原始提交与其副本配对是工具的工作,但不幸的是,该工具不存在。因此,如果您选择这种方法,则需要进行一些手动操作。
此外,请注意,这种方法不适用于某些情况:
A--B--C--D   <-- master
          \
           E--F--K   <-- branch1
               \
                G--H--L   <-- branch2
                    \
                     I--J   <-- branch3

branch3重新定位到branch1,只会复制G-H-I-J而不是L,因此您仍然可能需要偶尔使用git rebase --onto。(一个合适的工具应该能够完成所有这些步骤。)

1
现在有一个开源的CLI工具,用于执行分支的递归变基:https://github.com/screenplaydev/graphite-cli - Greg
1
祖先分支现在可以使用 git rebase --update-refs 堆栈化了。 - Guildenstern

3

简要概述:使用/复制Graphite CLI的实现

之前的回答已经过时。

"没有好的通用工具可以做到你想要的。"

这个开源CLI将执行递归分支变基(披露一下,我是贡献者): https://github.com/screenplaydev/graphite-cli

主要的变基递归可以在这里看到:https://github.com/screenplaydev/graphite-cli/blob/dfe4390baf9ce6aeedad0631e41c9f1bdc57ef9a/src/actions/fix.ts#L60

git rebase --onto ${parentBranch.name} ${mergeBase} ${currentBranch.name}

关键洞察是在git refs中存储分支父级,以便在操作期间递归DAG。如果没有父级元数据,则无法始终确定连续子分支的合并基础。
const metaSha = execSync(`git hash-object -w --stdin`, {input: JSON.stringify(desc)}).toString();

execSync(`git update-ref refs/branch-metadata/${this.name} ${metaSha}`);

https://github.com/screenplaydev/graphite-cli/blob/dfe4390baf9ce6aeedad0631e41c9f1bdc57ef9a/src/wrapper-classes/branch.ts#L102-L109


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接