在重新排序历史记录时避免交互式变基中的合并冲突

Avoiding a merge conflict in interactive rebase while reordering history

我有一个文件myfile.txt

Line one
Line two
Line four

其中每一行都添加到单独的提交中。

我编辑文件以添加“缺失”行,所以文件现在是

Line one
Line two
Line three
Line four

此 bash 脚本设置存储库:

#!/bin/bash

mkdir -p ~/testrepo
cd ~/testrepo || exit
git init

echo 'Line one' >> myfile.txt
git add myfile.txt
git commit -m 'First commit' 

echo 'Line two' >> myfile.txt
git commit -m 'Second Commit' myfile.txt

echo 'Line four' >> myfile.txt
git commit -m 'Third commit' myfile.txt

sed -i '/Line two/a Line three' myfile.txt
git commit --fixup=HEAD^ myfile.txt

历史是这样的

$ git --no-pager log  --oneline 
90e29ee (HEAD -> master) fixup! Second Commit
6a20f1a Third commit
ac1564b Second Commit
d8a038d First commit

我 运行 一个交互式 rebase 将修复提交合并到“第二次提交”中,但它报告合并冲突:

$ git rebase -i --autosquash HEAD^^^
Auto-merging myfile.txt
CONFLICT (content): Merge conflict in myfile.txt
error: could not apply 90e29ee... fixup! Second Commit
Resolve all conflicts manually, mark them as resolved with
"git add/rm <conflicted_files>", then run "git rebase --continue".
You can instead skip this commit: run "git rebase --skip".
To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply 90e29ee... fixup! Second Commit

$ git --no-pager diff
diff --cc myfile.txt
index b8b933b,43d9d5b..0000000
--- a/myfile.txt
+++ b/myfile.txt
@@@ -1,2 -1,4 +1,7 @@@
  Line one
  Line two
++<<<<<<< HEAD
++=======
+ Line three
+ Line four
++>>>>>>> 90e29ee... fixup! Second Commit

期望的历史是

xxxxxxx (HEAD -> master) Third commit
xxxxxxx Second Commit
d8a038d First commit

“第二次提交”看起来像这样:

diff --git a/myfile.txt b/myfile.txt
index e251870..802f69c 100644
--- a/myfile.txt
+++ b/myfile.txt
@@ -1 +1,3 @@
 Line one
+Line two
+Line three

TL;DR

您在此处 运行 基本上是合并中的边缘情况。您只需要手动修复这些。您可能想知道为什么我在谈论合并,而您没有 运行ning git merge。为此,请参阅下面的长答案。

git rebase所做的是复制(一些)提交。使用交互式 rebase 时,git rebase -i,您可以 fiddle 复制过程。当使用 --autosquash 时,Git 本身 fiddle 与复制过程有关。这种摆弄可能会导致您遇到的问题。即使没有任何摆弄,您仍然可以 遇到冲突。让我们来探讨一下。

关于提交

我们需要从提交的简要概述开始。每次提交:

  • 有一个唯一的编号:哈希 ID,由 运行对提交的全部内容进行加密校验和形成;
  • 包含所有文件的快照(作为内部 object保存提交的内容)和一些 元数据 ,或有关提交本身的信息:例如,您的姓名和电子邮件地址,以及提交的 parent[=543= 的哈希 ID ] 或 parents.

每个提交表单中的 parent 提交哈希 ID 提交到 backwards-looking 链中。例如,如果我们使用单个大写字母代表哈希 ID 来表示一个简单的线性提交链,我们将得到如下图:

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

其中 H 代表链中 last 提交的哈希 ID。该提交包含快照和早期提交的哈希 ID G。我们说H指向GG 依次指向 F,后者指向更早的位置。

因为提交保留快照,而不是更改,我们需要Git 比较 两个快照才能找到 更改.这就像玩 spot the difference 的游戏。为此,我们可以 运行 git diff 并给它两个原始提交哈希 ID,或者我们可以 运行 git show 在单个提交上,它将提交与其 (单)parent。 (对 merge commits 的影响是有两个或更多 parents 的提交,比较棘手。)

因为提交是通过它们的哈希 ID 找到的,并且哈希 ID 是加密校验和,所以我们无法更改任何现有提交的任何内容。如果某个提交在某些方面有缺陷,我们能做的最好的就是提取它,修复它,然后放入 Git 一个 new 提交:不同的内容将导致新提交的新的唯一哈希 ID。现有提交将保持不变。

因为提交包含其 parent 的哈希 ID,如果我们“更改”(即复制)任何提交,我们将被迫“更改”(复制)所有 随后的提交 也是如此。因此,任何 re-ordering 提交,或任何 broken-ness 关于 任何 提交的任何修复——包括仅修复其日志消息——都会产生连锁反应。这没什么大不了的:大多数提交都非常便宜。事实上 Git re-uses (de-duplicates) 文件在快照中,甚至 de-duplicates 整个快照,这意味着改变提交的一部分——比如它的日志消息——而不改变它的快照几乎不需要任何磁盘space。1 所以我们通常不需要担心磁盘space。

我们确实在变基时需要担心其他事情:特别是,我们必须担心其他Git存储库拥有的那些提交的副本。但是,如果没有其他 Git 存储库有这些提交,那么这种担心也就落空了。总的来说,当我们只使用私有存储库,或者当我们没有将我们的提交发送给其他任何人时,变基真的很安全。即使整个过程出错,我们的 原始 提交仍在 Git 中。 (然而,找到原作确实是一件苦差事。当你有 47 个长相相似的人,而且他们都声称是布鲁斯,哪个布鲁斯是原始布鲁斯?所以一定要小心跟踪,如果你做这种事。)


1在此过程中完全放弃的任何提交往往会持续至少 30 天,但随后会自动清除。


分支简介

一个分支名称主要只是保存某个链中最后一次提交的哈希ID。也就是说,当我们有:

...--G--H   <-- branch1

namebranch1 为我们做的是记住哈希 ID H。这样,我们就不必记住它,或将其写在白板上,或做任何其他事情。如果我们现在创建第二个分支名称 branch2,该名称 指向提交 H:

...--G--H   <-- branch1, branch2

我们将特殊名称 HEAD 附加到一个(并且只有一个)分支名称,以表示哪个名称,然后哪个提交,我们正在使用:

...--G--H   <-- branch1 (HEAD), branch2

现在我们进行一些新的提交。我们称之为 I 的第一个新提交将指向 currently-last 提交 H,并且 Git 将写入 I 的哈希 ID附加到 HEAD 的名称中:

          I   <-- branch1 (HEAD)
         /
...--G--H   <-- branch2

如果我们在 branch1 上进行第二次提交,则 git checkout branch2git switch branch2HEAD 附加到 branch2 并使 H当前提交,我们得到:

          I--J   <-- branch1
         /
...--G--H   <-- branch2 (HEAD)

在 now-current-branch2 上再提交两次给我们:

          I--J   <-- branch1
         /
...--G--H
         \
          K--L   <-- branch2 (HEAD)

正在合并

我们现在可以使用 git merge。如果我们首先 git checkout branch1J 将是 当前提交 ,并且我们将 git merge branch2 将工作与提交 L 合并。如果我们只是 git merge branch1L 将是当前提交,我们将把工作与提交 J 结合起来。合并效果在这里主要是对称的,但最终的 merge commit 将扩展我们实际上 on 的任何分支,所以让我们先 git checkout branch1 :

git checkout branch1 && git merge branch2

Git 现在会找到最好的 shared 提交——两个分支上最好的提交——作为 merge 基础 用于此合并操作。在这种情况下,最好的共享提交是显而易见的:它是提交 H。提交 G 并且所有较早的提交都在两个分支上,但是 H “更好”,因为它更接近 end.

为了合并工作,Git 现在将像我们一样使用 git diff 来查找 更改 。 Commit H 有一个快照,commit J 有一个,不管这两个 commit 之间有什么不同,嗯,这就是我们在 branch1:

上所做的
git diff --find-renames <hash-of-H> <hash-of-J>   # what we changed

重复差异但使用提交 L,另一个提交这次显示了 他们(好吧,我们)通过提交 K 更改的内容和 L:

git diff --find-renames <hash-of-H> <hash-of-L>   # what they changed

合并过程——我喜欢将其称为合并动词——现在合并这两组变化。合并后的更改不仅会做我们所做的事情,还会做他们所做的事情。如果我们触摸了一个文件而他们没有,我们就得到了我们的东西。如果他们碰了一个文件而我们没有,我们就会得到他们的东西。如果我们都触摸了某个文件,Git 也会尝试合并这些更改。

Git 将 应用 这些组合更改到 merge base 提交中的任何内容。也就是说,假设文件 F 中有 100 行,我们在第 42 行更改了一些内容并在第 50 行添加了一行,因此文件 F 现在有 101 行。假设他们在第 99 行更改了一些内容。Git 可以:

  • 将我们的更改保留在第 42 行;
  • 添加我们的行;和
  • 将他们的更改保留在第 99 行,现在是第 100 行

一切都很好。 Git 将认为这是合并的正确结果。2

这个合并更改并将合并的更改应用到合并基础的过程就是我所说的合并动词。这会生成一组 合并文件 。如果没有冲突,这些合并的文件就可以提交了。

合并工作实际上发生在 Git 的 index 又名 暂存区,尽管我们不会去在这里进入任何细节。如果存在 合并冲突 ,Git 会将所有三个输入文件留在其索引中,并尽最大努力合并到文件的工作树副本中。此工作树副本具有合并冲突标记。这会导致 merge-as-a-verb 进程失败。

对于 git merge,如果 merge-as-a-verb 步骤成功,Git 继续进行 合并提交 。合并提交几乎与常规提交完全相同:它有一个快照,就像任何提交一样,它有一个 parent,就像几乎所有提交一样。但是它还有一个secondparent。这就是使它成为 合并提交 的原因。这使用“合并”一词作为形容词,Git 通常将这些提交称为 合并 。所以这就是我所有合并为名词

假设一切顺利,我们会得到:

          I--J
         /    \
...--G--H      M   <-- branch1 (HEAD)
         \    /
          K--L   <-- branch2

合并 M第一个 parent 将提交 J,因为那是 branch1,我们的 HEAD, 是刚才。 merge Msecond parent 将提交 L.

如果 merge-as-a-verb 进程 失败 git merge 会在中间停止,留下烂摊子让你清理。对于那些来自脚本或程序的 运行ning git merge,它也会以非零状态退出。


2这是否真的 正确是一个单独的问题,而不是 Git 真正关心的问题。 Git 只是遵循这些简单的文本替换规则。


使用 git cherry-pick

复制提交

现在我们知道分支、分支名称和 git merge 是如何工作的,我们可以看看 git cherry-pick。它的功能是复制一个提交,通过找出提交的内容确实和“再做一次”。

也就是说,假设我们有这样的情况:

       I--J--K   <-- feature1
      /
...--H
      \
       L--M--N   <-- feature2 (HEAD)

我们现在正在处理 feature2,突然我们注意到:嘿,如果我们在提交 N 之后在这里提交 J,我们' d 准备好完成。 理想情况下,我们会让某人应用提交 J 提交 H — 可能在新分支上 — and/or 合并提交 J 变成一些东西,这样我们就可以更直接地使用它。但无论出于何种原因,我们只想将 更改为 ,从 IJ,变成 feature2.

我们可以运行:

git diff <hash-of-I> <hash-of-J>

查看更改是什么,然后我们自己对提交 N 中的任何内容进行相同的更改,并进行新的提交 O。但是,当我们有一台可以做到这一点的 计算机 时,我们为什么要竭尽全力进行这种复制呢?我们运行:

git cherry-pick <hash-of-J>

Git进行复制。如果一切顺利,它甚至会为我们复制 J 的提交消息,并进行新的提交。这个新提交很像 J——比较 N 和这个新提交将显示 相同的 变化,因为比较 IJ—所以我们不调用新提交 O,而是调用它 J':

       I--J--K   <-- feature1
      /
...--H
      \
       L--M--N--J'  <-- feature2 (HEAD)

这一切都很好,但这是我们需要知道的:git cherry-pick 实际 工作 的方式是 运行是 Git 合并机制。 它将提交 IJ 的 parent 设置为合并基础,然后 运行这两个 git diff 命令:

  • git diff 发现 他们 改变了什么;和
  • git diff 发现 we 改变了什么。

Git 现在结合了这两组更改,使我们的更改跟上提交 N,但添加它们的更改也能获得提交 J 的效果。提交 I 甚至不在我们的分支上这一事实是无关紧要的。 Git 使用合并 机器 来制作这个副本——通常一切都很好。

有了 运行 merge-as-a-verb 进程,Git 继续进行常规的普通 single-parent 提交。那就是我们的 J'。提交的 作者 author-date 和日志消息 从提交 J 中复制;我们成为提交者,新提交的提交日期是“现在”。

但是:merge-as-a-verb 进程可能 失败 。它可以有合并冲突。这就是您在 --autosquash 变基中看到的。

没有修复或其他技巧的变基

我们几乎已经准备好将各个部分拼凑起来。我们只需要再知道一件事:git rebase 通过 复制 提交来工作,就像使用 git cherry-pick 一样。对于某些版本的 git rebase,Git 字面意思是 运行s git cherry-pick。截至今天,最现代的 Git 版本已将 cherry-picking 内置到 rebase 代码中,因此不必单独 运行 它,但效果是一样的。我们可以认为为cherry-picking。即使是 fixup 和 squash 案例也是这样做的:它们只是改变了最后的 make-a-new-commit 步骤。

要完成变基,Git 首先列出要复制的所有提交的提交哈希 ID。这个 listing-out 过程比乍一看要复杂得多,但我们可以忽略这里的所有复杂情况,因为其中 none 实际上适用。在你的例子中,你有四个提交需要担心,其中三个将被复制,所以让我们画出那个。我们将第一个命名为 A。这是一个根提交:一个稍微特殊的情况,一个带有 no parent 的提交。所以,这就是你所拥有的:

A--B--C--D   <-- master (HEAD)

要执行 git rebase -i——无论是否有任何 autosquash 正在进行——Git 首先列出要复制的每个提交。使用 HEAD^^^,您告诉 Git 要复制的提交 而不是 A 开始并向后工作。它应该复制的提交是那些从HEAD(即master)开始并向后工作的提交:DCB,以及 A。从该列表中,我们扔掉 A-and-back,留下 DCB.

通常 Git会按B-C-D顺序复制这三个。那会 工作 。 Git 会将 B 复制到新的和改进的提交 B',然后使用 B' 复制 C 作为 C 的 parent ,然后使用 C' 复制 D,生成:

  B'-C'-D'  <-- master (HEAD)
 /
A--B--C--D   [abandoned]

每个复制步骤都像使用 git cherry-pick,使用 Git 的 detached HEAD 模式一样工作。 Git 首先使用 --detach:

检查提交 A
A   <-- HEAD
 \
  B--C--D   <-- master

现在 运行s git cherry-pick 带有提交 B 的散列。3 这会将 B 复制到 B' 使用合并引擎,将“合并基础”设置为提交 A。 Git 将合并基础提交 A 与自身进行比较,因为 HEAD 表示要使用 A。这表示不要更改任何内容。然后Git比较提交 A(再次合并基础)以提交 B。这表示要进行导致提交 B 快照的更改。 Git 做出导致提交 B 快照的更改,并将这些更改作为常规 (non-merge) 提交 B'、re-using 大多数 B的元数据:

A--B'  <-- HEAD
 \
  B--C--D   <-- master

现在 Git cherry-picks 提交 C。提交 BC 的 parent,因此它是强制合并基础。它与我们的 HEAD 提交 B' 完全匹配,因此 我们 没有要合并的更改;我们选择 他们的 更改并提交,从而得到 C 的精确副本 C':

A--B'-C'  <-- HEAD
 \
  B--C--D   <-- master

我们用 D 重复得到 D',然后 rebase 做它的最后一步,就是抽取 name master关闭提交 D 并将其粘贴到刚刚提交的最后一次提交中,并且 re-attach HEAD:

A--B'-C'-D'  <-- master (HEAD)
 \
  B--C--D   [abandoned]

这是我们之前画的同一张图,只是画的有点不同。


3rebase 命令在这里实际上很聪明:它意识到在此处复制 B,在提交 A 时,会产生一个新的提交,这实际上是一个 B 的副本,date-and-time-stamps 除外。因此,它不是复制它,而是 re-uses 它就位。为了打败这种聪明——这有时很有用,在极少数情况下你需要新的哈希 ID——你可以强制 git rebase 无论如何都要制作副本。出于说明目的,我们假设 git rebase 更笨,或者你已经打败了聪明,但如果你深入研究 rebase,就会知道它确实这样做了。


压缩或修复

我们可以,如果我们选择,告诉git rebase -i在这个复制过程中压缩一个提交到之前的提交。我们只是在git rebase -i给我们编辑的指令sheet中用squash这个词替换pick这个词。例如,假设我们使用提交 C 来做到这一点。然后将B复制到B'后,我们有:

A--B'  <-- HEAD
 \
  B--C--D   <-- master

Git 将以与以前几乎相同的方式执行 git cherry-pick,导致下一个将是 C' 的情况,如我们之前所示。但是这个提交步骤不是像往常一样提交,而是采取两个特殊操作:

  1. 它将来自B(或B'——它们是相同的)的提交消息写入临时文件,并添加来自[=137=的提交消息].它还添加了一些关于这是两个提交的压缩的文本。这就是当 Git 在实际 写出 新提交之前启动编辑器时你在编辑器中看到的内容。

  2. 而不是像往常一样提交,因此 C'B' 作为其 parent,Git 指示提交过程进行下一个提交有 A 作为它的 parent.

此时的结果是:

  B'   [abandoned]
 /
A--BC   <-- HEAD
 \
  B--C--D   <-- master

其中 BC 有一个与提交 C 匹配的快照,但是您在编辑文件时提供的提交消息。

Rebase然后可以像往常一样继续cherry-pick D,像往常一样移动分支名称。如果你看不到被遗弃的提交——包括被遗弃的 B'——那么它可能不存在,4 而你只有:

A--BC--D   <-- master (HEAD)

而且我们也真的不需要绘制其他废弃的提交。

请注意,如果您在 command-sheet 中使用 fixup 而不是 squash,Git 仍然会进行压缩 处理 .它不会打扰您编辑新的提交消息。它不是将来自各个 to-be-squashed commits/copies 的提交消息收集在一起,而是完全删除修复消息,保留先前提交的消息。 (您可以组合修复和压缩:如果您有 $S 压缩和 $F 修复,您编辑的组合消息将包含所有 $S 消息和 $F 消息的 none。)


4由于变基的聪明,它可能实际上不存在。即使 rebase 只是 re-uses 直接提交 B,这个过程仍然有效。


但是为什么我们会发生冲突?

您添加了 --autosquash。这使得 git rebase 自动 移动 复制命令(然后用 squashfixup 替换一些)。提交 B 保留在原位,但提交 D,这是它的修复,移动到 B 之后。 Commit C 留在最后。 Git 现在正在做:

  • 正常复制B;然后
  • 复制D,作为squash-with-fixup,即当我们将BD作为新提交时丢弃D的消息;然后
  • 正常复制C

那么让我们看看我们在复制时得到了什么 D。我们有:

A--B'  <-- HEAD
 \
  B--C--D   <-- master

就像我们以前做的一样。现在我们 运行 git cherry-pick 提交 D这使用提交 C 作为合并基础。 随着 我们的 的变化,我们得到了从 CB'.

CB' 的差异表示 remov 文件的合并基本副本中的行 line four;这一行应该是第三行。同时,从 CD 的差异表示 替换 文件的合并基础副本中的 line four 行,因此它读取 line three 代替。在 两种 情况下,这都出现在行 line two.

之后

在提交的实际文件中 B'第 2 行之后没有一行 line two。 Git 不知道如何将它从阅读 line four 更改为阅读 line three,也不知道如何删除它,因为它根本不存在。 Git 尽其所能处理此文件。然后它使 merge-as-a-verb 过程失败,在其轨道上停止 rebase 过程,并告诉您修复混乱。

如果您将 merge.conflictStyle 设置为 diff35 您的文件的工作树副本将不仅包含两个冲突的 更改 Git 由于某种原因无法合并,还有 merge base 版本的行。在这种情况下,这只会略有帮助,但这可能就足够了。我喜欢设置 diff3

一旦你修复了冲突——无论你选择如何修复它——Git 将你的结果作为“正确答案”并使用你告诉的任何内容进行新的 BD 组合提交 Git 是文件应该读取的正确方式。所以现在你有:

  B'   [abandoned]
 /
A--BD   <-- HEAD
 \
  B--C--D   <-- master

Git 现在应该 cherry-pick 提交 C。这 运行 是与合并基集的合并以提交 B。我们的提交是 BD,因此“我们更改的内容”是文件的 B 副本与您所做的任何内容的差异。他们的提交是 C,所以“他们改变了什么”是从 BC 的差异,它表示在第 3 行的一行之间添加“第四行”行“第二行”(第 2 行)和文件末尾。

除非您使文件在两行之后结束,第二行显示为“第二行”,否则 Git 可能无法将“他们的”更改与您的更改结合起来。所以你会看到合并冲突。如果你让文件像那样结束,Git 将决定合并根本不需要任何东西,这会让 git rebase 有点困惑:它会告诉你似乎没有理由再cherry-pick提交C,并强制你选择是否使用git rebase --skip跳过它。


5使用git config。要为所有尚未设置它的存储库设置它,请使用 git config --global。我使用 git config --global merge.conflictStyle diff3 全局设置它。