Git 变基与合并的冲突
Git conflicts in rebase vs merge
合并到分支与重新设置分支基线时冲突的数量有什么区别吗?这是为什么?
进行合并时,合并更改存储在合并提交本身(与两个父提交的提交)中。
但是当做一个变基时,合并存储在哪里?
谢谢,
奥马尔
在查看了 torek 的回答,然后又重新阅读了问题之后,我正在更新以澄清几点...
- Is there any difference between the number of conflicts when doing merge to a branch as opposed to rebase a branch? why is that?
可能,是的,原因有很多。最简单的是合并过程只看三个提交——“我们的”、“他们的”和合并基础。所有中间状态都被忽略。相比之下,在 rebase 中,每个提交都被转换为补丁并单独应用,一次一个。因此,如果第 3 次提交产生了冲突,但第 4 次提交取消了它,那么 rebase 将看到冲突,而 merge 则不会。
另一个区别是提交是否经过精心挑选或以其他方式在合并的两侧重复。在这种情况下,rebase
通常会跳过它们,而它们可能会在合并中引起冲突。
还有其他原因;归根结底,它们只是不同的过程,尽管它们通常通常会产生相同的组合内容。
- When doing a merge the merging changes are stored in the merge commit itself (the commit with the two parents). But when doing a rebase, where is the merge being stored?
合并的结果存储在变基创建的新提交中。默认情况下,rebase 为每个被 rebase 的提交写一个新的提交。
正如 torek 在他的回答中所解释的那样,这个问题可能表明对合并中存储的内容存在误解。可以阅读该问题以断言导致合并结果的更改集(“补丁”)明确存储在合并中;他们不是。合并 - 与任何提交一样 - 是内容的快照。使用它的父指针,您可以找出应用的补丁。在 rebase 的情况下,git 没有明确保留关于原始分支点的任何信息,关于哪个提交在哪个分支上,或者关于它们重新集成的位置;因此每个提交的更改都保留在该提交与其父项的关系中,但是在 rebase 之后没有通用的方法来重建将与相应合并关联的两个补丁,除非你有超出存储在 repo 中的其他知识。
例如,假设您有
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
是“我们的”合并,因此 M4
与 M3
具有相同的内容)。
所以 rebase 很像那样,除了它不跟踪 link 新提交回 feature
分支的“第二父”指针,它完全跳过 B'
。 (它也以不同的方式移动分支。)所以我们绘制
D' -- ~D' -- E' -- F' <--(feature)
/
O -- A -- B -- C <--(master)
\
D -- ~D -- E -- B' -- F
所以我们可以直观地表明 D'
“来自”D
,即使它不是合并提交,父指针显示它与 D
的关系。尽管如此,合并这些更改的结果仍然存储在那里;最终 F'
存储两个历史的完整整合。
如上所述,repo 的最终状态 (post-rebase) 中没有任何内容明确说明哪些补丁会与(大致等效的)合并相关联。您可以 git diff O C
查看其中一个,并 git diff C F'
查看另一个,但是您需要 git 不保留的信息才能知道 O
,C
和 F'
是相关的提交。
请注意,在此图片中,F
无法访问。它仍然存在,你可以在 reflog 中找到它,但除非有其他东西指向它,否则 gc
最终可能会摧毁它。
另请注意,将 feature
变基为 master
不会提升 master
。你可以
git checkout master
git merge feature
到ff master
到feature
完成分支的整合。
变基(大部分)只是一系列的挑选。 cherry-pick 和 merge 使用相同的逻辑——我称之为“合并逻辑”,文档通常称之为“三向合并”——来创建一个新的提交。
逻辑是,给定提交 X 和 Y:
从较早的提交开始。这称为合并基础。
在较早的提交和 X 之间进行区分。
在较早的提交和 Y 之间进行区分。
将两个差异应用到较早的提交,并且:
一个。如果您可以 做到这一点,请进行新的提交以表达结果。
b。如果你做不到,抱怨你有冲突。
在这方面,merge 和 cherry-pick(因此也是 merge 和 rebase)几乎 是同一件事,但有一些不同。一个特别重要的区别是“3”在“3 路合并”的逻辑中是谁。特别是,他们可以对第一步(合并基础)中的“早期提交”有不同的看法。
让我们先来看一个退化的例子,其中 merge 和 cherry-pick 几乎相同:
A -- B -- C <-- master
\
F <-- feature
如果您将 功能合并到主控中,Git 查找功能和主控最近发生分歧的提交。那是 B。它是我们合并逻辑中的“早期提交”——合并基础。因此 Git 将 C 与 B 进行比较,将 F 与 B 进行比较,并将 both diffs 应用于 B 以形成新的提交。它给出了提交的两个父项,C 和 F,并移动了 master
指针:
A -- B - C - Z <-- master
\ /
\ /
F <-- feature
如果你 cherry-pick 特征到 master 上,Git 寻找特征的 parent,意思是父F、又是B! (那是因为我故意选择了这种退化的情况。)这就是我们合并逻辑中的“早期提交”。因此,再次 Git 比较 C 和 B,比较 F 和 B,并将两个差异应用到 B 以形成新的提交。现在它给那个提交 one parent,C,并移动 master
指针:
A -- B - C - F' <-- master
\
F <-- feature
如果您 rebase feature 到 master 上,git 会挑选 each commit on feature 并移动feature
指针。在我们退化的情况下,只有一个功能提交:
A -- B - C <-- master
\ \
\ F' <-- feature
F
现在,在那些图中,碰巧作为合并基础的“早期提交”在每种情况下都是相同的:B。所以合并逻辑是相同的,所以发生冲突的可能性是在每个图中都一样。
但如果我在功能上引入更多提交,情况就会发生变化:
A -- B -- C <-- master
\
F -- G <-- feature
现在,将特征变基到 master 意味着将 F 挑选到 C 上(给出 F'),然后将 G 挑选到 C 上(给出 G')。对于第二个 cherry-pick,Git 使用 F 作为“早期提交”(合并基础),因为它是 G 的父级。这引入了我们之前没有考虑过的情况。特别是,合并逻辑将涉及从 F 到 F' 的差异,以及从 F 到 G 的差异。
因此,当我们变基时,我们会沿变基分支迭代地挑选每个提交,并且在每次迭代中,我们的合并逻辑中比较的三个提交是不同的。很明显,我们为合并冲突引入了新的可能性,因为实际上,我们正在进行更多不同的合并。
- Is there any difference between the number of conflicts when doing merge to a branch as opposed to rebase a branch? why is that?
动词是,我认为,这里有点过头了。如果我们把它改成can there be,答案肯定是肯定的。原因很简单:rebase 和 merge 是根本不同的操作。
- When doing a merge the merging changes are stored in the merge commit itself (the commit with the two parents). But when doing a rebase, where is the merge being stored?
这个问题预设了一些事实并非如此,尽管在某些方面是次要的。不过,为了解释发生了什么,它不再是次要的。
具体来说,要理解所有这些,我们需要知道:
- 确切地(或至少相当详细)什么是提交;
- 分支名称的工作原理;
- 合并的工作原理,reasonably-exactly;和
- rebase 的工作原理,reasonably-exactly。
当我们合并它们时,其中每一个的任何小错误都会被放大,所以我们需要非常详细。将 rebase 分解一下会有所帮助,因为 rebase 本质上是一系列重复的 cherry-pick 操作,还有一些周围的东西。所以我们将在上面添加“cherry-pick 的工作原理”。
提交已编号
让我们从这个开始:每个提交都是编号。但是,提交上的数字不是一个简单的计数:我们没有提交 #1,然后是 #2,然后是 #3,依此类推。相反,每个提交都会获得一个唯一但 random-looking 哈希 ID。这是一个以十六进制表示的非常大的数字(目前长 160 位)。 Git 通过对每个提交的内容进行加密校验和来形成每个数字。
这是使 Git 作为 分布式 版本控制系统 (DVCS) 工作的关键:像 Subversion 这样的集中式 VCS can 给每个修订版一个简单的计数,因为实际上有一个中央机构分发这些数字。如果你现在无法联系到中央权威,你也无法进行新的提交。所以在SVN中,你只能在中央服务器可用时才能提交。在 Git 中,您可以随时在本地提交:没有指定的中央服务器(当然您可以选择任何 Git 服务器并 调用 它“中央服务器”。
当我们将两个 Git 相互连接时,这一点最为重要。他们将对 bit-for-bit 相同的任何提交使用 same 编号,对不相同的任何提交使用 different 编号。这就是他们如何确定他们是否有相同的提交;这就是发送方 Git 可以发送到接收方 Git 的方式,发送方和接收方同意接收方需要并且发送方希望接收方拥有的任何提交,同时仍然最小化数据传输。 (除了 只是 这个,还有更多内容,但编号方案是它的核心。)
既然我们知道提交是有编号的——并且,基于编号系统,任何提交的任何部分都不能更改,一旦提交,因为这只是结果在一个新的 不同的 提交中使用不同的数字——我们可以看看每个提交 在 中实际是什么。
提交存储快照和元数据
每个提交有两个部分:
一个提交有每个文件的完整快照Git,在你或任何人做出那个提交的时候。快照中的文件以特殊的 read-only、Git-only、压缩和 de-duplicated 格式存储。 de-duplication 意味着如果有数千个提交都具有某些文件的 相同 副本,则不会受到惩罚:这些提交全部 share 那个文件。由于大多数新提交大多与一些或大多数较早的提交具有相同文件的相同版本,因此即使每次提交都有每个文件,存储库也不会真正增长太多。
除了文件,每个提交存储一些元数据,或者关于提交本身的信息。这包括提交的作者和一些 date-and-time-stamps。它包括一条日志消息,您可以在其中向自己解释 and/or 其他人 为什么 您进行了此特定提交。并且——Git 操作的关键,而不是你自己管理的东西——每个提交存储一些 previous[ 的提交编号或哈希 ID =570=] 提交或提交。
大多数提交只存储一个以前的提交。此先前提交哈希 ID 的目标是列出新提交的 parent 或 parents。这就是 Git 可以找出 更改的内容 的方式,即使每个提交都有一个快照。通过查找上一次提交,Git可以获得上一次提交的快照。 Git 然后可以比较两个快照。 de-duplication 使这比其他方式更容易。任何时候这两个快照都有 相同的 文件,Git 对此什么也不能说。 Git 只需com是两个文件中实际上 不同 的文件。 Git 使用差异引擎找出哪些更改将采用旧的(或 left-hand-side)文件并将其转换为较新的(right-hand-side)文件,并向您展示这些差异。
您可以使用相同的差异引擎来比较任何 两个提交或文件:只需给它一个左侧和右侧文件进行比较,或者一个左侧和右侧提交。 Git 将玩 Spot the Difference 游戏并告诉您发生了什么变化。这对我们以后很重要。不过现在,对于任何简单的 one-parent-one-child 提交对,只需比较 parent 和 child,就会告诉我们该提交中 发生了什么变化。
对于一个 child 向后指向一个 parent 的简单提交,我们可以得出这种关系。如果我们使用单个大写字母代表散列 ID——因为真正的散列 ID 对人类来说太大太难看——我们得到一张看起来像这样的图片:
... <-F <-G <-H
在这里,H
代表链中的 last 提交。它向后指向较早的提交 G
。两次提交都有快照和 parent 哈希 ID。所以提交 G
向后指向 its parent F
。提交 F
有一个快照和元数据,因此向后指向另一个提交。
如果我们让 Git 从末尾开始,并且一次只向后一次提交,我们可以让 Git 一直回到第一个提交。 first 提交不会有一个 backwards-pointing 箭头出来,因为它不能,这会让 Git(和我们)停止并且休息。例如,这就是 git log
所做的(至少对于 git log
的最简单情况)。
但是,我们确实需要一种方法来找到 last 提交。这就是分支名称的用武之地。
一个分支名称指向一个提交
A Git 分支名称包含 one 提交的哈希 ID。根据定义,无论哈希 ID 存储在 中 该分支名称,都是链的末尾 对于该分支 。该链条可能会继续运行,但由于 Git 向 向后 工作,这就是 那个分支 .
的结尾
这意味着如果我们有一个只有一个分支的存储库——我们称它为 main
,就像 GitHub 现在所做的那样——有一些 last commit 及其哈希 ID 在名称 main
中。让我们画一下:
...--F--G--H <-- main
我变懒了,不再从提交 中绘制箭头作为 箭头。这也是因为我们即将遇到 arrow-drawing 问题(至少在字体可能受限的 Whosebug 上)。请注意,这与我们刚才的图片相同;我们刚刚弄清楚 如何 我们记住提交的哈希 ID H
:通过将其粘贴到分支名称中。
让我们添加一个新分支。分支名称必须包含某个提交的哈希 ID。我们应该使用哪个提交?让我们使用 H
:这是我们现在使用的提交,它是最新的,所以在这里很有意义。让我们画出结果:
...--F--G--H <-- dev, main
两个分支名称都选择 H
作为他们的“最后”提交。因此,包括 H
在内的所有提交都在 两个分支 上。我们还需要一件事:一种记住我们正在使用的 name 的方法。让我们添加特殊名称 HEAD
,并将其写在一个分支名称之后,在 parentheses 中,以记住我们使用的是哪个 name:
...--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
做任何事情。我们添加了一个新提交,这个新提交应该是 last 分支 dev
上的提交。为了实现这一点,Git 只需将 I
的哈希 ID 写入 name dev
,Git 知道这是正确的名称,因为这是 HEAD
附加到的名称:
I <-- dev (HEAD)
/
...--F--G--H <-- main
我们得到了我们想要的:last main
上的提交仍然是 H
但 last dev
上的提交现在是 I
。通过 H
的提交仍在两个分支上;提交 I
仅在 dev
.
我们cn 添加更多分支名称,指向这些提交中的任何一个。或者,我们现在可以 运行 git checkout main
或 git switch main
。如果我们这样做,我们得到:
I <-- dev
/
...--F--G--H <-- main (HEAD)
我们的当前提交现在是提交H
,因为我们的当前名称是main
,并且main
指向 H
。 Git 将所有提交-I
文件从我们的工作树中取出,并将所有提交-H
文件放入我们的工作树中。
(旁注:请注意,工作树文件不在 Git 本身。Git 只是 复制 Git-ified,已提交files from 提交,to 我们的工作树,在这里。这是 checkout
或 switch
操作的一部分: 我们选择一些提交,通常是通过一些分支名称,然后 Git 从我们 正在 处理的提交中删除文件,然后放入所选提交的文件。有这里面隐藏了很多奇特的机制,但我们将在这里忽略所有这些。)
我们现在准备好继续 git merge
。重要的是要注意 git merge
并不总是进行任何实际的合并。下面的描述将从 需要 真正合并的设置开始,因此,运行宁 git merge
将进行真正的合并。真正的合并可能有合并冲突。 git merge
做的其他事情——so-called fast-forward merge,这根本不是真正的合并,它只是说不,什么也不做——实际上不能有合并冲突。
真正的合并是如何工作的
假设此时,在我们的 Git 存储库中,我们将这两个分支排列如下:
I--J <-- branch1 (HEAD)
/
...--G--H
\
K--L <-- branch2
(可能有一个指向 H
或其他提交的分支名称,但我们不会费心将其引入,因为它对我们的合并过程无关紧要。)我们是“ on" branch1
,正如您从图中看到的那样,我们现在已经提交 L
签出。我们运行:
git merge branch2
Git 现在将找到提交 J
,这很简单:这就是我们所坐的那个。 Git 还将使用名称 branch2
定位提交 L
。这很容易,因为名称 branch2
中包含提交 L
的原始哈希 ID。但是现在 git merge
开始了它的第一个主要技巧。
请记住,合并的目标 是合并更改。提交 J
和 L
没有 有 改变 。他们有 快照 。 从 某些快照中获取更改的唯一方法是查找其他提交并进行比较。
直接比较 J
和 L
可能会有所作为,但就实际组合两组不同的工作而言,它并没有多大用处。所以这不是 git merge
所做的。相反,它使用 commit graph——我们一直用大写字母代表提交的东西——来找到最好的 shared 提交在两个分支。
这个最佳共享提交实际上是一种名为 Lowest Common Ancestors of a Directed Acyclic Graph 的算法的结果,但对于像这样的简单案例,它是非常明显的。从两个分支提示提交 J
和 L
开始,并使用你的眼球向后(向左)工作。这两个分支在哪里汇合?没错,它是在 commit H
处。提交 G
也被共享,但是 H
比 G
更接近结尾,所以它显然(?)更好。所以 Git 在这里选择了它。
Git 将此共享起点称为 合并基础 。 Git 现在可以进行比较,从提交 H
到提交 J
,找出 我们 发生了什么变化。此差异将显示对某些文件的更改。另外,Git 现在可以对提交 H
和提交 L
进行比较,以确定 他们 发生了什么变化。此 diff 将显示对某些文件的一些更改:可能是完全不同的文件,或者我们都更改了相同的 文件 ,我们更改了不同的 这些文件的行。
git merge
的工作现在是合并更改。通过接受我们的更改并添加他们的——或者接受他们的更改并添加我们的,这会产生相同的结果——然后将 combined 更改应用于提交 H
、Git 可以建立一个新的 ready-to-go 快照。
当“我们的”和“他们的”更改发生冲突时,此过程失败,合并冲突。如果我们和他们都触及相同文件的 相同 行,Git 不知道使用谁的更改。我们将被迫收拾残局,然后继续合并。
关于这个 fixing-up 是如何进行的以及我们如何使更多的自动化,有很多东西需要了解,但是对于这个特定的答案,我们可以到此为止:我们要么有冲突,要么必须解决它们手动向上 运行 git merge --continue
,1 或者我们没有冲突并且 Git 将完成合并本身。合并通信得到一个新的快照——不是改变,而是一个完整的快照——然后链接回 both 提交:它的第一个 parent 像往常一样是我们当前的提交,然后它有,作为 second parent,我们说要合并的提交。所以生成的 graph 看起来像这样:
I--J
/ \
...--G--H M <-- branch1 (HEAD)
\ /
K--L <-- branch2
Merge commit M
有快照,如果我们运行 git diff <em>hash-of-J hash-of-M </em>
,我们将看到我们在 中带来的变化,因为 “他们的” 在他们的分支中工作:从 H
到 L
已添加到我们从 H
到 J
的更改中。如果我们运行 git diff <em>hash-of-L hash-of-M</em>
,我们会看到变化引入 是因为 “我们的”在我们分支的工作:从 H
到 J
的变化添加到他们从 H
到 L
。当然,如果合并因任何原因停止 在 提交 M
之前,我们可以对 M
的最终快照进行任意更改,使某些人称之为“邪恶合并”(参见 Evil merges in git?)。
(这个merge commit对于后面git log
也是有点绊脚石的,因为:
- 无法生成单个普通差异:它应该使用哪个 parent?
- 有两个parent要访问,因为我们向后遍历:它将如何访问两个? 它会访问两者吗?
这些问题及其答案相当复杂,但不适用于此 Whosebug 答案。)
接下来,在我们继续变基之前,让我们仔细看看git cherry-pick
。
1您可以 运行 git commit
而不是 git merge --continue
。这最终会做完全相同的事情。合并程序留下面包屑,git commit
找到它们并意识到它正在完成合并并实施 git merge --continue
而不是进行简单的 single-parent 合并。在过去不好的时候,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
的人已经修复或编写了。他们所做的是在 parent 提交 P
和 child 提交 C
之间进行一些更改,我们现在希望在 [=130] 上进行完全相同的更改=].
(Cherry-picking 这里通常是错误的方法,但无论如何我们还是来说明一下,因为我们需要展示 cherry-pick 是如何工作的,“正确”做起来更复杂。)
要复制提交 C
,我们只需 运行 git cherry-pick <em>hash-of-C</em>
,我们在其中找到 运行ning git log feature1
提交 C
的哈希值。如果一切顺利,我们最终会得到一个新的提交,C'
——如此命名是为了表明它是 C
的 copy,有点——继续我们当前分支的结尾:
H--P--C--J <-- feature1
/
...--G--I <-- main
\
K--L--N--C' <-- feature2 (HEAD)
但是 Git 如何实现此 cherry-pick 提交?
简单但不完全正确的解释是 Git 比较 P
和 C
中的快照以查看有人在那里更改了什么。然后 Git 对 N
中的快照做同样的事情来制作 C'
——当然 C'
的 parent(单数)是提交 N
, 不提交 P
.
但这并没有说明 cherry-pick 是如何产生合并冲突的。 real的解释比较复杂。 cherry-pick really 的工作方式是借用之前的合并代码。但是,cherry-pick 并没有找到实际的 merge base 提交,而是强制 Git 使用 commit P
作为“伪造的”合并基础。它将提交 C
设置为“他们的”提交。这样,“他们的”更改将是 P
-vs-C
。这正是我们想要添加到我们的提交中的更改 N
.
为了使这些更改顺利,cherry-pick代码继续使用合并代码。它说 our 更改是 P
vs N
,因为我们当前的提交 是 提交 N
时我们开始整个事情。因此 Git 比较 P
与 N
以查看“我们”在“我们的分支”中更改了什么。事实上 P
甚至 都不在 我们的分支上——它只在 feature1
上——我不重要。 Git 想确保它可以适应 P
-vs-C
的变化,所以它查看 P
-vs-N
的区别看看在哪里放置 P
-vs-C
变化。它结合了我们的 P
-vs-N
变化和他们的 P
-vs-C
更改,并将 组合 更改应用到来自提交 P
的快照。所以整个是一个合并!
当合并顺利进行时,Git 接受合并的更改,将它们应用于 P
中的内容,并获得提交 C'
,这是它自己正常进行的, single-parent 提交 parent N
。这让我们得到了我们想要的结果。
当合并 不 顺利时,Git 给我们留下了与任何合并完全相同的混乱。不过,这次“合并基础”是提交 P
中的内容。 “我们的”提交是我们的提交 N
,“他们的”提交是他们的提交 C
。我们现在负责收拾残局。完成后,我们 运行:
git cherry-pick --continue
完成 cherry-pick.2 Git 然后提交 C'
我们得到了我们想要的。
旁注:git revert
和 git cherry-pick
共享他们的大部分代码。通过交换 parent 和 child 进行合并来实现还原。也就是说,git revert C
有Git找到P
和C
和HEAD
,但是这次是以C
为基础进行合并, P
作为“他们的”提交,HEAD
作为我们的提交。如果您完成几个示例,您将看到这实现了正确的结果。这里另一个棘手的问题是 en-masse cherry-pick 必须“从左到右”工作,较旧的提交到较新的,而 en-masse 还原必须“从右到左”工作,较新的承诺年长。但现在是时候继续变基了。
2正如脚注 1 中的合并,我们也可以在这里使用 git commit
,在过去糟糕的日子里,可能有一段时间不得不这样做,尽管我认为在我使用 Git 或至少使用 cherry-picking 功能时,Git 调用 sequencer 的东西已经到位git cherry-pick --continue
有效。
变基如何工作
rebase 命令非常复杂,有很多选项,这里我们不会一一介绍。当我输入所有这些内容时,我们将看到的部分是 的回顾。
让我们回到简单的合并设置:
I--J <-- branch1 (HEAD)
/
...--G--H
\
K--L <-- branch2
如果我们 运行 git rebase branch2
、Git 将:
而不是 git merge branch2
列出可从 HEAD
/ branch1
访问但无法从 branch2
访问的提交(哈希 ID)。这些是 仅 在 branch1
上的提交。在我们的例子中,提交 J
和 I
.
确保列表按“拓扑”顺序排列,即首先是 I
,然后是 J
。也就是说,我们想要工作 left-to-right,以便我们总是在较早的副本之上添加较晚的副本。
将由于某种原因不应复制的任何提交从列表中剔除。这很复杂,但我们只是说没有提交被淘汰:这是一个很常见的情况。
使用Git的分离HEAD模式开始cherry-picking。这相当于 运行宁 git switch --detach branch2
.
我们还没有提到分离的 HEAD 模式。在分离 HEAD 模式下,特殊名称 HEAD
不包含 branch 名称。相反,它直接持有一个提交哈希 ID。我们可以这样画出这个状态:
I--J <-- branch1
/
...--G--H
\
K--L <-- HEAD, branch2
提交 L
现在是 当前提交 但没有当前分支名称。这就是 Git 的意思 术语“分离的 HEAD”。在这种模式下,当我们进行新的提交时,HEAD
将直接指向那些新的提交。
接下来,Git 将 运行 相当于 git cherry-pick
的每个提交,它仍然在其列表中,在 knocking-out 步骤之后。在这里,这是按顺序 I
和 J
提交的实际哈希 ID。所以我们先运行一个gitcherry-pick<em>hash-of-I</em>
。如果一切正常,我们得到:
I--J <-- branch1
/
...--G--H
\
K--L <-- branch2
\
I' <-- HEAD
在复制过程中,这里的“基础”是提交H
(parent of I
),“他们的”提交是我们的提交I
,并且“我们的”提交是他们的提交 L
。请注意 ours
和 theirs
概念此时是如何交换的。如果存在合并冲突——这可能发生,因为这个 是 合并——ours
提交将是他们的,而 theirs
提交将是我们的!
如果一切顺利,或者您已修复任何问题并使用 git rebase --continue
继续合并,我们现在有 I'
并且我们开始复制提交 J
。本次复制的最终目标是:
I--J <-- branch1
/
...--G--H
\
K--L <-- branch2
\
I'-J' <-- HEAD
如果出现问题,您将遇到合并冲突。这次 base 提交将是 I
(这是我们的一个)并且 theirs
提交将是 J
(仍然是我们的一个)。 真正令人困惑的部分是ours
提交将是提交I'
:我们刚做的,刚刚!
如果有更多的提交要复制,这个过程会重复。 每个副本都是可能发生合并冲突的地方。有多少实际冲突发生在很大程度上取决于各种提交的内容,以及您是否做了某事,在一些 较早 提交的冲突解决期间,当 cherry-picking 一个 较晚 提交时,这将引发冲突。 (我遇到过这样的情况,每次复制的提交都有相同的冲突,一遍又一遍。使用 git rerere
在这里非常有用,尽管有时有点可怕。)
完成所有复制后,git rebase
将 分支名称 从曾经是分支提示的提交中拉出来,并将其粘贴到提交中HEAD
现在姓名:
I--J ???
/
...--G--H
\
K--L <-- branch2
\
I'-J' <-- HEAD, branch1
现在很难找到旧的提交。它们仍在您的存储库中,但如果您没有另一个 name 可以让您找到它们,它们似乎已经消失了!最后,在将控制权还给您之前,git rebase
re-attaches HEAD
:
I--J ???
/
...--G--H
\
K--L <-- branch2
\
I'-J' <-- branch1 (HEAD)
所以 git status
又说 on branch branch1
。 运行 git log
,您会看到与原始提交具有相同 日志消息 的提交。似乎 Git 以某种方式移植了这些提交。它没有:它制作了 份 。原件还在。副本是基于重新设置的提交,并以人类对分支的思考方式构成重新设置的分支(尽管 Git 不是:Git 使用哈希 ID ,而这些明显不同)。
结论
可以说,底线是 git merge
合并 。这意味着:通过组合工作进行一个新的提交,并将该新提交绑定回两个现有的提交链。 但是 git rebase
copies 提交。这意味着:通过复制那些旧的提交来进行许多新的提交;新提交存在于提交图中的其他地方,并且有新的快照,但是 re-use 旧提交的作者姓名、作者日期戳和提交消息;复制完成后,将分支名称从旧提交中拉出并将其粘贴到新提交上,放弃旧提交以支持新的和改进的提交。
这个“放弃”就是人们说rebase改写历史的意思。历史,在 Git 存储库中, 是 存储库中的提交。它们按哈希 ID 编号,如果两个 Git 存储库具有相同的提交,则它们具有相同的历史记录。因此,当您将旧提交复制到 new-and-improved 时,放弃旧提交时,您需要说服 other Git 存储库也放弃那些旧提交以支持新的。
用他们的 Git 存储库说服其他用户可能很容易也可能很难。如果他们一开始就都明白这一点,那很容易 和 已经同意提前这样做。另一方面,合并不会丢弃旧历史以支持 new-and-improved 历史:它只是添加新的历史来引用旧历史。 Git 可以轻松添加 new 历史记录:毕竟 Git 就是这样构建的。
合并到分支与重新设置分支基线时冲突的数量有什么区别吗?这是为什么?
进行合并时,合并更改存储在合并提交本身(与两个父提交的提交)中。 但是当做一个变基时,合并存储在哪里?
谢谢, 奥马尔
在查看了 torek 的回答,然后又重新阅读了问题之后,我正在更新以澄清几点...
- Is there any difference between the number of conflicts when doing merge to a branch as opposed to rebase a branch? why is that?
可能,是的,原因有很多。最简单的是合并过程只看三个提交——“我们的”、“他们的”和合并基础。所有中间状态都被忽略。相比之下,在 rebase 中,每个提交都被转换为补丁并单独应用,一次一个。因此,如果第 3 次提交产生了冲突,但第 4 次提交取消了它,那么 rebase 将看到冲突,而 merge 则不会。
另一个区别是提交是否经过精心挑选或以其他方式在合并的两侧重复。在这种情况下,rebase
通常会跳过它们,而它们可能会在合并中引起冲突。
还有其他原因;归根结底,它们只是不同的过程,尽管它们通常通常会产生相同的组合内容。
- When doing a merge the merging changes are stored in the merge commit itself (the commit with the two parents). But when doing a rebase, where is the merge being stored?
合并的结果存储在变基创建的新提交中。默认情况下,rebase 为每个被 rebase 的提交写一个新的提交。
正如 torek 在他的回答中所解释的那样,这个问题可能表明对合并中存储的内容存在误解。可以阅读该问题以断言导致合并结果的更改集(“补丁”)明确存储在合并中;他们不是。合并 - 与任何提交一样 - 是内容的快照。使用它的父指针,您可以找出应用的补丁。在 rebase 的情况下,git 没有明确保留关于原始分支点的任何信息,关于哪个提交在哪个分支上,或者关于它们重新集成的位置;因此每个提交的更改都保留在该提交与其父项的关系中,但是在 rebase 之后没有通用的方法来重建将与相应合并关联的两个补丁,除非你有超出存储在 repo 中的其他知识。
例如,假设您有
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
是“我们的”合并,因此 M4
与 M3
具有相同的内容)。
所以 rebase 很像那样,除了它不跟踪 link 新提交回 feature
分支的“第二父”指针,它完全跳过 B'
。 (它也以不同的方式移动分支。)所以我们绘制
D' -- ~D' -- E' -- F' <--(feature)
/
O -- A -- B -- C <--(master)
\
D -- ~D -- E -- B' -- F
所以我们可以直观地表明 D'
“来自”D
,即使它不是合并提交,父指针显示它与 D
的关系。尽管如此,合并这些更改的结果仍然存储在那里;最终 F'
存储两个历史的完整整合。
如上所述,repo 的最终状态 (post-rebase) 中没有任何内容明确说明哪些补丁会与(大致等效的)合并相关联。您可以 git diff O C
查看其中一个,并 git diff C F'
查看另一个,但是您需要 git 不保留的信息才能知道 O
,C
和 F'
是相关的提交。
请注意,在此图片中,F
无法访问。它仍然存在,你可以在 reflog 中找到它,但除非有其他东西指向它,否则 gc
最终可能会摧毁它。
另请注意,将 feature
变基为 master
不会提升 master
。你可以
git checkout master
git merge feature
到ff master
到feature
完成分支的整合。
变基(大部分)只是一系列的挑选。 cherry-pick 和 merge 使用相同的逻辑——我称之为“合并逻辑”,文档通常称之为“三向合并”——来创建一个新的提交。
逻辑是,给定提交 X 和 Y:
从较早的提交开始。这称为合并基础。
在较早的提交和 X 之间进行区分。
在较早的提交和 Y 之间进行区分。
将两个差异应用到较早的提交,并且:
一个。如果您可以 做到这一点,请进行新的提交以表达结果。
b。如果你做不到,抱怨你有冲突。
在这方面,merge 和 cherry-pick(因此也是 merge 和 rebase)几乎 是同一件事,但有一些不同。一个特别重要的区别是“3”在“3 路合并”的逻辑中是谁。特别是,他们可以对第一步(合并基础)中的“早期提交”有不同的看法。
让我们先来看一个退化的例子,其中 merge 和 cherry-pick 几乎相同:
A -- B -- C <-- master
\
F <-- feature
如果您将 功能合并到主控中,Git 查找功能和主控最近发生分歧的提交。那是 B。它是我们合并逻辑中的“早期提交”——合并基础。因此 Git 将 C 与 B 进行比较,将 F 与 B 进行比较,并将 both diffs 应用于 B 以形成新的提交。它给出了提交的两个父项,C 和 F,并移动了 master
指针:
A -- B - C - Z <-- master
\ /
\ /
F <-- feature
如果你 cherry-pick 特征到 master 上,Git 寻找特征的 parent,意思是父F、又是B! (那是因为我故意选择了这种退化的情况。)这就是我们合并逻辑中的“早期提交”。因此,再次 Git 比较 C 和 B,比较 F 和 B,并将两个差异应用到 B 以形成新的提交。现在它给那个提交 one parent,C,并移动 master
指针:
A -- B - C - F' <-- master
\
F <-- feature
如果您 rebase feature 到 master 上,git 会挑选 each commit on feature 并移动feature
指针。在我们退化的情况下,只有一个功能提交:
A -- B - C <-- master
\ \
\ F' <-- feature
F
现在,在那些图中,碰巧作为合并基础的“早期提交”在每种情况下都是相同的:B。所以合并逻辑是相同的,所以发生冲突的可能性是在每个图中都一样。
但如果我在功能上引入更多提交,情况就会发生变化:
A -- B -- C <-- master
\
F -- G <-- feature
现在,将特征变基到 master 意味着将 F 挑选到 C 上(给出 F'),然后将 G 挑选到 C 上(给出 G')。对于第二个 cherry-pick,Git 使用 F 作为“早期提交”(合并基础),因为它是 G 的父级。这引入了我们之前没有考虑过的情况。特别是,合并逻辑将涉及从 F 到 F' 的差异,以及从 F 到 G 的差异。
因此,当我们变基时,我们会沿变基分支迭代地挑选每个提交,并且在每次迭代中,我们的合并逻辑中比较的三个提交是不同的。很明显,我们为合并冲突引入了新的可能性,因为实际上,我们正在进行更多不同的合并。
- Is there any difference between the number of conflicts when doing merge to a branch as opposed to rebase a branch? why is that?
动词是,我认为,这里有点过头了。如果我们把它改成can there be,答案肯定是肯定的。原因很简单:rebase 和 merge 是根本不同的操作。
- When doing a merge the merging changes are stored in the merge commit itself (the commit with the two parents). But when doing a rebase, where is the merge being stored?
这个问题预设了一些事实并非如此,尽管在某些方面是次要的。不过,为了解释发生了什么,它不再是次要的。
具体来说,要理解所有这些,我们需要知道:
- 确切地(或至少相当详细)什么是提交;
- 分支名称的工作原理;
- 合并的工作原理,reasonably-exactly;和
- rebase 的工作原理,reasonably-exactly。
当我们合并它们时,其中每一个的任何小错误都会被放大,所以我们需要非常详细。将 rebase 分解一下会有所帮助,因为 rebase 本质上是一系列重复的 cherry-pick 操作,还有一些周围的东西。所以我们将在上面添加“cherry-pick 的工作原理”。
提交已编号
让我们从这个开始:每个提交都是编号。但是,提交上的数字不是一个简单的计数:我们没有提交 #1,然后是 #2,然后是 #3,依此类推。相反,每个提交都会获得一个唯一但 random-looking 哈希 ID。这是一个以十六进制表示的非常大的数字(目前长 160 位)。 Git 通过对每个提交的内容进行加密校验和来形成每个数字。
这是使 Git 作为 分布式 版本控制系统 (DVCS) 工作的关键:像 Subversion 这样的集中式 VCS can 给每个修订版一个简单的计数,因为实际上有一个中央机构分发这些数字。如果你现在无法联系到中央权威,你也无法进行新的提交。所以在SVN中,你只能在中央服务器可用时才能提交。在 Git 中,您可以随时在本地提交:没有指定的中央服务器(当然您可以选择任何 Git 服务器并 调用 它“中央服务器”。
当我们将两个 Git 相互连接时,这一点最为重要。他们将对 bit-for-bit 相同的任何提交使用 same 编号,对不相同的任何提交使用 different 编号。这就是他们如何确定他们是否有相同的提交;这就是发送方 Git 可以发送到接收方 Git 的方式,发送方和接收方同意接收方需要并且发送方希望接收方拥有的任何提交,同时仍然最小化数据传输。 (除了 只是 这个,还有更多内容,但编号方案是它的核心。)
既然我们知道提交是有编号的——并且,基于编号系统,任何提交的任何部分都不能更改,一旦提交,因为这只是结果在一个新的 不同的 提交中使用不同的数字——我们可以看看每个提交 在 中实际是什么。
提交存储快照和元数据
每个提交有两个部分:
一个提交有每个文件的完整快照Git,在你或任何人做出那个提交的时候。快照中的文件以特殊的 read-only、Git-only、压缩和 de-duplicated 格式存储。 de-duplication 意味着如果有数千个提交都具有某些文件的 相同 副本,则不会受到惩罚:这些提交全部 share 那个文件。由于大多数新提交大多与一些或大多数较早的提交具有相同文件的相同版本,因此即使每次提交都有每个文件,存储库也不会真正增长太多。
除了文件,每个提交存储一些元数据,或者关于提交本身的信息。这包括提交的作者和一些 date-and-time-stamps。它包括一条日志消息,您可以在其中向自己解释 and/or 其他人 为什么 您进行了此特定提交。并且——Git 操作的关键,而不是你自己管理的东西——每个提交存储一些 previous[ 的提交编号或哈希 ID =570=] 提交或提交。
大多数提交只存储一个以前的提交。此先前提交哈希 ID 的目标是列出新提交的 parent 或 parents。这就是 Git 可以找出 更改的内容 的方式,即使每个提交都有一个快照。通过查找上一次提交,Git可以获得上一次提交的快照。 Git 然后可以比较两个快照。 de-duplication 使这比其他方式更容易。任何时候这两个快照都有 相同的 文件,Git 对此什么也不能说。 Git 只需com是两个文件中实际上 不同 的文件。 Git 使用差异引擎找出哪些更改将采用旧的(或 left-hand-side)文件并将其转换为较新的(right-hand-side)文件,并向您展示这些差异。
您可以使用相同的差异引擎来比较任何 两个提交或文件:只需给它一个左侧和右侧文件进行比较,或者一个左侧和右侧提交。 Git 将玩 Spot the Difference 游戏并告诉您发生了什么变化。这对我们以后很重要。不过现在,对于任何简单的 one-parent-one-child 提交对,只需比较 parent 和 child,就会告诉我们该提交中 发生了什么变化。
对于一个 child 向后指向一个 parent 的简单提交,我们可以得出这种关系。如果我们使用单个大写字母代表散列 ID——因为真正的散列 ID 对人类来说太大太难看——我们得到一张看起来像这样的图片:
... <-F <-G <-H
在这里,H
代表链中的 last 提交。它向后指向较早的提交 G
。两次提交都有快照和 parent 哈希 ID。所以提交 G
向后指向 its parent F
。提交 F
有一个快照和元数据,因此向后指向另一个提交。
如果我们让 Git 从末尾开始,并且一次只向后一次提交,我们可以让 Git 一直回到第一个提交。 first 提交不会有一个 backwards-pointing 箭头出来,因为它不能,这会让 Git(和我们)停止并且休息。例如,这就是 git log
所做的(至少对于 git log
的最简单情况)。
但是,我们确实需要一种方法来找到 last 提交。这就是分支名称的用武之地。
一个分支名称指向一个提交
A Git 分支名称包含 one 提交的哈希 ID。根据定义,无论哈希 ID 存储在 中 该分支名称,都是链的末尾 对于该分支 。该链条可能会继续运行,但由于 Git 向 向后 工作,这就是 那个分支 .
的结尾这意味着如果我们有一个只有一个分支的存储库——我们称它为 main
,就像 GitHub 现在所做的那样——有一些 last commit 及其哈希 ID 在名称 main
中。让我们画一下:
...--F--G--H <-- main
我变懒了,不再从提交 中绘制箭头作为 箭头。这也是因为我们即将遇到 arrow-drawing 问题(至少在字体可能受限的 Whosebug 上)。请注意,这与我们刚才的图片相同;我们刚刚弄清楚 如何 我们记住提交的哈希 ID H
:通过将其粘贴到分支名称中。
让我们添加一个新分支。分支名称必须包含某个提交的哈希 ID。我们应该使用哪个提交?让我们使用 H
:这是我们现在使用的提交,它是最新的,所以在这里很有意义。让我们画出结果:
...--F--G--H <-- dev, main
两个分支名称都选择 H
作为他们的“最后”提交。因此,包括 H
在内的所有提交都在 两个分支 上。我们还需要一件事:一种记住我们正在使用的 name 的方法。让我们添加特殊名称 HEAD
,并将其写在一个分支名称之后,在 parentheses 中,以记住我们使用的是哪个 name:
...--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
做任何事情。我们添加了一个新提交,这个新提交应该是 last 分支 dev
上的提交。为了实现这一点,Git 只需将 I
的哈希 ID 写入 name dev
,Git 知道这是正确的名称,因为这是 HEAD
附加到的名称:
I <-- dev (HEAD)
/
...--F--G--H <-- main
我们得到了我们想要的:last main
上的提交仍然是 H
但 last dev
上的提交现在是 I
。通过 H
的提交仍在两个分支上;提交 I
仅在 dev
.
我们cn 添加更多分支名称,指向这些提交中的任何一个。或者,我们现在可以 运行 git checkout main
或 git switch main
。如果我们这样做,我们得到:
I <-- dev
/
...--F--G--H <-- main (HEAD)
我们的当前提交现在是提交H
,因为我们的当前名称是main
,并且main
指向 H
。 Git 将所有提交-I
文件从我们的工作树中取出,并将所有提交-H
文件放入我们的工作树中。
(旁注:请注意,工作树文件不在 Git 本身。Git 只是 复制 Git-ified,已提交files from 提交,to 我们的工作树,在这里。这是 checkout
或 switch
操作的一部分: 我们选择一些提交,通常是通过一些分支名称,然后 Git 从我们 正在 处理的提交中删除文件,然后放入所选提交的文件。有这里面隐藏了很多奇特的机制,但我们将在这里忽略所有这些。)
我们现在准备好继续 git merge
。重要的是要注意 git merge
并不总是进行任何实际的合并。下面的描述将从 需要 真正合并的设置开始,因此,运行宁 git merge
将进行真正的合并。真正的合并可能有合并冲突。 git merge
做的其他事情——so-called fast-forward merge,这根本不是真正的合并,它只是说不,什么也不做——实际上不能有合并冲突。
真正的合并是如何工作的
假设此时,在我们的 Git 存储库中,我们将这两个分支排列如下:
I--J <-- branch1 (HEAD)
/
...--G--H
\
K--L <-- branch2
(可能有一个指向 H
或其他提交的分支名称,但我们不会费心将其引入,因为它对我们的合并过程无关紧要。)我们是“ on" branch1
,正如您从图中看到的那样,我们现在已经提交 L
签出。我们运行:
git merge branch2
Git 现在将找到提交 J
,这很简单:这就是我们所坐的那个。 Git 还将使用名称 branch2
定位提交 L
。这很容易,因为名称 branch2
中包含提交 L
的原始哈希 ID。但是现在 git merge
开始了它的第一个主要技巧。
请记住,合并的目标 是合并更改。提交 J
和 L
没有 有 改变 。他们有 快照 。 从 某些快照中获取更改的唯一方法是查找其他提交并进行比较。
直接比较 J
和 L
可能会有所作为,但就实际组合两组不同的工作而言,它并没有多大用处。所以这不是 git merge
所做的。相反,它使用 commit graph——我们一直用大写字母代表提交的东西——来找到最好的 shared 提交在两个分支。
这个最佳共享提交实际上是一种名为 Lowest Common Ancestors of a Directed Acyclic Graph 的算法的结果,但对于像这样的简单案例,它是非常明显的。从两个分支提示提交 J
和 L
开始,并使用你的眼球向后(向左)工作。这两个分支在哪里汇合?没错,它是在 commit H
处。提交 G
也被共享,但是 H
比 G
更接近结尾,所以它显然(?)更好。所以 Git 在这里选择了它。
Git 将此共享起点称为 合并基础 。 Git 现在可以进行比较,从提交 H
到提交 J
,找出 我们 发生了什么变化。此差异将显示对某些文件的更改。另外,Git 现在可以对提交 H
和提交 L
进行比较,以确定 他们 发生了什么变化。此 diff 将显示对某些文件的一些更改:可能是完全不同的文件,或者我们都更改了相同的 文件 ,我们更改了不同的 这些文件的行。
git merge
的工作现在是合并更改。通过接受我们的更改并添加他们的——或者接受他们的更改并添加我们的,这会产生相同的结果——然后将 combined 更改应用于提交 H
、Git 可以建立一个新的 ready-to-go 快照。
当“我们的”和“他们的”更改发生冲突时,此过程失败,合并冲突。如果我们和他们都触及相同文件的 相同 行,Git 不知道使用谁的更改。我们将被迫收拾残局,然后继续合并。
关于这个 fixing-up 是如何进行的以及我们如何使更多的自动化,有很多东西需要了解,但是对于这个特定的答案,我们可以到此为止:我们要么有冲突,要么必须解决它们手动向上 运行 git merge --continue
,1 或者我们没有冲突并且 Git 将完成合并本身。合并通信得到一个新的快照——不是改变,而是一个完整的快照——然后链接回 both 提交:它的第一个 parent 像往常一样是我们当前的提交,然后它有,作为 second parent,我们说要合并的提交。所以生成的 graph 看起来像这样:
I--J
/ \
...--G--H M <-- branch1 (HEAD)
\ /
K--L <-- branch2
Merge commit M
有快照,如果我们运行 git diff <em>hash-of-J hash-of-M </em>
,我们将看到我们在 中带来的变化,因为 “他们的” 在他们的分支中工作:从 H
到 L
已添加到我们从 H
到 J
的更改中。如果我们运行 git diff <em>hash-of-L hash-of-M</em>
,我们会看到变化引入 是因为 “我们的”在我们分支的工作:从 H
到 J
的变化添加到他们从 H
到 L
。当然,如果合并因任何原因停止 在 提交 M
之前,我们可以对 M
的最终快照进行任意更改,使某些人称之为“邪恶合并”(参见 Evil merges in git?)。
(这个merge commit对于后面git log
也是有点绊脚石的,因为:
- 无法生成单个普通差异:它应该使用哪个 parent?
- 有两个parent要访问,因为我们向后遍历:它将如何访问两个? 它会访问两者吗?
这些问题及其答案相当复杂,但不适用于此 Whosebug 答案。)
接下来,在我们继续变基之前,让我们仔细看看git cherry-pick
。
1您可以 运行 git commit
而不是 git merge --continue
。这最终会做完全相同的事情。合并程序留下面包屑,git commit
找到它们并意识到它正在完成合并并实施 git merge --continue
而不是进行简单的 single-parent 合并。在过去不好的时候,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
的人已经修复或编写了。他们所做的是在 parent 提交 P
和 child 提交 C
之间进行一些更改,我们现在希望在 [=130] 上进行完全相同的更改=].
(Cherry-picking 这里通常是错误的方法,但无论如何我们还是来说明一下,因为我们需要展示 cherry-pick 是如何工作的,“正确”做起来更复杂。)
要复制提交 C
,我们只需 运行 git cherry-pick <em>hash-of-C</em>
,我们在其中找到 运行ning git log feature1
提交 C
的哈希值。如果一切顺利,我们最终会得到一个新的提交,C'
——如此命名是为了表明它是 C
的 copy,有点——继续我们当前分支的结尾:
H--P--C--J <-- feature1
/
...--G--I <-- main
\
K--L--N--C' <-- feature2 (HEAD)
但是 Git 如何实现此 cherry-pick 提交?
简单但不完全正确的解释是 Git 比较 P
和 C
中的快照以查看有人在那里更改了什么。然后 Git 对 N
中的快照做同样的事情来制作 C'
——当然 C'
的 parent(单数)是提交 N
, 不提交 P
.
但这并没有说明 cherry-pick 是如何产生合并冲突的。 real的解释比较复杂。 cherry-pick really 的工作方式是借用之前的合并代码。但是,cherry-pick 并没有找到实际的 merge base 提交,而是强制 Git 使用 commit P
作为“伪造的”合并基础。它将提交 C
设置为“他们的”提交。这样,“他们的”更改将是 P
-vs-C
。这正是我们想要添加到我们的提交中的更改 N
.
为了使这些更改顺利,cherry-pick代码继续使用合并代码。它说 our 更改是 P
vs N
,因为我们当前的提交 是 提交 N
时我们开始整个事情。因此 Git 比较 P
与 N
以查看“我们”在“我们的分支”中更改了什么。事实上 P
甚至 都不在 我们的分支上——它只在 feature1
上——我不重要。 Git 想确保它可以适应 P
-vs-C
的变化,所以它查看 P
-vs-N
的区别看看在哪里放置 P
-vs-C
变化。它结合了我们的 P
-vs-N
变化和他们的 P
-vs-C
更改,并将 组合 更改应用到来自提交 P
的快照。所以整个是一个合并!
当合并顺利进行时,Git 接受合并的更改,将它们应用于 P
中的内容,并获得提交 C'
,这是它自己正常进行的, single-parent 提交 parent N
。这让我们得到了我们想要的结果。
当合并 不 顺利时,Git 给我们留下了与任何合并完全相同的混乱。不过,这次“合并基础”是提交 P
中的内容。 “我们的”提交是我们的提交 N
,“他们的”提交是他们的提交 C
。我们现在负责收拾残局。完成后,我们 运行:
git cherry-pick --continue
完成 cherry-pick.2 Git 然后提交 C'
我们得到了我们想要的。
旁注:git revert
和 git cherry-pick
共享他们的大部分代码。通过交换 parent 和 child 进行合并来实现还原。也就是说,git revert C
有Git找到P
和C
和HEAD
,但是这次是以C
为基础进行合并, P
作为“他们的”提交,HEAD
作为我们的提交。如果您完成几个示例,您将看到这实现了正确的结果。这里另一个棘手的问题是 en-masse cherry-pick 必须“从左到右”工作,较旧的提交到较新的,而 en-masse 还原必须“从右到左”工作,较新的承诺年长。但现在是时候继续变基了。
2正如脚注 1 中的合并,我们也可以在这里使用 git commit
,在过去糟糕的日子里,可能有一段时间不得不这样做,尽管我认为在我使用 Git 或至少使用 cherry-picking 功能时,Git 调用 sequencer 的东西已经到位git cherry-pick --continue
有效。
变基如何工作
rebase 命令非常复杂,有很多选项,这里我们不会一一介绍。当我输入所有这些内容时,我们将看到的部分是
让我们回到简单的合并设置:
I--J <-- branch1 (HEAD)
/
...--G--H
\
K--L <-- branch2
如果我们 运行 git rebase branch2
、Git 将:
git merge branch2
列出可从
HEAD
/branch1
访问但无法从branch2
访问的提交(哈希 ID)。这些是 仅 在branch1
上的提交。在我们的例子中,提交J
和I
.确保列表按“拓扑”顺序排列,即首先是
I
,然后是J
。也就是说,我们想要工作 left-to-right,以便我们总是在较早的副本之上添加较晚的副本。将由于某种原因不应复制的任何提交从列表中剔除。这很复杂,但我们只是说没有提交被淘汰:这是一个很常见的情况。
使用Git的分离HEAD模式开始cherry-picking。这相当于 运行宁
git switch --detach branch2
.
我们还没有提到分离的 HEAD 模式。在分离 HEAD 模式下,特殊名称 HEAD
不包含 branch 名称。相反,它直接持有一个提交哈希 ID。我们可以这样画出这个状态:
I--J <-- branch1
/
...--G--H
\
K--L <-- HEAD, branch2
提交 L
现在是 当前提交 但没有当前分支名称。这就是 Git 的意思 术语“分离的 HEAD”。在这种模式下,当我们进行新的提交时,HEAD
将直接指向那些新的提交。
接下来,Git 将 运行 相当于 git cherry-pick
的每个提交,它仍然在其列表中,在 knocking-out 步骤之后。在这里,这是按顺序 I
和 J
提交的实际哈希 ID。所以我们先运行一个gitcherry-pick<em>hash-of-I</em>
。如果一切正常,我们得到:
I--J <-- branch1
/
...--G--H
\
K--L <-- branch2
\
I' <-- HEAD
在复制过程中,这里的“基础”是提交H
(parent of I
),“他们的”提交是我们的提交I
,并且“我们的”提交是他们的提交 L
。请注意 ours
和 theirs
概念此时是如何交换的。如果存在合并冲突——这可能发生,因为这个 是 合并——ours
提交将是他们的,而 theirs
提交将是我们的!
如果一切顺利,或者您已修复任何问题并使用 git rebase --continue
继续合并,我们现在有 I'
并且我们开始复制提交 J
。本次复制的最终目标是:
I--J <-- branch1
/
...--G--H
\
K--L <-- branch2
\
I'-J' <-- HEAD
如果出现问题,您将遇到合并冲突。这次 base 提交将是 I
(这是我们的一个)并且 theirs
提交将是 J
(仍然是我们的一个)。 真正令人困惑的部分是ours
提交将是提交I'
:我们刚做的,刚刚!
如果有更多的提交要复制,这个过程会重复。 每个副本都是可能发生合并冲突的地方。有多少实际冲突发生在很大程度上取决于各种提交的内容,以及您是否做了某事,在一些 较早 提交的冲突解决期间,当 cherry-picking 一个 较晚 提交时,这将引发冲突。 (我遇到过这样的情况,每次复制的提交都有相同的冲突,一遍又一遍。使用 git rerere
在这里非常有用,尽管有时有点可怕。)
完成所有复制后,git rebase
将 分支名称 从曾经是分支提示的提交中拉出来,并将其粘贴到提交中HEAD
现在姓名:
I--J ???
/
...--G--H
\
K--L <-- branch2
\
I'-J' <-- HEAD, branch1
现在很难找到旧的提交。它们仍在您的存储库中,但如果您没有另一个 name 可以让您找到它们,它们似乎已经消失了!最后,在将控制权还给您之前,git rebase
re-attaches HEAD
:
I--J ???
/
...--G--H
\
K--L <-- branch2
\
I'-J' <-- branch1 (HEAD)
所以 git status
又说 on branch branch1
。 运行 git log
,您会看到与原始提交具有相同 日志消息 的提交。似乎 Git 以某种方式移植了这些提交。它没有:它制作了 份 。原件还在。副本是基于重新设置的提交,并以人类对分支的思考方式构成重新设置的分支(尽管 Git 不是:Git 使用哈希 ID ,而这些明显不同)。
结论
可以说,底线是 git merge
合并 。这意味着:通过组合工作进行一个新的提交,并将该新提交绑定回两个现有的提交链。 但是 git rebase
copies 提交。这意味着:通过复制那些旧的提交来进行许多新的提交;新提交存在于提交图中的其他地方,并且有新的快照,但是 re-use 旧提交的作者姓名、作者日期戳和提交消息;复制完成后,将分支名称从旧提交中拉出并将其粘贴到新提交上,放弃旧提交以支持新的和改进的提交。
这个“放弃”就是人们说rebase改写历史的意思。历史,在 Git 存储库中, 是 存储库中的提交。它们按哈希 ID 编号,如果两个 Git 存储库具有相同的提交,则它们具有相同的历史记录。因此,当您将旧提交复制到 new-and-improved 时,放弃旧提交时,您需要说服 other Git 存储库也放弃那些旧提交以支持新的。
用他们的 Git 存储库说服其他用户可能很容易也可能很难。如果他们一开始就都明白这一点,那很容易 和 已经同意提前这样做。另一方面,合并不会丢弃旧历史以支持 new-and-improved 历史:它只是添加新的历史来引用旧历史。 Git 可以轻松添加 new 历史记录:毕竟 Git 就是这样构建的。