操纵提交历史,同时避免分支之间的合并冲突

manipulating commit history while avoiding merge conflicts between branches

我们的团队第一次开始使用 git。我们有 3 个分支:master、a 和 b。 Master 有五个提交(commit1 到 commit5),而分支 a 和 b 也包含所有五个提交加上它们自己的一些新提交。 a和b以后会合并为master

最近,我发现提交 2 和 3 不属于 master。它应该属于一个。 Master 应该只有提交 1、4 和 5。提交 2 和 3 稍后将合并。这是我们还没有创建单独的分支的时候。这是一个问题,因为 a 和 b 是从 master 分支出来的,因此前 5 次提交是相同的。

有没有办法从 master 和 b 中删除提交 2 和 3,同时避免稍后当我们将 a 合并到 master 时发生合并冲突?

简答一般是"no"。然而,由于提交相对较少,而且您的团队可能足够小,所以有一种方法可以公平、干净地处理这个问题,还有几种方法有点混乱但可能就足够了。 (另外,一个简短的旁注:有更快的方法来完成我在下面描述的几乎所有事情,使用个人的强大工具版本,one-commit-at-a-time 方法。我将把它们留给 space原因。)

TL;DR 摘要

我可能会重写历史记录,因为存储库很小而且您的团队可能有能力处理它。 (如果每个人都可以一起工作几个小时左右,则尤其如此。)在一个更大的项目中,我可能会选择我在下面展示的最后一个(混合)替代方案——或者,在许多情况下,忽略这个问题,因为我标记为 BC 的提交可能不会造成太多困扰。但所有这些都是价值 judgments,不一定有 "correct" 答案。

长的部分

主要技巧是从绘制提交开始。请记住,在 Git 中,提交由其丑陋的大哈希 ID 标识,并且每个提交记录其 parent 提交的哈希 ID。这意味着总是 "point backwards" 提交,实际上 Git 总是向后工作。每个提交还包含整个源的完整快照:每当您将提交视为 更改 时,您就是通过 Git 从该提交向后查看它的parent,然后比较两个快照。

每个提交的哈希 ID 非常依赖于(通过加密哈希)关于提交的一切:作者和提交者姓名和电子邮件地址和时间戳、源快照、提交消息和 parent 也提交哈希 ID。因此,如果您尝试更改 关于提交的任何内容,您将获得一个新的、不同的哈希 ID。

不过,我不会写出丑陋的大哈希 ID,而是每次提交都使用一个大写字母(并且 运行 在 26 次提交后输出...)。你或多或少有这个(我只是猜测两个分支中的每一个都有两个提交):

              H--I   <-- branch-b
             /
A--B--C--D--E   <-- master
             \
              F--G   <-- branch-a

(请注意,像 master 这样的分支名称充当可移动指针,指向分支上的 last 提交。通过使新提交点回到当前提示,然后移动分支名称以指向新提交。Git 现在找到所有曾经在分支上的提交,因为它从新提交开始并向后工作,并且后退的第一步将它带到旧提示。)

方法一:大量历史重写(不是那么大量)

喜欢拥有但不能完全拥有的是:

        H--I   <-- branch-b
       /
A--D--E   <-- master
       \
        B--C--F--G   <-- branch-a

无法实现此图,因为 D 的哈希 ID 表示 parent 是 A 的提交,其源快照是 [=38= 中的快照] ] 现在,类似于 C.

中的快照

我们可以做的是提交,我们称它为D'A作为它的parent,并且采用 change-from-C-to-D 并将更改应用于 A 中的快照。我们通过使用 git checkout 直接检出提交 A(作为 "detached HEAD"),然后使分支名称 new-master 指向它,使用:

$ git checkout <hash of A>
$ git checkout -b new-master

或一步使用:

$ git checkout -b new-master <hash of A>

给予:

A   <-- new-master (HEAD)
 \
  B         H--I   <-- branch-b
   \       /
    C--D--E   <-- master
           \
            F--G   <-- branch-a

现在我们有了这个新命名的分支,我们可以使用 git cherry-pick 复制 提交 D:

$ git cherry-pick <hash of D>

Cherry-pick 将比较 D 与其 parent C,并使用结果更改将更改应用到我们现在的位置(HEAD = new-master ) 并进行新的提交 D':

A--D'   <-- new-master (HEAD)
 \
  B         H--I   <-- branch-b
   \       /
    C--D--E   <-- master
           \
            F--G   <-- branch-a

现在我们有 D' 我们也可以 cherry-pick E:

A--D'-E'   <-- new-master (HEAD)
 \
  B         H--I   <-- branch-b
   \       /
    C--D--E   <-- master
           \
            F--G   <-- branch-a

请注意这与您希望拥有的东西有多少相似之处。图的下半部分我们先停止绘制,但记住它还在,现在创建new-branch-a并上去:

$ git checkout -b new-branch-a

A--D'-E'   <-- new-master, new-branch-a (HEAD)

然后是 cherry-pick BCFG

$ git cherry-pick <hash of B>

A--D'-E'   <-- new-master
       \
        B'   <-- new-branch-a (HEAD)

(对剩余的三个提交重复):

A--D'-E'   <-- new-master
       \
        B'-C'-F'-G'   <-- new-branch-a (HEAD)

现在我们回到 new-master 并创建 new-branch-b 和 cherry-pick 所有旧 branch-b 的提交:

        H'-I'   <-- new-branch-b (HEAD)
       /
A--D'-E'   <-- new-master
       \
        B'-C'-F'-G'   <-- new-branch-a

我们现在要做的就是删除原来的三个分支名称,这三个分支重命名为masterbranch-a, 和 branch-b, 任何不记得原始 哈希 ID 的人都会认为历史已经以某种方式神奇地被改写了。 历史真的没有' 实际上根本没有改变,每个拥有原始提交和原始名称的人仍然拥有原始历史——这就是它变得有点毛茸茸的地方,因为 Git 是分布式的。

每个拥有存储库克隆的人都拥有所有原始提交及其原始哈希 ID。 这意味着您团队的每个成员在他们自己的 Git 存储库中都有 origin/masterorigin/branch-aorigin/branch-b 形式的名称,这些名称会记住提交的哈希 ID EGI,它们具有您 想要的形式的所有原始提交——原始历史,而不是重写的历史。

他们必须谨慎对待 他们 的任何工作,这些工作建立在 EGI 和 [=318= 的基础上]transplant 有效(使用 git cherry-pick 或等价物),因此它现在从 E'G'I' 增长。如果你的团队足够小,并且每个人都通过会议讨论如何处理这个历史重写,那么这对他们来说可能就像重写对你一样容易。

棘手的部分是,如果他们 小心,他们可以 re-introduce 所有原始提交回到他们自己的历史记录中(通过让他们的分支名称指向到原件,或提交 parent 指向原件)。然后,当他们将他们的历史与您重写的历史合并时,他们 re-introduce 所有原始历史。这种事情就是为什么 "history rewriting" 通常是一个坏主意:只需要一个人就可以把它弄错 re-introduce "wrong" 历史。

(历史重写的另一个问题是合并提交。你不能 cherry-pick 合并提交来创建新的合并。你可以做一些事情,但所有的选择都有问题。在你的不过,在特殊情况下,您还没有合并提交,所以您在这里很安全。)

方法二:还原

您无需重写历史,只需向历史中添加内容即可。从某种意义上说,这更安全:Git 是为了在旧提交之上添加新提交而构建的,因此所有这些都是自然发生的,无需任何其他人做任何额外的工作。缺点是丑陋,而且当你去git merge其他分支时,每次合并都需要额外的工作。

再次强调,理解这一点的关键是从绘制图形开始(或至少绘制其中的一部分,而您的图形足够小,可以绘制所有图形):

              H--I   <-- branch-b
             /
A--B--C--D--E   <-- master
             \
              F--G   <-- branch-a

我们现在想要 "undo" 提交 CB,按照 branch-abranch-b 的顺序。执行此操作的 Git 命令是 git revert。还原,可能应该被称为 "backout",其工作方式与 git cherry-pick.

大致相同

这个撤销可能有冲突(但是如果是的话,注意方法一中的历史重写也会有冲突)。我们将从还原 C 开始,它比较 BC 以查看发生了什么变化。然后它与我们现在所处的位置相反。让我们开始 branch-a,即提交 G,并执行 backing-out 从 C:

撤消这些更改
$ git checkout branch-a
$ git revert <hash of C>

与 cherry-pick 一样,如果一切顺利,还原将进行新的提交,提交消息显示 "this reverts ":

              H--I   <-- branch-b
             /
A--B--C--D--E   <-- master
             \
              F--G--J   <-- branch-a (HEAD)

(我没有聪明的方法来写一个颠倒的 C 或其他东西......好吧,我知道,例如 ,但我担心它可能不会在某些 fonts/browsers.) 现在我们还必须退出 B,使用第二个 git revert 命令:

              H--I   <-- branch-b
             /
A--B--C--D--E   <-- master
             \
              F--G--J--K   <-- branch-a (HEAD)

现在我们通过检查并恢复两次来在 branch-b 上重复这项工作。 (作为一个小 short-cut,我们可能会注意到 git revert 可以还原多个提交:我们可以 select 在命令行上同时提交。但重要的是我们将两者都还原,在在 C 的情况下 back-to-front 的正确顺序取决于 B。)现在的结果是:

              H--I--L--M   <-- branch-b (HEAD)
             /
A--B--C--D--E   <-- master
             \
              F--G--J--K   <-- branch-a

其中所有四个新提交都是 backing-out C 的回退,然后是 B.

我们也可以在 master 上取消 CB,但这很愚蠢,因为我们可能想立即将它们放回去。这将向 E 添加四次提交,最终源快照将与 E 中的快照匹配。它对未来的合并也没有帮助,因为两个分支重新加入 master 分支的点仍然是提交 E.

方法2设置的陷阱

进行所有这些恢复的缺点会在稍后出现,当您将分支合并回 master 时。要了解缺点,请回顾一下我们已有的绘图(或者如果您已删除它,请重新绘制它:-))。从 branch-amaster 的提示开始,向后(向左)工作到您可以在 both 分支上的第一个提交。那是提交 E,所以提交 E 是合并基础。

当我们稍后 运行 git checkout master && git merge branch-a 时,Git 将查看 branch-a 的尖端和 master 的尖端并向后工作以找到 E。无论我们向 branch-amaster 添加了多少提交,只要我们还没有合并,我们最终会回到 E 这里。

Git 将在这一点上 运行 两个 git diffs(无论如何都有效):

git diff --find-renames E <tip of master>   # what we changed
git diff --find-renames E <tip of branch-a> # what they changed

无论我们在 master 中做了什么,Git 都会记录哪些文件受到影响(以及这些文件的哪些行)。无论他们在 branch-a 做了什么,Git 记录哪些文件受到影响(以及这些文件的哪些行)。请注意 "we",在 master 中,还没有对 任何 BC 呢。即使我们还原并重新 cherry-pick 它们,我们仍然没有对它们做任何事情,因为 Git 不查看每个单独的提交,它只查看最终产品。

另一方面,他们在 branch-a 中......好吧,他们 恢复了 东西,所以他们有 backing-out 里面的东西BC 他们的 提示中,与提交 E 相比。所以 Git 认为撤销 BC 中的内容很重要。因此,合并 branch-a 将退出 BC,正如我们非常清楚地告诉 Git 很重要一样。

那么陷阱就在于,在合并 branch-a 之后,我们必须记住将这两个提交放回去,例如,通过 cherry-picking 它们。或者,等效地,就在合并 branch-a 之前,我们可以通过 cherry-picking 将两个提交放回 branch-a 中。现在是第二个差异,看看他们在 branch-a 中做了什么, 不会 包括退出 BC

此陷阱与 branch-b 重复,其中包含 内容(与提交 E 相比)退出 BC请注意,我们很可能不会在这里遇到任何冲突;我们必须记住re-add每次合并时的提交。没有冲突就是陷阱!

混合体:退出、合并和 cherry-pick

现在,正如我们在上面看到的,避免重写历史的问题是我们为后面的 git merge 命令设置了陷阱。但是,如果相反,我们违反了一些通常的 good-practice 规则并提前合并怎么办?

让我们再回到最初的图表:

              H--I   <-- branch-b
             /
A--B--C--D--E   <-- master
             \
              F--G   <-- branch-a

假设此时我们git checkout master && git revert <C> && git revert <B>得到这个:

              H--I   <-- branch-b
             /
A--B--C--D--E--J--K   <-- master (HEAD)
             \
              F--G   <-- branch-a

master 分支提示提交 K 现在看起来像——也就是说,它的源快照——我们希望 E 在我们制作两个时看起来像侧枝。所以现在我们可以 merge master 进入 两个分支。有时这不是个好主意,但我们会谨慎行事:

$ git checkout branch-a && git merge master
# write a commit message explaining that this is a special backout merge
(and repeat for branch-b)

现在我们的图表如下所示:

              H--I--M   <-- branch-b (HEAD)
             /     /
A--B--C--D--E--J--K   <-- master
             \     \
              F--G--L   <-- branch-a

现在我们立即返回 master 并通过 cherry-pick 将 BC 带回来(或还原它们的还原,这做同样的事情) .我们称 revert-of-revert 或 re-picks B'C' 因为它们很像 BC

              H--I--M   <-- branch-b
             /     /
A--B--C--D--E--J--K--B'-C'  <-- master (HEAD)
             \     \
              F--G--L   <-- branch-a

提交 Lbranch-a 顶端的合并,现在指向 GK(按此顺序)。我们不会担心 branch-b 因为它基本上是一样的。提交 L 具有 撤消 BC.

的效果

让我们想象一下,现在,我们及时前进,人们在 branch-a 上做出一些承诺:

              H--I--M   <-- branch-b
             /     /
A--B--C--D--E--J--K--B'-C'  <-- master
             \     \
              F--G--L--N--O--P   <-- branch-a

现在是 git checkout master && git merge branch-a 的时候了。 git merge 将选择哪个提交作为合并基础?

向后访问链接以查看哪些提交在哪些分支上。 C'master 上(仅); B' 在 master 上(仅); K 在两个分支上。提交 PONL 仅在 branch-a 上,但 L 返回到 K合并基础现已提交 K

Git 现在会将 K 的内容(没有 BC 的变化)与 C' 的内容进行比较: master 上的更改是恢复两个提交。 我们就是这么做的。然后 Git 会将 K 的内容与 P 的内容进行比较,看看他们做了什么。这将 not 包括退出 BC (因为那是 GL 所做的,所以 K vs L 没有)。如果我们遇到任何冲突,它们将不会包含 backing-out 部分。我们可以做一个正常的合并,就像我们做任何其他合并一样。最后的结果是:

              H--I--M   <-- branch-b
             /     /
A--B--C--D--E--J--K--B'-C'-----Q  <-- master (HEAD)
             \     \          /
              F--G--L--N--O--P   <-- branch-a

其中 Q 是从 K 加上我们的更改(re-introduce BC)加上他们的更改(引入 F +G+N+O+P).