在将分支合并时,与将分支变基有什么不同?为什么会这样?
当进行合并时,合并的更改存储在合并提交本身中(具有两个父级的提交)。 但是,在进行变基时,合并存储在哪里?
谢谢, Omer
在查看torek的答案之后,再次阅读问题,我更新以澄清一些问题...
- 在将分支合并到分支或将分支变基时,冲突数量之间有什么区别吗?为什么会这样?
可能会有很多原因导致冲突数量不同。最简单的是,合并过程只查看三个提交 --“ours”、“theirs”和合并基础。所有中间状态都被忽略了。相比之下,变基将每个提交转换为补丁,并逐个应用。所以如果第3个提交创建了一个冲突,但第4个提交取消了它,那么变基将看到冲突而合并不会。
另一个区别是当提交已在合并的两侧进行了cherry-pick或其他复制时。在这种情况下,rebase
通常会跳过它们,而在合并中可能会引起冲突。
还有其他原因;归根结底它们只是不同的过程,尽管它们通常预期产生相同的组合内容。
- 在合并时,合并更改存储在合并提交本身(具有两个父级的提交)中。但在变基时,合并存储在哪里?
合并结果存储在rebase创建的新提交中。默认情况下,变基为每个被变基的提交编写一个新的提交。
如torek在他的答案中所解释的那样,问题可能表明了对合并存储内容的错误理解。问题可以被理解为断言导致合并结果的改变集(“补丁”)在合并中是明确存储的;它们不是。合并 - 就像任何提交一样 - 是内容的快照。使用其父指针,您可以找出应用了哪些补丁。在变基的情况下,git不会显式保留有关原始分支点,有关哪些提交位于哪个分支以及它们在何处被重新集成的信息;因此,每个提交的更改都在该提交与其父提交之间的关系中得到保留,但除了存储在仓库中的附加知识外,没有一般方法来重构与相应合并相关联的两个补丁。
例如,假设您有
O -- A -- B -- C <--(master)
\
D -- ~D -- E -- B' -- F <--(feature)
如果 D
和 master
的更改发生冲突,~D
将撤销 D
,B'
是将 B
樱桃拣选到 feature
中的结果。
现在,如果你将 feature
合并到 master
中,合并只会查看 (1) F
和 O
之间的区别,以及 (2) C
和 O
之间的区别。它不会“看到”来自 D
的冲突,因为 ~D
反转了冲突的更改。它会看到 B
和 B'
都更改了相同的行;由于双方都做出了相同的更改,它可能能够自动解决这个问题,但根据其他提交中发生的情况,这里存在潜在的冲突。
但一旦解决了任何冲突,你最终获得的是:
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
相同。)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不保留的信息才能知道O
,C
和F'
是相关提交。F
是不可访问的。它仍然存在,并且您可以在reflog中找到它,但是除非还有其他东西指向它,否则gc
最终可能会销毁它。feature
重演到master
不会推进master
。您可以git checkout master
git merge feature
master
分支合并到 feature
分支以完成分支的整合。A -- B -- C <-- master
\
F <-- feature
如果你将功能合并到主分支中,Git会查找最近发生分歧的功能和主分支的提交。这是B。在我们的合并逻辑中,它是“较早的提交”——即合并基础。因此,Git使用B与C进行差异比较,并使用B与F进行差异比较,并将两个差异都应用于B以形成一个新的提交。它给该提交两个父级,C和F,并移动master
指针:
A -- B - C - Z <-- master
\ /
\ /
F <-- feature
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
每个小错误在合并时都会被放大,所以我们需要非常详细。为了更好地理解rebase,我们可以将其拆分一下,因为rebase本质上是一系列重复的cherry-pick操作,再加上一些其他内容。所以我们将在上面添加“cherry-pick的工作原理”。
让我们从这里开始:每个提交都有一个编号。但是,提交的编号不是简单的计数数字,我们没有#1、#2、#3等连续的编号。相反,每个提交都有一个唯一但看起来随机的哈希ID。这是一个非常大的数字(目前为160位长),用十六进制表示。Git通过对每个提交的内容进行密码校验和来生成每个编号。
这是使Git作为分布式版本控制系统(DVCS)发挥作用的关键:像Subversion这样的集中式版本控制系统可以为每个修订版本分配一个简单的计数编号,因为实际上有一个中央机构分发这些编号。如果此刻无法连接到中央机构,您也无法进行新的提交。所以在SVN中,只有在中央服务器可用时才能提交。而在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 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 main
或git switch main
。如果我们这样做,我们将得到:
I <-- dev
/
...--F--G--H <-- main (HEAD)
我们的当前提交现在是提交H
,因为我们的当前名称是main
,而main
指向H
。Git将所有的提交-I
文件从我们的工作树中取出,并将所有的提交-H
文件放入我们的工作树中。
(附注:请注意,工作树文件本身不在Git中。Git只是将Git化的、已提交的文件从提交中复制到我们的工作树中。这是checkout
或switch
的一部分操作:我们选择某个提交,通常通过某个分支名称,让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
会执行其主要技巧之一。
记住,合并的目标是合并变更。然而,提交J
和L
并没有变更。它们只有快照。从某个快照获取变更的唯一方法是找到其他提交并进行比较。
J
和L
可能会有一些效果,但从实际上将两个不同的工作集合组合起来并没有太大好处。所以这不是git merge
的功能。相反,它使用提交图 - 我们用大写字母代表提交所绘制的东西 - 来找到最佳的共享提交,这些提交在两个分支上都存在。J
和L
开始,向后(向左)使用你的眼球。两个分支在哪里相遇?没错,就是在提交H
。提交G
也是共享的,但H
更接近末尾,所以显然(?)更好。因此,Git在这里选择了它。 I--J
/ \
...--G--H M <-- branch1 (HEAD)
\ /
K--L <-- branch2
M
有一个快照,如果我们运行git diff 哈希值-J 哈希值-M
,我们将看到由于他们在他们的分支上的工作而带来的变化:从H
到L
的变化被添加到我们从H
到J
的变化中。如果我们运行git diff 哈希值-L 哈希值-M
,我们将看到由于我们在我们的分支上的工作而带来的变化:从H
到J
的变化被添加到他们从H
到L
的变化中。当然,如果合并在制作提交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
上命名了两个提交P
和C
,原因暂时不明显,但会变得明显起来。(我跳过了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)
P
中,并生成一个新的提交C'
,作为一个普通的单父提交,父提交为N
。这样我们就得到了想要的结果。P
中的内容。"我们的"提交是我们的提交N
,而"对方的"提交是他们的提交C
。现在我们需要负责修复这个混乱状态。完成后,运行以下命令:git cherry-pick --continue
最后完成樱桃挑选。2 Git然后创建提交C'
,我们得到了想要的结果。
顺便说一下:git revert
和git cherry-pick
共享大部分代码。通过交换父级和子级进行合并来实现还原。也就是说,git revert C
让Git找到P
、C
和HEAD
,但这次使用C
作为基础,P
作为"their"提交,HEAD
作为我们的提交进行合并。如果你通过几个例子来操作,你会发现这样可以得到正确的结果。另一个棘手的地方在于批量挑选必须从左到右进行,从较旧的提交到较新的提交,而批量还原必须从右到左进行,从较新的提交到较旧的提交。但现在是时候继续进行变基了。
2就像对于合并的脚注1一样,我们在这里也可以使用git commit
,而且在过去的恶劣时代,可能还需要这样做,尽管我认为在我使用Git时,或者至少是使用Git的拣选功能时,Git称之为顺序器的东西已经存在,并且git cherry-pick --continue
起作用。
rebase命令非常复杂,有很多选项,我们在这里不会详细介绍所有内容。我们将部分回顾Mark Adelsberger在他的回答中提到的内容,当我正在输入这些文字时。
让我们回到我们简单的合并设置:
I--J <-- branch1 (HEAD)
/
...--G--H
\
K--L <-- branch2
git rebase branch2
而不是git merge branch2
,Git将会:
列出从HEAD
/branch1
可达但从branch2
不可达的提交(哈希ID)。这些提交仅存在于branch1
上。在我们的例子中,这是提交J
和I
。
确保列表按照"拓扑"顺序排列,即先I
,然后J
。也就是说,我们希望从左到右工作,这样我们总是在早期副本之上添加后来的副本。
从列表中删除任何因某种原因而不应被复制的提交。这很复杂,但我们假设没有提交被删除:这是一个非常常见的情况。
使用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
的操作,在排除步骤之后。在这里,按顺序是提交I
和J
的实际哈希ID。因此,我们首先运行git cherry-pick 哈希值-I
。如果一切顺利,我们会得到:
I--J <-- branch1
/
...--G--H
\
K--L <-- branch2
\
I' <-- HEAD
在拷贝过程中,这里的“base”是提交 H
(I
的父提交),“their”提交是我们的提交 I
,而“our”提交是他们的提交 L
。请注意,在这一点上,ours
和theirs
的概念似乎互换了位置。如果存在合并冲突(因为这确实是一个合并操作),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
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
则是复制提交。这意味着:通过复制旧的提交创建许多新的提交;新的提交存在于提交图中的其他位置,并具有新的快照,但会重用旧的提交的作者名称、作者日期和提交消息;一旦复制完成,就会将分支名称从旧的提交上移除,并粘贴到新的提交上,放弃旧的提交,转而使用新的改进后的提交。