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

xpszyzbs  于 2023-03-06  发布在  Git
关注(0)|答案(2)|浏览(149)

我有一个文件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

我运行了一个交互式变基来将修正提交合并到“SecondCommit”中,但是它报告了一个合并冲突:

$ 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
  • 为什么将链接地址信息提交从分支的HEAD移动到“第二次提交”和“第三次提交”之间的位置会产生合并冲突?
  • 是否有一种方法可以执行重定基并避免冲突,或者自动解决冲突?

期望的历史是

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
xv8emn3q

xv8emn3q1#

TL; DR

你在这里遇到的基本上是合并的边缘情况。你只需要手动修复这些问题。你可能会想,当你没有运行git merge的时候,我为什么要谈论合并。关于这个问题,请参阅下面的详细答案。

git rebase所做的是 * 复制 *(一些)提交。当使用交互式rebase,git rebase -i时,你可以修改复制过程。当使用--autosquash时,Git自己修改复制过程。这种修改可能会导致你遇到的问题。即使没有任何修改,你仍然会遇到冲突。让我们来探索一下。

关于提交

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

  • 有唯一的编号:* 散列ID *,通过在提交的全部内容上运行加密校验和而形成;
  • 包含 * 所有文件的快照 *(作为保存提交内容的内部 * 树 * 对象)和一些 * 元数据 *,或关于提交本身的信息:例如,您的姓名和电子邮件地址,以及提交的 * parent * 或 * parents * 的哈希ID。

每个提交表单中的父提交哈希ID都提交到一个向后看的链中,例如,如果我们用单个大写字母来代表哈希ID,我们会得到一个简单的线性提交链:

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

其中H代表提交链中 * 最后一个 * 提交的哈希ID,该提交包含快照和较早提交G的哈希ID,我们说H * 指向 * GG依次指向FF指向更早的提交。
因为提交保存的是快照,而不是更改,所以我们需要让Git比较两个快照来发现更改,这就像玩spot the difference游戏一样。为此,我们可以运行git diff并给它两个原始提交哈希ID,或者我们可以对一个提交运行git show,比较提交和它的(单)父提交。(对 * merge commits * 的影响比较复杂,因为它们是有两个或更多父提交的提交。)
因为提交是通过哈希ID找到的,而哈希ID是加密校验和,所以我们不能修改任何现有提交的任何内容。如果某个提交在某些方面有缺陷,我们能做的最好的就是提取它,修复它,然后在Git中放入一个新的提交:不同的内容将为新的提交产生新的唯一的哈希ID。2现有的提交将保持不变。
因为一个提交包含其父提交的哈希ID,所以如果我们"改变"(即复制)任何一个提交,我们也会被迫"改变"(复制)所有 * 后续提交 *。因此,任何对提交的重新排序,或任何对 * 任何 * 提交的任何破坏性的修复--包括仅仅修复其日志消息--都会产生连锁React。这其实并不是什么大问题:大多数提交都很便宜。事实上,Git重用快照中的文件(去重),甚至删除整个快照,这意味着更改提交的一部分--比如日志消息--而不更改快照几乎不需要任何磁盘空间。1因此,我们通常不需要担心磁盘空间。
我们 * 确实 * 需要在重定基准时担心其他事情:特别是,我们必须担心其他Git仓库拥有这些提交的副本。但如果其他Git仓库没有这些提交,这种担心也就不重要了。总的来说,当我们只是使用私有仓库,或者当我们没有将提交发送给其他任何人时,重定基是非常安全的。即使整个过程出错,我们的 * 原始 * 提交仍然在Git中。(然而,它可以成为一个真正的苦差事,以 * 找到**原件 *。当你有47个人看起来很像,他们都声称是布鲁斯,哪个布鲁斯是 * 原始 * 布鲁斯?所以,如果你做这类事情,一定要仔细跟踪。)
1在此过程中完全放弃的任何提交往往会停留至少30天,但随后会自动清除。

简单看一下分支

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

...--G--H   <-- branch1
  • name * branch1为我们做的是记住哈希ID H,这样我们就不必记住它,也不必把它写在白板上,或者做其他事情。如果我们现在创建第二个分支名称branch2,这个名称 * 也 * 指向提交H
...--G--H   <-- branch1, branch2

我们将特殊的名称HEAD附加到一个(且只有一个)分支名称上,以表示我们使用的是哪个名称,也就是哪个提交:

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

现在我们做一些新的提交,第一个新的提交,我们称之为I,会指向当前最后一个提交H,Git会把I的哈希ID写入到HEAD所附加的名字中:

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

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

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

在当前的branch2上再提交两次,我们得到:

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

合并

现在我们可以使用git merge。如果我们先使用git checkout branch1J将是 * 当前提交 *,并且我们将使用git merge branch2来合并工作和提交L。如果我们只使用git merge branch1L将是当前提交,并且我们将合并工作和提交J。合并效果在这里基本上是对称的。但是最后的合并提交将扩展我们实际所在的分支,所以我们先来看看git checkout branch1

git checkout branch1 && git merge branch2

Git现在会找到最佳的 shared commit--两个分支上的最佳提交--作为合并操作的 merge base。在这种情况下,最佳的共享提交是显而易见的:提交G和之前的所有提交都在两个分支上,但是H更好,因为它更接近 end
为了合并工作,Git现在使用git diff来查找 * 修改 *。提交H有一个快照,提交J有一个快照,无论这两个提交之间有什么不同,好吧,这就是我们在branch1上所做的:

git diff --find-renames <hash-of-H> <hash-of-J>   # what we changed

重复diff,但是使用提交L,另一个提交,这次,显示了 * 他们 *(好吧,我们)通过提交KL改变了什么:

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

合并过程--我喜欢称之为动词“合并”--现在“合并”了这两组更改。合并后的更改不仅执行我们所做的更改,还执行他们所做的更改。如果我们接触了某个文件,而他们没有,我们就得到我们的内容。如果他们接触了某个文件,而我们没有,我们就得到他们的内容。如果我们都接触了某个文件,Git也会尝试合并这些更改。
Git会将这些合并后的修改“应用”到“合并基础”提交中的任何内容,也就是说,假设文件F有100行,我们修改了第42行的内容,并在第50行添加了一行,这样文件F现在有101行,假设他们修改了第99行的内容,Git可以:

  • 保留对第42行更改;
  • 加上我们的线;以及
  • 保留对第99行更改,即现在的第100行

一切正常,Git会认为这是合并的正确结果。2
这个合并更改并将合并后的更改应用到合并库的过程,再次被我称为动词**merge,这将产生一组 merged files,如果没有冲突,这些合并文件就可以提交了。
合并工作实际上发生在Git的 index aka staging area 中,但我们在这里不会详细介绍。如果存在 merge conflict,Git会将所有三个输入文件保留在其索引中,并将其最大努力写入文件的工作树副本中。该工作树副本具有合并冲突标记。这会导致合并为动词过程失败。
对于git merge,如果merge-as-a-verb步骤成功,Git会继续进行一次 merge commit。合并提交几乎和常规提交完全一样:它有一个快照,就像任何提交一样,它有一个父提交,就像几乎所有的提交一样。但它还有一个 second 父提交。这就是为什么它是一个 *merge提交 *。这里使用了单词“merge”作为形容词,Git经常把这些提交称为 a merge。所以这就是我所有的 *merge作为名词 *。
假设一切顺利,我们会得到:

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

合并M的 * 第一个父节点 * 应该是提交J,因为branch1,我们的HEAD,刚才就在那里.合并M的 * 第二个父节点 * 应该是提交L.
如果合并为动词的过程 * 失败 *,git merge会在中间停止,并留下一些混乱让您清理。对于那些从脚本或程序运行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,我们就可以完成了。* 理想情况下,我们会让某人把提交J应用到提交H上--可能是在一个新的分支上--和/或把提交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的父级)设置为合并基础,然后运行这两个git diff命令:

  • git diff查找 * 他们 * 修改了什么;以及
  • git diff查找 * 我们 * 修改的内容。

Git现在将这两组修改结合起来,保留我们的修改以跟上提交N,但添加它们的修改以获得提交J的效果。提交I甚至不在我们的分支上这一事实是无关紧要的。Git使用merge machinery 来进行这个复制-通常一切都运行得很好。
运行了合并为动词的过程后,Git继续执行一个普通的单亲提交,这就是我们的J',提交的 * 作者,作者日期和日志信息 * 都是从提交J中复制过来的;我们成为提交者,并且新提交的提交日期是“现在”。
但是:合并为动词的过程可能会失败,可能会有合并冲突,这就是你在--autosquash rebase中看到的。

不使用修正或其他技巧的重定基准

我们已经准备好把碎片拼起来了。我们只需要知道一件事:git rebase通过复制提交来工作,就像使用git cherry-pick一样。对于某些版本的git rebase,Git实际上运行git cherry-pick。到目前为止,最新版本的Git在rebase代码中内置了摘樱桃功能,因此它不必单独运行它。但效果是一样的。我们可以把它当作是摘樱桃。即使是修补和南瓜案件也是这样:它们只是改变了最后的创建新提交步骤。
为了完成一个变基,Git首先列出所有要被复制的提交的提交哈希ID。这个列出过程比看起来要复杂得多,但我们可以忽略所有的复杂性,因为它们都不适用。在你的例子中,你有四个提交要担心,其中三个会被复制。我们把它画出来,我们把第一个命名为A,这是根提交一个稍微特殊的例子,一个没有父提交的提交。所以,这里是你所得到的:

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

无论是否有autosquash正在运行,要执行git rebase -i,Git首先列出要复制的每个提交。使用HEAD^^^,你告诉Git not 要复制的提交从A开始并向后运行。它 should 要复制的提交是那些从HEAD(即master)开始并向后运行的提交:DCBA。在该列表中,我们去掉A,然后返回,留下DCB

  • 通常情况下 * Git会按照B-C-D的顺序复制这三个提交,这样就可以 * 工作 *。Git会将B复制到一个新的改进的提交B',然后使用B'作为C的父提交来复制C,再使用C'来复制D,以生成:
B'-C'-D'  <-- master (HEAD)
 /
A--B--C--D   [abandoned]

每个复制步骤都像使用git cherry-pick一样,使用Git的 detached HEAD 模式。Git首先使用--detach checkout 提交A

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

现在运行git cherry-pick,哈希值为提交B。3使用合并引擎将B复制到B',将“合并基”设置为提交A。Git比较提交A,合并基,因为HEAD要求使用A。这表示不改变任何东西。然后Git比较提交A(再次合并基址)来提交B。这里说的是要做出导致提交B的快照的更改。Git做出导致提交B的快照的更改,并将这些提交为常规(非合并)提交B',重用B的大部分元数据:

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

现在Git选择提交C。提交BC的父提交,所以它是强制合并的基提交。它与我们的HEAD提交B'完全匹配,所以 * 我们 * 没有要合并的更改;我们拾取 * 他们的 * 更改并提交,从而得到C的精确副本C'

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

我们用D重复这个过程,得到D',然后rebase执行最后一步,也就是把 namemaster从提交D中拉出来,粘贴到刚刚提交的最后一个文件中,然后重新附加HEAD

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

这和我们之前画的是一样的,只是画得有点不同。

3 rebase命令在这里实际上很聪明:它意识到在提交A时复制B会产生一个新的提交,除了日期和时间戳之外,它实际上是B的“精确”副本。因此,它没有复制它,而是在适当的位置重用它。在极少数情况下,当您需要新的散列ID时-您可以强制git rebase进行复制。为了便于说明,我们将假设git rebase比较笨,或者您已经战胜了这种聪明,但是如果您深入研究rebase,知道它确实是这样的。

压扁或修复

如果我们愿意,我们可以在复制过程中告诉git rebase -i将一个提交压缩到前一个提交中,我们只需要在git rebase -i给我们编辑的指令表中用squash替换pick,假设我们对提交C做了这样的操作:例如,在将B复制到B'之后,我们得到:

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

Git将以和之前基本相同的方式处理git cherry-pick,导致下一个提交的是C',就像我们之前展示的那样。但是这个提交步骤并不是像平常一样提交,而是采取了两个特殊的动作:
1.它把来自B(或B'--它们是相同的)的提交信息写入一个临时文件,并添加来自C的提交信息,还添加了一段文字说明这是两次提交的挤压,这就是Git在实际 * 写出 * 新提交之前启动编辑器时,在编辑器中看到的内容。

  1. Git没有像往常一样提交,而是指示提交进程将A作为其父提交,从而使C'B'作为其父提交。
    此时的结果为:
B'   [abandoned]
 /
A--BC   <-- HEAD
 \
  B--C--D   <-- master

其中BC有一个与提交C匹配的快照,但提交消息是您在编辑文件时提供的。
然后,Rebase可以像往常一样选择D,并像往常一样移动分支名称。如果你看不到被放弃的提交(包括被放弃的B'),那么它可能不存在,4你只需要:

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

而且我们也不需要引入其他被放弃的提交。
请注意,如果您在命令表中使用fixup而不是squash,Git仍然会执行这个压缩 * 过程 *,只是不需要您 * 编辑新的提交消息 *。它不会将各个要压缩的提交/副本中的提交消息收集在一起,而是将修复消息完全删除。保留上一次提交的消息。(您可以合并修复和挤压:如果您有$S个挤压和$F个修复,则您编辑的组合邮件将包含所有$S个邮件,而不包含任何$F个邮件。)
4由于rebase的聪明,它可能实际上并不存在。即使rebase只是直接重用commit B,这个过程也能工作。

但是为什么我们会有冲突呢?

您添加了--autosquash。这使得git rebase自动 * 移动 * 复制命令(然后也将一些替换为squashfixup)。提交B仍在原处,但提交D,这是它的修正,移动到B之后。Commit C保留在末尾。Git正在执行以下操作:

  • 正常复制B;那么
  • 复制D作为带有修正的压缩,即,当我们使BD作为新提交时丢弃D的消息;那么
  • 正常复制C

让我们看看复制D时得到了什么。

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

就像我们之前做的一样。现在我们在提交D时运行git cherry-pick。**这使用提交C作为合并基础。*随着 * 我们的 * 更改,我们得到CB'的差异。
CB'的差异表明要从文件的合并基础副本中 * 删除 * 行line four;这一行应该是第三行。同时,CD的diff表示要 * 替换 * 文件合并基础副本中的line four行,以便读取line three。在 * 两种 * 情况下,这一行都在line two行之后。
在提交B'的实际文件中,
在第2行之后没有一行是line two *。Git不知道如何将其从阅读line four改为读line three,也不知道如何删除它,因为它根本不存在。Git对这个文件做了最好的处理。然后它失败了合并为动词的过程,停止重定基过程,并告诉您修复混乱。
如果你将merge.conflictStyle设置为diff3,5那么你的工作树副本不仅会包含Git由于某种原因无法合并的两个冲突的 changes,还会包含行的 merge base 版本。在这种情况下,这只会有一点点帮助,但可能已经足够了。
一旦你修复了冲突--不管你选择用什么方式修复它--Git就会把你的结果当作“正确答案”,并使用你告诉Git的正确的读取方式来进行新的BD组合提交。

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

Git现在应该选择提交C。这将运行一个合并,合并基础设置为提交B。我们的提交是BD,所以“我们改变的”是文件的B副本与您所做的任何操作的差异。他们的提交是C,因此,“他们改变了什么”是从BC的差异,这意味着在第3行添加行“第4行”,在表示“第2行”的行(在第2行)和文件末尾之间。
除非你让文件在两行之后结束,并且第二行阅读“line two”,否则Git很可能在合并“他们的”修改和你的修改时遇到问题,所以你会看到合并冲突。如果你 do 让文件在这里结束,Git会认为合并不需要任何东西,这会让git rebase有点困惑:它会告诉您似乎没有理由再选择提交C,并迫使您选择是否使用git rebase --skip跳过它。
5使用git config。要为所有尚未设置它的存储库设置它,请使用git config --global。我使用git config --global merge.conflictStyle diff3全局设置它。

6rqinv9w

6rqinv9w2#

正如@torek所指出的,这里您遇到了一个边缘情况,但是,如果您遇到了更一般的情况,即在第二次提交的第二行下面碰巧有一个锚行,那么一切都没有问题。

#!/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
echo "An anchor line" >> 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

此时,myfile.txt看起来像

Line one
Line two
Line three
An anchor line
Line four

然后,如果运行带有autosquash选项的rebase命令

git rebase -i --autosquash HEAD~3

将不存在合并冲突,并且你得到期望的提交历史。

相关问题