在 Git 中,rebase 和 merge 产生的冲突有何不同?

4
  1. 在将分支合并时,与将分支变基有什么不同?为什么会这样?

  2. 当进行合并时,合并的更改存储在合并提交本身中(具有两个父级的提交)。 但是,在进行变基时,合并存储在哪里?

谢谢, Omer

3个回答

7

在查看torek的答案之后,再次阅读问题,我更新以澄清一些问题...

  1. 在将分支合并到分支或将分支变基时,冲突数量之间有什么区别吗?为什么会这样?

可能会有很多原因导致冲突数量不同。最简单的是,合并过程只查看三个提交 --“ours”、“theirs”和合并基础。所有中间状态都被忽略了。相比之下,变基将每个提交转换为补丁,并逐个应用。所以如果第3个提交创建了一个冲突,但第4个提交取消了它,那么变基将看到冲突而合并不会。

另一个区别是当提交已在合并的两侧进行了cherry-pick或其他复制时。在这种情况下,rebase通常会跳过它们,而在合并中可能会引起冲突。

还有其他原因;归根结底它们只是不同的过程,尽管它们通常预期产生相同的组合内容。

  1. 在合并时,合并更改存储在合并提交本身(具有两个父级的提交)中。但在变基时,合并存储在哪里?

合并结果存储在rebase创建的新提交中。默认情况下,变基为每个被变基的提交编写一个新的提交。

如torek在他的答案中所解释的那样,问题可能表明了对合并存储内容的错误理解。问题可以被理解为断言导致合并结果的改变集(“补丁”)在合并中是明确存储的;它们不是。合并 - 就像任何提交一样 - 是内容的快照。使用其父指针,您可以找出应用了哪些补丁。在变基的情况下,git不会显式保留有关原始分支点,有关哪些提交位于哪个分支以及它们在何处被重新集成的信息;因此,每个提交的更改都在该提交与其父提交之间的关系中得到保留,但除了存储在仓库中的附加知识外,没有一般方法来重构与相应合并相关联的两个补丁。


例如,假设您有

O -- A -- B -- C <--(master)
 \
  D -- ~D -- E -- B' -- F <--(feature)

如果 Dmaster 的更改发生冲突,~D将撤销 DB' 是将 B 樱桃拣选到 feature 中的结果。

现在,如果你将 feature 合并到 master 中,合并只会查看 (1) FO 之间的区别,以及 (2) CO 之间的区别。它不会“看到”来自 D 的冲突,因为 ~D 反转了冲突的更改。它会看到 BB' 都更改了相同的行;由于双方都做出了相同的更改,它可能能够自动解决这个问题,但根据其他提交中发生的情况,这里存在潜在的冲突。

但一旦解决了任何冲突,你最终获得的是:

O -- A -- B -- C -------- M <--(master)
 \                       /
  D -- ~D -- E -- B' -- F <--(feature)

同时,正如您所指出的,M包含合并的结果。

回到原始图片...

O -- A -- B -- C <--(master)
 \
  D -- ~D -- E -- B' -- F <--(feature)

如果你将feature变基到master,就像是逐个将每个feature提交与master合并一样。你可以粗略地想象,开始时你说了:

git checkout master
git merge feature~4

由此产生了冲突。您解决了这个问题,然后得到了

O -- A -- B -- C -- M <--(master)
 \                /
  -------------- D -- ~D -- E -- B' -- F <--(feature)

您可以使用以下命令切换到下一个提交:

git merge feature~3

可能会有冲突,也可能没有冲突,但当你完成后,你会得到...
O -- A -- B -- C -- M -- M2 <--(master)
 \                /     /
  -------------- D -- ~D -- E -- B' -- F <--(feature)

如果您正确地解决了任何冲突,则M2应与C具有相同的内容。 然后执行E

git merge feature~2

B'有些不同,因为rebase会跳过它;所以你可以这样做:

git merge -s ours feature~1

最后

git merge feature

你最终将得到:

O -- A -- B -- C -- M -- M2 -- M3 -- M4 - M5<--(master)
 \                /     /    /     /    /
  -------------- D -- ~D -- E -- B' -- F <--(feature)

(其中M4是一个"ours"合并,所以M4的内容与M3相同。)
因此,rebase非常类似,只是它不跟踪连接新提交到feature分支的“第二个父级”指针,并且完全跳过了B'。(还将分支移动到不同的位置。)因此我们画出以下图示。
                   D' -- ~D' -- E' -- F' <--(feature)
                 /
O -- A -- B -- C <--(master)
 \
  D -- ~D -- E -- B' -- F

因此,我们可以通过视觉方式表明D'“来自于”D,即使它不是具有父指针显示其与D关系的合并提交。但是,这就是合并这些更改结果存储的地方;最终,F'存储了两个历史记录的完成集成。
正如上面提到的,仓库的最终状态(重演后)中没有任何东西清楚地表明哪些补丁将与(大致等效的)合并相关联。您可以使用git diff O C查看其中一个,使用git diff C F'查看另一个,但是您需要git不保留的信息才能知道OCF'是相关提交。
请注意,在此图片中,F是不可访问的。它仍然存在,并且您可以在reflog中找到它,但是除非还有其他东西指向它,否则gc最终可能会销毁它。
还要注意,将feature重演到master不会推进master。您可以
git checkout master
git merge feature

master 分支合并到 feature 分支以完成分支的整合。

我唯一建议的是,将“请注意,在这张图片中,F是无法到达的”澄清为从F向后(直到O)整个链条都是无法到达的。基本上,我们(故意地)丢失了整个“分支”,用一个“副本”替换了它。初学者通常对于rebase的这一点不太理解。 - matt

2
重新定义基础是(大多数情况下)一系列cherry-pick。cherry-pick和merge都使用相同的逻辑——我称之为“合并逻辑”,文档通常称之为“三方合并”——来创建一个新的提交。
这个逻辑是,给定提交X和Y:
1. 从一个较早的提交开始。这被称为“合并基础”。 2. 对比较早的提交和X之间的差异。 3. 对比较早的提交和Y之间的差异。 4. 将两个差异都应用于较早的提交,并且: a. 如果你可以这样做,就创建一个表达结果的新提交。 b. 如果你无法这样做,请抱怨你有冲突。
在这方面,合并和cherry-pick(因此合并和rebase)几乎是相同的,但是存在一些差异。其中一个极其重要的区别是“3-way merge”逻辑中的“3”是谁。特别是,在第一步(合并基础)中,他们可能对“较早的提交”有不同的想法。
让我们首先看一个退化的例子,在这个例子中,合并和cherry-pick几乎是相同的:
A -- B -- C <-- master
      \
       F <-- feature

如果你将功能合并到主分支中,Git会查找最近发生分歧的功能和主分支的提交。这是B。在我们的合并逻辑中,它是“较早的提交”——即合并基础。因此,Git使用B与C进行差异比较,并使用B与F进行差异比较,并将两个差异都应用于B以形成一个新的提交。它给该提交两个父级,C和F,并移动master指针:

A -- B - C - Z <-- master
      \     /
       \   / 
         F <-- feature

如果你将一个功能 挑选 到主分支上,Git会寻找该功能的父级,也就是F的父级。那就是B!(这是因为我故意选择了这种退化情况。)这是我们合并逻辑中的“较早提交”。因此,Git再次将C与B进行比较,将F与B进行比较,并将这两个差异应用于B以形成新的提交。现在它给该提交一个父级C,并移动了master指针:
A -- B - C - F' <-- master
      \   
       F <-- feature

如果你在主分支上执行rebase功能,Git会对每个提交进行挑选,并移动feature指针。在我们的极端情况下,feature上只有一个提交:

A -- B - C <-- master
      \    \
       \    F' <-- feature
        F

现在,在这些图表中,作为合并基础的“早期提交”在每种情况下都是相同的:B。因此,合并逻辑是相同的,因此在每个图表中都存在冲突的可能性。

但是,如果我在功能上引入更多提交,情况会发生变化:

A -- B -- C <-- master
      \
       F -- G <-- feature

现在,将功能分支变基到主分支意味着先将F挑选出来放在C上(得到F'),然后再将G挑选出来放在F'上(得到G')。对于第二次挑选,Git使用F作为“早期提交”(合并基础),因为它是G的父提交。这引入了一个我们以前没有考虑过的情况。特别地,合并逻辑将涉及从F到F'的差异,以及从F到G的差异。
因此,当我们进行变基时,我们迭代地挑选每个提交,在每次迭代中,比较我们合并逻辑中的三个提交是不同的。因此,显然我们引入了新的合并冲突可能性,因为实际上,我们正在进行许多不同的合并。

0
在将分支合并或变基时,冲突数量是否有所不同?为什么会这样呢?
我认为使用动词“是”在这里有些过头。如果我们将其改为“可能会有”,答案肯定是肯定的。原因很简单:变基和合并是根本不同的操作。
当进行合并时,合并的更改存储在合并提交本身中(具有两个父提交的提交)。但是当进行变基时,合并被存储在哪里呢?
这个问题假设了一些事情,虽然在某些方面是次要的。但为了解释发生了什么,它不再是次要的。
具体来说,为了理解所有这些,我们需要知道:
- 提交究竟是什么(或者至少相当详细); - 分支名称的工作原理; - 合并的工作原理,相对准确地; - 变基的工作原理,相对准确地。

每个小错误在合并时都会被放大,所以我们需要非常详细。为了更好地理解rebase,我们可以将其拆分一下,因为rebase本质上是一系列重复的cherry-pick操作,再加上一些其他内容。所以我们将在上面添加“cherry-pick的工作原理”。

提交被编号

让我们从这里开始:每个提交都有一个编号。但是,提交的编号不是简单的计数数字,我们没有#1、#2、#3等连续的编号。相反,每个提交都有一个唯一但看起来随机的哈希ID。这是一个非常大的数字(目前为160位长),用十六进制表示。Git通过对每个提交的内容进行密码校验和来生成每个编号。

这是使Git作为分布式版本控制系统(DVCS)发挥作用的关键:像Subversion这样的集中式版本控制系统可以为每个修订版本分配一个简单的计数编号,因为实际上有一个中央机构分发这些编号。如果此刻无法连接到中央机构,您也无法进行新的提交。所以在SVN中,只有在中央服务器可用时才能提交。而在Git中,您可以随时本地提交:没有指定的中央服务器(当然,如果你愿意,你可以选择任何Git服务器并称其为“中央服务器”)。
这在我们将两个Git连接起来时最重要。它们将对于每个完全相同的提交使用相同的编号,并且对于任何不同的提交使用不同的编号。这就是它们如何确定它们是否具有相同的提交;这就是发送方Git可以向接收方Git发送发送者和接收者都同意接收方需要并且发送方希望接收者拥有的任何提交,同时尽量减少数据传输的方式。(其中还有更多内容,但编号方案是其核心)。

现在我们知道了提交是有编号的,根据编号系统,一旦提交完成,任何部分都不能再进行更改,因为这只会导致一个新的、不同编号的提交。现在我们可以看看每个提交中实际包含了什么。

提交存储快照和元数据

每个提交由两部分组成:

  • 每个提交都包含了 Git 在你或其他人进行该提交时所知道的每个文件的完整快照。这些文件以一种特殊的、只读的、Git专用的、压缩和去重的格式存储。去重意味着如果有成千上万个提交都有某个文件的相同副本,那么这些提交都共享该文件。由于大多数新提交与之前的某些或大多数提交具有相同版本的相同文件,因此仓库实际上并不会增长太多,即使每个提交都包含了每个文件。

  • 除了文件之外,每个提交还存储了一些元数据,即关于提交本身的信息。这包括提交的作者和一些日期时间戳。它还包括一个日志消息,在其中你可以向自己和/或他人解释为什么进行了这个特定的提交。而且,对于 Git 的操作来说至关重要的是,每个提交还存储了某个先前提交或多个先前提交的提交号码或哈希 ID,但这不是你自己管理的。

大多数提交仅存储一个先前的提交。此先前提交的哈希 ID 的目标是列出新提交的父提交父提交。这就是 Git 如何找出变更的方法,尽管每个提交都有一个快照。通过查找先前提交,Git 可以获取先前提交的快照。然后,Git 可以比较这两个快照。去重使得这一过程比否则更加容易。只要两个快照具有相同的文件,Git 就可以完全不提及它们。只有在两个文件实际上不同时,Git 才需要比较文件。Git 使用差异引擎确定将旧文件(或左侧文件)转换为新文件(右侧文件)所需的更改,并显示这些差异。

你可以使用这个差异引擎来比较任意两个提交或文件:只需给它一个左侧和右侧的文件进行比较,或者一个左侧和右侧的提交。Git会玩“找出不同之处”的游戏,并告诉你发生了什么变化。这对我们以后很重要。不过,现在只需比较父提交和子提交,对于任何简单的一对父子提交,就能告诉我们在该提交中发生了什么变化。

对于只有一个指向父提交的子提交,我们可以画出这种关系。如果我们用单个大写字母代表哈希ID(因为真正的哈希ID对人类来说太大且难看),我们得到的图像如下:

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

在这里,H代表链中的最后一个提交。它指向之前的提交G。这两个提交都有快照和父哈希ID。所以提交G指向它的父提交F。提交F有一个快照和元数据,因此它指向另一个提交。
如果我们让Git从末尾开始,一次只向后移动一个提交,我们就可以让Git一直回溯到第一个提交。那个第一个提交不会有一个向后指向的箭头,因为它不能有,这样就可以让Git(和我们)停下来休息。这就是例如git log做的事情(至少对于git log的最简单情况)。
然而,我们确实需要一种找到最后提交的方法。这就是分支名称发挥作用的地方。

分支名称指向一个提交

Git分支名称保存了一个提交的哈希ID。根据定义,无论存储在该分支名称中的哈希ID是什么,都是该分支的末端。链条可能会继续,但由于Git是向后工作的,那就是该分支的末端。

这意味着如果我们只有一个分支的存储库,让我们称之为“main”,就像GitHub现在所做的那样,存在一些最后的提交,其哈希ID在名称“main”中。让我们画出来:

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

我变得懒惰了,不再将提交的箭头画成箭头形状。这也是因为我们即将面临一个绘制箭头的问题(至少在StackOverflow上,字体可能有限)。请注意,这与刚才的图片相同;我们刚刚弄清楚如何记住提交H的哈希ID:将其放入分支名称中。

让我们添加一个新的分支。分支名称必须保存某个提交的哈希ID。我们应该使用哪个提交?让我们使用H:它是我们现在正在使用的提交,也是最新的提交,所以在这里非常合理。让我们来绘制结果:

...--F--G--H   <-- dev, main

两个分支的名称都选择H作为它们的“最后”提交。因此,包括H在内的所有提交都在两个分支上。我们还需要一件事:一种记住我们正在使用的名称的方法。让我们添加一个特殊的名称HEAD,并将其写在一个分支名称之后,用括号括起来,以便记住我们正在使用的名称

...--F--G--H   <-- dev, main (HEAD)

这意味着我们当前处于主分支(on branch main),就像git status所显示的那样。让我们运行git checkout dev或者git switch dev来更新我们的绘图。
...--F--G--H   <-- dev (HEAD), main

我们可以看到,HEAD 现在已经附加到名称 dev 上,但我们仍然在使用提交 H
现在让我们创建一个新的提交。我们将按照通常的步骤进行(不在这里描述)。当我们运行 git commit 时,Git 将创建一个新的快照并添加新的元数据。我们可能需要首先输入一个提交消息,以进入元数据,但无论如何,我们都会到达那里。Git 将把所有这些内容写入以创建一个新的提交,该提交将获得一个新的、唯一的、又长又丑陋的哈希 ID。不过,我们将简称此提交为 I。提交 I 将指向 H,因为我们在此刻之前一直在使用 H。让我们在图中画出这个提交:
             I
            /
...--F--G--H

但是我们的分支名称呢?嗯,我们对main没有做任何操作。我们添加了一个新的提交,这个新的提交应该是分支dev上的最后一次提交。为了实现这一点,Git只需将I的哈希ID写入名称dev中,Git知道这是正确的名称,因为这是HEAD所附着的名称:
             I   <-- dev (HEAD)
            /
...--F--G--H   <-- main

我们现在拥有我们想要的:在main分支的最新提交仍然是H,但在dev分支上的最新提交现在是I。直到H提交的内容仍然存在于两个分支上;而I提交只存在于dev分支上。

我们可以添加更多的分支名称,指向这些提交中的任何一个。或者,我们现在可以运行git checkout maingit switch main。如果我们这样做,我们将得到:

             I   <-- dev
            /
...--F--G--H   <-- main (HEAD)

我们的当前提交现在是提交H,因为我们的当前名称main,而main指向H。Git将所有的提交-I文件从我们的工作树中取出,并将所有的提交-H文件放入我们的工作树中。

(附注:请注意,工作树文件本身不在Git中。Git只是将Git化的、已提交的文件从提交中复制到我们的工作树中。这是checkoutswitch的一部分操作:我们选择某个提交,通常通过某个分支名称,让Git擦除我们之前使用的提交中的文件,并替换为所选提交的文件。其中隐藏了很多复杂的机制,但我们在这里忽略所有这些。)

我们现在可以继续进行 `git merge`了。需要注意的是,`git merge`并不总是执行实际的合并操作。下面的描述将从需要真正合并的设置开始,因此运行 `git merge` 将执行一个真正的合并操作。真正的合并可能会出现合并冲突。而 `git merge` 进行的其他操作,即所谓的快进式合并(实际上并不是合并),以及不执行任何操作的情况,都不会产生合并冲突。
真正合并的工作原理如下:
假设在我们的 Git 仓库中,此时有这样两个分支的排列关系:
          I--J   <-- branch1 (HEAD)
         /
...--G--H
         \
          K--L   <-- branch2

(可能有一个指向H或其他提交的分支名称,但由于对我们的合并过程没有影响,我们不会在图中画出它。)如图所示,我们当前处于branch1上,因此我们现在检出了提交L。我们运行:
git merge branch2

Git现在将定位到提交J,这是微不足道的:那就是我们目前所在的提交。Git还将使用名称branch2来定位提交L。这很容易,因为名称branch2中包含了提交L的原始哈希ID。但是现在git merge会执行其主要技巧之一。

记住,合并的目标合并变更。然而,提交JL并没有变更。它们只有快照。从某个快照获取变更的唯一方法是找到其他提交并进行比较。

直接比较JL可能会有一些效果,但从实际上将两个不同的工作集合组合起来并没有太大好处。所以这不是git merge的功能。相反,它使用提交图 - 我们用大写字母代表提交所绘制的东西 - 来找到最佳的共享提交,这些提交在两个分支上都存在。
实际上,这个最佳共享提交实际上是一个名为“有向无环图的最低公共祖先”的算法的结果,但对于像这样简单的情况来说,它是相当明显的。从分支尖端提交JL开始,向后(向左)使用你的眼球。两个分支在哪里相遇?没错,就是在提交H。提交G也是共享的,但H更接近末尾,所以显然(?)更好。因此,Git在这里选择了它。
Git 将这个共享的起点称为“合并基准”。现在,Git可以进行差异比较,从提交 H 到提交 J,以确定我们所做的变更。这个差异将显示出对某些文件的一些改动。另外,Git可以现在进行差异比较,从提交 H 到提交 L,以确定他们所做的变更。这个差异将显示出对某些文件的一些改动:可能是完全不同的文件,或者,当我们都对相同的文件进行了改动时,我们可能对这些文件的不同行进行了改动。
git merge 的工作现在是将这些变更合并起来。通过接受我们的变更并添加他们的变更——或者接受他们的变更并添加我们的变更(这将得到相同的结果)——然后应用这些合并后的变更到提交 H 中的内容,Git可以构建一个新的、准备就绪的快照。
这个过程会失败,因为合并冲突,当"我们"和"他们"的更改发生冲突时。如果我们和他们都修改了相同文件的相同行,Git 就不知道该使用谁的更改。我们将被迫修复混乱,然后继续合并。
关于如何进行修复和如何自动化更多内容,有很多需要了解的地方,但对于这个特定的答案,我们可以在这里停下:要么我们有冲突,必须手动修复并运行 "git merge --continue",1 要么我们没有冲突,Git 将完成合并。合并提交会得到一个新的快照——不是更改,而是完整的快照——然后链接回两个提交:第一个父提交是我们当前的提交,然后作为第二个父提交,是我们要合并的提交。所以最终的图形看起来像这样:
          I--J
         /    \
...--G--H      M   <-- branch1 (HEAD)
         \    /
          K--L   <-- branch2

合并提交M有一个快照,如果我们运行git diff 哈希值-J 哈希值-M,我们将看到由于他们在他们的分支上的工作而带来的变化:从HL的变化被添加到我们从HJ的变化中。如果我们运行git diff 哈希值-L 哈希值-M,我们将看到由于我们在我们的分支上的工作而带来的变化:从HJ的变化被添加到他们从HL的变化中。当然,如果合并在制作提交M之前因任何原因停止,我们可以对M的最终快照进行任意更改,这被一些人称为“恶意合并”(参见Git中的恶意合并?)。
(这个合并提交对于以后的git log也是一个小障碍,因为:
  • 没有办法生成一个普通的差异:它应该使用哪个父节点?
  • 在我们向后遍历时,有两个父节点要访问:它们会如何被访问? 它们会被访问吗?

这些问题及其答案相当复杂,但不适合放在这个StackOverflow答案中)。

接下来,在我们继续讲解rebase之前,让我们仔细看一下git cherry-pick


1不要使用git merge --continue,你可以运行git commit。这样做的效果完全一样。合并程序会留下面包屑,而git commit会找到它们,并意识到正在完成合并,并实现git merge --continue,而不是进行简单的单父合并。在Git用户界面较差的旧日子里,没有git merge --continue,所以我们这些有着非常老习惯的人倾向于在这里使用git commit


如何使用git cherry-pick

在使用任何版本控制系统时,我们经常会遇到一些需要“复制”提交的情况。例如,假设我们有以下情况:

       H--P--C--J   <-- feature1
      /
...--G--I   <-- main
         \
          K--L--N   <-- feature2 (HEAD)

有人正在开发feature1,已经进行了一段时间;我们现在正在开发feature2。我在分支feature1上命名了两个提交PC,原因暂时不明显,但会变得明显起来。(我跳过了M,因为它听起来太像N,而且我喜欢用M表示合并。)当我们要创建一个新的提交O时,我们意识到有一个错误或缺失的功能,是我们需要的,而做feature1的人已经修复或编写了。他们在父提交P和子提交C之间进行了一些更改,我们希望现在在feature2上也能有这些完全相同的更改。

(在这里使用樱桃拣选通常是错误的方法,但让我们还是演示一下,因为我们需要展示樱桃拣选的工作原理,而正确的方法更加复杂。)

为了复制提交 C,我们只需运行 git cherry-pick hash-of-C,其中我们通过运行 git log feature1 找到提交 C 的哈希值。如果一切顺利,我们将得到一个新的提交 C',它被命名为 copy of C,并放在当前分支的末尾。
       H--P--C--J   <-- feature1
      /
...--G--I   <-- main
         \
          K--L--N--C'  <-- feature2 (HEAD)

但是Git如何实现这个cherry-pick提交呢?
简单来说,Git比较了快照中的P和C,以查看在那里有什么变化。然后,Git对快照中的N做同样的操作,生成C',当然C'的父提交是N,而不是P。
但是这并没有解释cherry-pick如何产生合并冲突。真正的解释更加复杂。cherry-pick实际上是从之前的合并代码中借用的。不过,与找到一个实际的合并基础提交不同,cherry-pick只是强制Git使用提交P作为“伪造”的合并基础。它将提交C设置为“他们”的提交。这样,“他们”的变化就是P与C之间的差异。这正是我们想要添加到我们的提交N中的变化。
为了使这些更改顺利进行,cherry-pick代码继续使用合并代码。它表示我们的更改是P vs N,因为当我们开始整个过程时,我们当前的提交是提交N。所以Git对比P和N,看看在我们的分支中我们改变了什么。P甚至不在我们的分支上——它只在feature1上——这并不重要。Git想要确保它可以容纳P与C之间的更改,所以它查看P与N之间的差异,以确定将P与C之间的更改放在何处。它将我们的P vs N更改与他们的P vs C更改相结合,并将这些组合更改应用于来自提交P的快照。所以整个过程就是一次合并!
当合并成功时,Git会将组合后的更改应用于P中,并生成一个新的提交C',作为一个普通的单父提交,父提交为N。这样我们就得到了想要的结果。
当合并失败时,Git会留下与任何合并一样的混乱状态。这次的"合并基础"是在提交P中的内容。"我们的"提交是我们的提交N,而"对方的"提交是他们的提交C。现在我们需要负责修复这个混乱状态。完成后,运行以下命令:
git cherry-pick --continue

最后完成樱桃挑选。2 Git然后创建提交C',我们得到了想要的结果。

顺便说一下:git revertgit cherry-pick共享大部分代码。通过交换父级和子级进行合并来实现还原。也就是说,git revert C让Git找到PCHEAD,但这次使用C作为基础,P作为"their"提交,HEAD作为我们的提交进行合并。如果你通过几个例子来操作,你会发现这样可以得到正确的结果。另一个棘手的地方在于批量挑选必须从左到右进行,从较旧的提交到较新的提交,而批量还原必须从右到左进行,从较新的提交到较旧的提交。但现在是时候继续进行变基了。


2就像对于合并的脚注1一样,我们在这里也可以使用git commit,而且在过去的恶劣时代,可能还需要这样做,尽管我认为在我使用Git时,或者至少是使用Git的拣选功能时,Git称之为顺序器的东西已经存在,并且git cherry-pick --continue起作用。


rebase工作原理

rebase命令非常复杂,有很多选项,我们在这里不会详细介绍所有内容。我们将部分回顾Mark Adelsberger在他的回答中提到的内容,当我正在输入这些文字时。

让我们回到我们简单的合并设置:

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

如果我们运行的是git rebase branch2而不是git merge branch2,Git将会:
  1. 列出从HEAD/branch1可达但从branch2不可达的提交(哈希ID)。这些提交仅存在于branch1上。在我们的例子中,这是提交JI

  2. 确保列表按照"拓扑"顺序排列,即先I,然后J。也就是说,我们希望从左到右工作,这样我们总是在早期副本之上添加后来的副本。

  3. 从列表中删除任何因某种原因而不应被复制的提交。这很复杂,但我们假设没有提交被删除:这是一个非常常见的情况。

  4. 使用Git的"分离HEAD"模式开始挑选。这相当于运行git switch --detach branch2

我们还没有提到分离 HEAD 模式。当处于分离 HEAD 模式时,特殊名称 HEAD 不再指向一个分支名称,而是直接指向一个提交哈希值。我们可以用下面的方式表示这个状态:

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

提交L现在是当前提交,但没有当前分支名称。这就是Git所谓的"分离的HEAD"的含义。在这种模式下,当我们进行新的提交时,HEAD将直接指向这些新的提交。

接下来,Git将对其列表中仍存在的每个提交运行相当于git cherry-pick的操作,在排除步骤之后。在这里,按顺序是提交IJ的实际哈希ID。因此,我们首先运行git cherry-pick 哈希值-I。如果一切顺利,我们会得到:

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

在拷贝过程中,这里的“base”是提交 HI的父提交),“their”提交是我们的提交 I,而“our”提交是他们的提交 L。请注意,在这一点上,ourstheirs的概念似乎互换了位置。如果存在合并冲突(因为这确实是一个合并操作),ours提交将变成他们的,而theirs提交将变成我们的!

如果一切顺利,或者您已经修复了任何问题并使用 git rebase --continue 继续合并,现在我们有了 I',然后开始拷贝提交 J。这个拷贝的最终目标是:

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

如果出现问题,你将会遇到合并冲突。这次的基础提交将是I(我们其中之一),而theirs提交将是J(仍然是我们其中之一)。真正令人困惑的部分是ours提交将是提交I':就是我们刚刚做的那个! 如果要复制更多的提交,这个过程将重复进行。每次复制都有可能发生合并冲突。实际发生的冲突数量取决于各个提交的内容,以及在解决某个较早提交的冲突时,是否会在挑选较晚提交时设置冲突。(我曾经遇到过每个被复制的提交都有相同冲突的情况,一遍又一遍。使用git rerere在这里非常有帮助,尽管有时候有点可怕。)
一旦所有的复制工作完成,git rebase 的工作方式是将分支名称从原来的分支尖端提交中拔出,并粘贴到当前命名为HEAD的提交上。
          I--J   ???
         /
...--G--H
         \
          K--L   <-- branch2
              \
               I'-J'  <-- HEAD, branch1

现在很难找到旧的提交。它们仍然存在于您的代码库中,但是如果您没有其他能让您找到它们的“名称”,它们似乎已经消失了!在将控制权交还给您之前,git rebase重新附加了HEAD。
          I--J   ???
         /
...--G--H
         \
          K--L   <-- branch2
              \
               I'-J'  <-- branch1 (HEAD)

这样git status会再次显示on branch branch1。运行git log,你会看到与你原来的提交具有相同日志信息的提交。看起来好像Git已经以某种方式移植了这些提交。但实际上并不是如此:它只是创建了副本。原始提交仍然存在。副本就是那些被变基的提交,它们构成了变基后的分支,以人类的思维方式来理解分支(尽管Git并不是这样做:Git使用哈希ID,而这些哈希ID显然是不同的)。

结论

底线是,git merge合并。这意味着:通过合并工作创建一个新的提交,并将该新提交与现有的提交链关联起来。而git rebase则是复制提交。这意味着:通过复制旧的提交创建许多新的提交;新的提交存在于提交图中的其他位置,并具有新的快照,但会重用旧的提交的作者名称、作者日期和提交消息;一旦复制完成,就会将分支名称从旧的提交上移除,并粘贴到新的提交上,放弃旧的提交,转而使用新的改进后的提交。
当人们说rebase“重写历史”时,他们指的是这种“放弃”。在Git存储库中,历史就是存储库中的提交。它们通过哈希ID进行编号,如果两个Git存储库具有相同的提交,则它们具有相同的历史。因此,当您将旧的提交复制到新的改进后的提交中,并放弃旧的提交时,您需要说服“其他”Git存储库也放弃那些旧的提交,转而使用新的提交。
那就是说,通过他们的Git仓库来说服其他用户可能很容易,也可能很困难。如果他们一开始就都理解这一点,并且事先同意这样做,那么这将变得很容易。然而,合并操作并不会舍弃旧的历史记录以换取新的改进历史记录:它只是添加了一个引用旧历史记录的新历史记录。Git可以轻松地添加新的历史记录:毕竟,这就是Git的构建方式。

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