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

sna*_*erb 1 git rebase git-merge-conflict

我有一个文件 myfile.txt

Line one
Line two
Line four
Run Code Online (Sandbox Code Playgroud)

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

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

Line one
Line two
Line three
Line four
Run Code Online (Sandbox Code Playgroud)

此 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
Run Code Online (Sandbox Code Playgroud)

历史是这样的

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

我运行一个交互式 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
Run Code Online (Sandbox Code Playgroud)
  • 为什么将修复提交从分支的 HEAD 移动到“第二次提交”和“第三次提交”之间的位置会产生合并冲突?
  • 有没有办法可以执行 rebase 并避免冲突,或者让它自动解决?

所需的历史是

xxxxxxx (HEAD -> master) Third commit
xxxxxxx Second Commit
d8a038d First commit
Run Code Online (Sandbox Code Playgroud)

“第二次提交”如下所示:

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
Run Code Online (Sandbox Code Playgroud)

tor*_*rek 6

TL; 博士

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

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

关于提交

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

  • 有一个唯一的数字:一个哈希 ID,通过对提交的全部内容运行加密校验和形成;
  • 既包含了所有文件的快照(作为内部对象持有的提交肉)和一些元数据,信息或上述承诺本身:您的姓名和电子邮件地址,例如,和的哈希ID提交的父母父母.

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

... <-F <-G <-H
Run Code Online (Sandbox Code Playgroud)

whereH代表链中最后一次提交的哈希 ID 。该提交包含快照和较早提交的哈希 ID G。我们说,H 指向 GG反过来又指向F,它指向更早。

因为提交保存的是快照,而不是更改,所以我们需要让 Git比较两个快照以找到更改。这就好比是玩点差价的游戏。为此,我们可以运行git diff并为其提供两个原始提交哈希 ID,或者我们可以git show在单个提交上运行,将提交与其(单个)父提交进行比较。(对合并提交的影响,即有两个或多个父项的提交,比较棘手。)

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

因为提交包含其父项的哈希 ID,如果我们“更改”(即复制)任何提交,我们也被迫“更改”(复制)所有后续提交。因此,对提交的任何重新排序,或对任何提交的任何损坏的任何修复(包括仅修复其日志消息)都会产生连锁反应。这并不是什么大问题:大多数提交都非常便宜。Git 在快照中重复使用(重复数据删除)文件,甚至删除整个快照的重复数据,这意味着更改提交的一部分(例如其日志消息)而不更改其快照几乎不需要任何磁盘空间。1 所以我们通常不需要担心磁盘空间。

在变基时,我们确实需要担心其他事情:特别是,我们必须担心其他 Git 存储库所拥有的那些提交的副本。但是,如果没有其他 Git 存储库有这些提交,那么这种担心也就不存在了。总的来说,当我们只使用私有存储库时,或者当我们没有将提交发送给其他人时,rebase 确实非常安全。即使整个过程出错,我们最初的提交仍然在 Git 中。(它可以,但是,成为一个真正的苦差事找到原件。当你有47人看起来很像,和他们都自称是布鲁斯,这布鲁斯是原来的布鲁斯?所以一定要保持谨慎的轨道,如果你做这种事。)


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


分支的简要介绍

一个分支名主要只是持有的哈希ID上次在一些连锁提交。也就是说,当我们有:

...--G--H   <-- branch1
Run Code Online (Sandbox Code Playgroud)

这个名字 branch1对我们的作用是记住哈希 ID H。这样,我们就不必记住它,或者把它写在白板上,或者其他什么。如果我们现在创建第二个分支名称branch2,该名称指向 commit H

...--G--H   <-- branch1, branch2
Run Code Online (Sandbox Code Playgroud)

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

...--G--H   <-- branch1 (HEAD), branch2
Run Code Online (Sandbox Code Playgroud)

现在我们进行一些新的提交。我们将调用的第一个新提交I将指向当前最后一次提交H,并且 Git 会将I的哈希 ID 写入HEAD附加到的名称中:

          I   <-- branch1 (HEAD)
         /
...--G--H   <-- branch2
Run Code Online (Sandbox Code Playgroud)

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

          I--J   <-- branch1
         /
...--G--H   <-- branch2 (HEAD)
Run Code Online (Sandbox Code Playgroud)

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

          I--J   <-- branch1
         /
...--G--H
         \
          K--L   <-- branch2 (HEAD)
Run Code Online (Sandbox Code Playgroud)

合并

我们现在可以使用git merge. 如果我们首先git checkout branch1,J将是当前提交,我们将git merge branch2结合工作与提交L。如果我们只是git merge branch1,L将是当前提交,我们将结合 work 与 commit J。合并的效果主要是对称这里,但最终合并提交将延长取其分支,我们实际上是,所以让我们git checkout branch1先:

git checkout branch1 && git merge branch2
Run Code Online (Sandbox Code Playgroud)

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

为了合并工作,Git 现在将git diff像我们一样使用来查找更改。CommitH有一个快照,commitJ也有一个,无论这两个提交之间有什么不同,好吧,这就是我们所做的branch1

git diff --find-renames <hash-of-H> <hash-of-J>   # what we changed
Run Code Online (Sandbox Code Playgroud)

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

git diff --find-renames <hash-of-H> <hash-of-L>   # what they changed
Run Code Online (Sandbox Code Playgroud)

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

Git会应用这些组合的变化无论是在合并基础提交。也就是说,假设文件中F有 100 行,我们在第 42 行更改了一些内容并在第 50 行添加了一行,因此该文件F现在有 101 行。假设他们更改了第 99 行的某些内容。 Git 可以:

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

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

这种组合更改并将组合更改应用于合并基础的过程再次被我称为“合并”作为动词。这会产生一组合并的文件。如果没有冲突,这些合并的文件就可以提交了。

合并工作实际上发生在 Git 的index aka staging area 中,尽管我们不会在这里详细介绍。如果存在合并冲突,Git 会将所有三个输入文件都保留在其索引中,并将尽最大努力合并到文件的工作树副本中。此工作树副本具有合并冲突标记。这会导致合并作为动词过程失败。

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

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

          I--J
         /    \
...--G--H      M   <-- branch1 (HEAD)
         \    /
          K--L   <-- branch2
Run Code Online (Sandbox Code Playgroud)

merge的第一个父M将是 commit J,因为那是branch1我们HEAD的 刚才所在的地方。merge的第二个父级M是 commit L

如果merge-as-a-verb 过程失败,则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)
Run Code Online (Sandbox Code Playgroud)

我们现在正在工作feature2,突然我们注意到:嘿,如果我们在J这里提交一个又一个提交N,我们就可以完成了。 理想情况下,我们会让某人将提交J应用于提交——H可能是在一个新的分支上——和/或将提交合并J到某个东西中,以便我们可以更直接地使用它。但无论出于何种原因,我们只想,从IJ,变成feature2

我们可以运行:

git diff <hash-of-I> <hash-of-J>
Run Code Online (Sandbox Code Playgroud)

查看更改是什么,然后我们自己对提交中的任何内容进行相同的更改N,然后进行新的提交O。但是,当我们有一可以进行复制的计算机时,我们为什么还要自己去复制呢?我们跑:

git cherry-pick <hash-of-J>
Run Code Online (Sandbox Code Playgroud)

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

       I--J--K   <-- feature1
      /
...--H
      \
       L--M--N--J'  <-- feature2 (HEAD)
Run Code Online (Sandbox Code Playgroud)

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

  • git diff 发现他们改变了什么;和
  • git diff 找到我们更改的内容。

Git 现在结合了这两组更改,保持我们的更改跟上 commit N,但添加他们的更改以获得 commit 的效果J。提交I甚至不在我们的分支上的事实是无关紧要的。Git 使用合并机制来制作这个副本——通常所有的工作都非常出色。

运行合并作为动词过程后,Git 继续进行常规的普通单亲提交。那是我们的J'. 提交的作者、作者日期和日志消息从 commit 中复制J;我们成为提交者,新提交的提交日期是“现在”。

但是:merge-as-a-verb 过程可能会失败。它可能有合并冲突。这就是你在--autosquashrebase中看到的。

无需修复或其他技巧的变基

我们几乎准备好将各个部分放在一起。我们只需要知道一件事:git rebase通过复制提交来工作,就像使用git cherry-pick. 对于某些版本的git rebase,Git 会直接运行git cherry-pick. 目前最现代的 Git 版本在 rebase 代码中内置了cherry-picking,因此不必单独运行它,但效果是一样的。我们可以认为它是樱桃采摘。即使是 fixup 和 squash 情况也是如此:它们只是改变了最后的 make-a-new-commit 步骤。

为了完成变基,Git 首先列出要复制的所有提交的提交哈希 ID。这个上市过程比最初看起来要复杂得多,但我们可以忽略这里的所有复杂情况,因为它们实际上都不适用。在您的情况下,您需要担心四个提交,其中三个将被复制,所以让我们绘制它。我们将命名第一个A。这是一个根提交:一个稍微特殊的情况,一个没有父提交的提交。所以,这就是你所拥有的:

A--B--C--D   <-- master (HEAD)
Run Code Online (Sandbox Code Playgroud)

要做——git rebase -i无论是否有任何autosquash事情发生——Git 首先列出要复制的每个提交。使用HEAD^^^,您告诉 Git复制的提交开始于A并向后工作。它的提交复制不同于起点HEAD(即master)和向后工作:DCB,和A。从这个名单中,我们扔掉A-and回,留下DCB

通常Git 会按B-C-D顺序复制这三个。那行得通。Git 将复制B到一个新的和改进的 commit B',然后C使用B'asC的父级复制,然后D使用复制C',以产生:

  B'-C'-D'  <-- master (HEAD)
 /
A--B--C--D   [abandoned]
Run Code Online (Sandbox Code Playgroud)

这些复制步骤中的每一个都像使用git cherry-pick,使用 Git 的分离 HEAD模式一样工作。Git首先A使用--detach以下命令检查提交:

A   <-- HEAD
 \
  B--C--D   <-- master
Run Code Online (Sandbox Code Playgroud)

现在git cherry-pick使用 commit 的哈希值运行B3 这将复制BB'使用合并引擎,将“合并基础”设置为 commit A。Git 将 commit A(合并基础)与其自身进行比较,因为HEAD说要使用A. 这表示不要改变任何东西。然后 Git 将 commit A(再次合并基数)与 commit进行比较B。这表示进行导致 commitB快照的更改。Git 进行导致 commitB快照的更改,并将这些更改作为常规(非合并) commit 提交B',重新使用大部分B元数据:

A--B'  <-- HEAD
 \
  B--C--D   <-- master
Run Code Online (Sandbox Code Playgroud)

现在 Git 选择 commit C。CommitB是 的父级C,因此它是强制合并基础。它与我们的HEADcommit完全匹配B',因此我们没有要合并的更改;我们获取他们的更改并提交,从而生成Cas的精确副本C'

A--B'-C'  <-- HEAD
 \
  B--C--D   <-- master
Run Code Online (Sandbox Code Playgroud)

我们重复Dget D',然后 rebase 执行最后一步,即从 commit 中拉出名称 并将其粘贴到刚刚进行的最后一次提交上,然后重新附加:masterDHEAD

A--B'-C'-D'  <-- master (HEAD)
 \
  B--C--D   [abandoned]
Run Code Online (Sandbox Code Playgroud)

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


3 rebase 命令在这里实际上很聪明:它意识到B在 commit 时复制这里A会产生一个新的提交,它实际上是除日期和时间戳之外的精确副本B。因此,它不是复制它,而是在原地重新使用它。为了打败聪明——这有时很有用,在极少数情况下你需要新的哈希 ID——你可以强制git rebase进行复制。出于说明目的,我们将假设它git rebase更笨,或者你已经打败了聪明,但如果你深入研究 rebase,知道它确实这样做了。


挤压或修复

我们可以,如果我们选择,告诉git rebase -i压扁在这个过程中复制一个提交到先前的承诺。我们只是用说明表中让我们进行编辑pick的单词替换该单词。例如,假设我们用 commit 做到了这一点。然后复制到后,我们有:squashgit rebase -iCBB'

A--B'  <-- HEAD
 \
  B--C--D   <-- master
Run Code Online (Sandbox Code Playgroud)

Git 会git cherry-pick以与之前大致相同的方式执行此操作,从而导致下一个将C'如我们之前展示的那样。但与正常提交不同的是,此提交步骤需要两个特殊操作:

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

  2. 相反犯像往常一样,使C'具有B'作为其母公司,Git的指示提交过程,使下一提交具有A作为其父。

此时的结果是:

  B'   [abandoned]
 /
A--BC   <-- HEAD
 \
  B--C--D   <-- master
Run Code Online (Sandbox Code Playgroud)

whereBC有一个与 commit 匹配的快照C,但是你在编辑文件时提供的提交消息。

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

A--BC--D   <-- master (HEAD)
Run Code Online (Sandbox Code Playgroud)

并且我们也不需要绘制其他放弃的提交。

请注意,如果您在命令表中使用fixup而不是squash,Git 仍会执行此压缩过程。让您编辑新的提交消息并不麻烦。它没有将来自各种要压缩的提交/副本中的每一个的提交消息收集在一起,而是完全删除修复的消息,保留先前提交的消息。(您可以组合修复和压缩:如果您有 $S 压缩和 $F 修复,您编辑的组合消息将包含所有 $S 消息,而没有任何 $F 消息。)


4由于变基聪明,它实际上可能不存在。即使 rebase 只是B直接重用提交,这个过程也能工作。


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

你加了--autosquash。这使得git rebase自动移动复制命令(然后用squash或替换一些fixup)。CommitB保留在原位,但D作为其修复的commit移动到B. 提交C留在最后。Git 现在正在做:

  • B正常复制;然后
  • copy D,作为一个带有修复的壁球,即,D当我们BD作为一个新的提交时,丢弃的消息;然后
  • C正常复制。

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

A--B'  <-- HEAD
 \
  B--C--D   <-- master
Run Code Online (Sandbox Code Playgroud)

正如我们之前所做的那样。现在我们运行git cherry-pickcommit D这使用提交C作为合并基础。 随着我们的变化,我们得到了从C到的差异B'

diff from CtoB'表示从文件的合并基本副本中删除该行line four;这一行应该是第三行。同时,从差异CD表示,以代替该行line four的文件合并基础副本,因此它读取line three代替。在这两种情况下,这都在行之后line two

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

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

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

  B'   [abandoned]
 /
A--BD   <-- HEAD
 \
  B--C--D   <-- master
Run Code Online (Sandbox Code Playgroud)

Git 现在应该挑选 commit C。这将运行合并基础设置为 commit 的合并B。我们的提交是BD,因此“我们更改的内容”是B文件副本与您所做的任何事情的差异。他们的提交是C,所以“他们更改的内容”是与Bto的差异C,它表示在第 3 行、“第 2 行”(第 2 行)和文件末尾之间添加“第 4 行”行.

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


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