这很长,所以请随意跳过您已经知道的部分(或一直滚动到最后)。每个部分都有设置信息来解释正在发生的事情,或者我们正在做什么,在后面的部分。
介绍-y位
让我首先以我喜欢的方式重新绘制这个图(我认为这是一个部分图,但它包含我们需要的关键提交):
S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5 <-- branch-S
\ \ / /
T0-------------o----M2---M3--------R2 <---- branch-T1
\ \ /
F0--fc1---fc2---M1 <------------------- branch-F
这里,分支名称是 branch-S、branch-T1 和 branch-F,这些名称目前用于识别其哈希 ID 无法发音且人类无法记住的提交,但我们分别调用sc5、R2 和M1。任何o 节点都是没有以任何方式特别区分的提交,实际上可能代表任意数量的提交。命名的fc<number>s 是功能分支上的一组提交,M<number> 提交是合并的。我将第一个提交重命名为 S0、T0 和 F0,只是为了将它们与分支名称区分开来。
一些合并是手动进行的:
$ git checkout <branch-name>
$ git merge [options] <other-branch>
... fix up conflicts if necessary, and git commit (or git merge --continue)
其他合并是由软件进行的,只有在没有冲突的情况下才会发生。 R 提交来自运行:
git checkout <branch>
git revert -m 1 <hash ID of some M commit>
其中<branch> 是T1 或S,而-m 1 是因为您总是必须告诉git revert 在恢复合并时使用哪个父级,并且它几乎总是父级#1。
提交会移动一个分支名称
最简单的 Git 提交图是一条直线,只有一个分支名称,通常是 master:
A--B--C <-- master (HEAD)
这里,我们需要提到Git的index。最好将索引描述为 Git 构建 next 提交的地方。它最初包含保存在当前提交中的每个文件(此处为C):您检查此提交,使用来自提交C 的文件填充索引和工作树。名称master 指向此提交,名称HEAD 附加到名称master。
然后修改工作树中的文件,使用git add 将它们复制回索引,如果需要,使用git add 将新 文件复制到索引中,然后运行@987654354 @。通过将这些索引副本冻结到快照中来进行新的提交。然后,Git 添加快照元数据(您的姓名和电子邮件、您的日志消息等)以及当前提交的哈希 ID,以便新提交指向现有提交。结果是:
A--B--C <-- master (HEAD)
\
D
有了新的提交,有了新的唯一哈希 ID,就在半空中闲逛,没有什么可记住的。因此,进行新提交的最后步骤是将新提交的哈希 ID 写入分支名称:
A--B--C--D <-- master (HEAD)
现在当前提交是D,并且索引和当前提交匹配。如果你git add-ed 工作树中的所有文件,那也匹配当前提交和索引。如果没有,您可以git add 更多文件并再次提交,使名称master 指向新提交E,依此类推。在任何情况下,新提交的(单个)父级是当前提交是的。
关于合并
让我概述一下git merge 的实际工作原理。在某些情况和某些方面非常简单,让我们从最简单的 true-merge 案例开始。考虑如下图:
o--...--L <-- mainline (HEAD)
/
...--o--*
\
o--...--R <-- feature
我们已经运行了git checkout mainline; git merge feature,所以我们告诉Git 将分支feature/提交R 合并到分支mainline/提交L。为此,Git 必须首先找到 merge base 提交。粗略地说,合并基础是两个分支共同的“最近”提交,即可从访问。在这个简单的例子中,我们从L 开始并向后走到较旧的提交,并从R 开始并向后走,我们遇到的第一个地方是提交*,所以这就是合并基础。
(有关可达性的更多信息,请参阅Think Like (a) Git。)
找到合并基础后,Git 需要将L(左侧/本地/--ours)和R(右侧/远程/--theirs)快照转换为变更集。这些变更集告诉 Git 自合并基础 * 以来我们在 mainline 上做了什么,以及自合并基础以来它们在 feature 上做了什么。这三个提交都有哈希ID,是三个提交的真实名称,所以Git可以在内部运行相当于:
git diff --find-renames <hash-of-*> <hash-of-L> # what we changed
git diff --find-renames <hash-of-*> <hash-of-R> # what they changed
合并只是合并两组更改,并将合并后的设置应用于* 中的快照中的文件。
如果一切顺利,Git 会以通常的方式进行新提交,除了新提交有 两个 父级。这使得当前分支指向新的合并提交:
o--...--L
/ \
...--o--* M <-- mainline (HEAD)
\ /
o--...--R <-- feature
M 的第一个父级是L,第二个是R。这就是为什么 reverts 几乎总是使用 parent #1,以及为什么 git log --first-parent 只“看到”主线分支,从 M 遍历到 L 而完全忽略 R 分支。 (注意这里的branch这个词指的是图的结构,而不是像feature这样的分支names:此时我们可以删除name 完全是feature。另见What exactly do we mean by "branch"?)
当出现问题时
如果两个变更集以“糟糕的方式”重叠,则合并将停止,合并冲突。特别是,假设 base-vs-L 说要更改文件 F 的第 75 行,而 base-vs-R also 说要更改文件 F 的第 75 行。如果两个更改集都说要进行 same 更改,Git 可以这样做:这两个更改的组合是进行一次更改。但是,如果他们说要进行 不同 更改,Git 就会声明合并冲突。在这种情况下,Git 会在自己能做的一切之后停下来,让你收拾残局。
由于有三个输入,Git 将在此时将文件F 的所有三个 版本保留在索引中。通常,索引对每个要提交的文件都有一个副本,但在此冲突解决阶段,它最多有三个副本。 (“up to”部分是因为你可能有其他类型的冲突,由于篇幅原因我不会在这里讨论。)同时,在文件F的work-tree副本中, Git 将其近似值保留为合并,工作树文件中的两组或全部三组行带有 <<<<<<< / >>>>>>> 标记。 (要获得所有三个,请将merge.conflictStyle 设置为diff3。我更喜欢这种模式来解决冲突。)
如您所见,您可以以任何您喜欢的方式解决这些冲突。 Git 假定无论您做什么都是解决问题的正确方法:这会产生完全正确的最终合并文件,或者在某些情况下会缺少文件。
但是,无论您做什么,最终的合并(假设您没有中止它,并且没有使用合并的非合并变体之一)仍然会在图表中产生相同的结果,并且无论您放置什么在索引中,通过解决冲突,是合并的结果。这是合并提交中的新快照。
更复杂的合并基础
当图形像上面那样非常简单时,合并基很容易看到。但是图表并不简单,你的也不是。包含一些合并的图的合并基础比较棘手。例如,考虑以下片段:
...--sc4----M4---R1
\ /
...--M2---M3--------R2
如果R1 和R2 是两个提示提交,它们的合并基础是什么?答案是M3,而不是sc4。原因是虽然M3 和sc4 都是从R1 和R2 开始并向后工作的提交,但M3 更“接近”R2(后退一步)。从R1 到M3 或sc4 的距离是两跳——跳到M4,然后再往后退一步——但是从R2 到M3 的距离是一跳,从R2 到 sc4 是两跳。所以M3 是“较低的”(在图表中),因此赢得了比赛。
(幸运的是,您的图表没有出现平局的情况。如果有 平局,Git 的默认方法是合并所有已绑定的提交,一次两个,以产生一个 "虚拟合并基础”,它实际上是一个实际的(尽管是临时的)提交。然后它使用通过合并合并基础所做的临时提交。这就是 recursive 策略,它的名字来源于事实Git 递归地合并合并基以获得合并基。您可以选择 resolve 策略,它只是简单地选择一个看似随机的基 - 无论哪个基在算法前面弹出. 几乎没有任何优势:递归方法通常要么做同样的事情,要么是对随机选择获胜者的改进。)
这里的关键点是进行合并提交更改,提交未来合并将选择作为它们的合并基础。即使在进行简单的合并时,这一点也很重要,这就是为什么我用粗体字表示的原因。这就是我们进行合并提交的原因,而不是不是合并的 squash-“合并”操作。 (但 squash 合并仍然有用,我们稍后会看到。)
介绍问题:出了什么问题(这样你以后可以避免它)
解决了上述问题,现在我们可以看看真正的问题了。让我们从这个开始(稍微编辑以使用更新的提交和分支名称):
我将branch-T1 合并到branch-F (M1),然后将branch-F 合并到branch-T1 (M2)。
我在这里假设合并fc2(作为branch-F 的then-tip)和o(作为branch-T1 的then-tip)进展顺利,并且Git 能够使M1 on它自己的。正如我们之前看到的,合并实际上不是基于分支,而是基于commits。它是创建一个调整分支名称的新提交。所以这创建了M1,所以branch-F 指向M1。 M1 本身指向 branch-T1 的现有提示——我现在将 o 标记为它的第二个父提交,fc2 作为它的第一个父提交。 Git 通过 git diff 计算出正确的 内容 提交,将合并基础 T0 的内容与 o 和 fc2 进行对比:
T0-------------o <-- branch-T1
\
F0--fc1---fc2 <--- branch-F (HEAD)
一切顺利,Git 现在可以自己生成M1:
T0-------------o <-- branch-T1
\ \
F0--fc1---fc2---M1 <--- branch-F (HEAD)
现在你 git checkout branch-T1 和 git merge --no-ff branch-F (没有 --no-ff Git 只会快进,这不是图片中的内容),所以 Git 找到了 o 和 M1 的合并基础,即o 本身。这种合并很简单:从o 到o 的区别是什么,加上从o 到M1 的区别等于M1 的内容。所以M2,作为一个快照,和M1是一模一样的,Git很容易就创建了:
T0-------------o----M2 <-- branch-T1 (HEAD)
\ \ /
F0--fc1---fc2---M1 <--- branch-F
到目前为止,一切都很好,但现在事情开始变得非常糟糕:
T1 分支中有一个文件与 S 存在合并冲突...鉴于我过去遇到的问题,合并冲突解决方案的行为不符合我的预期,我想我会尝试一些新的东西:只将有冲突的文件从S 合并到T1,解决那里的合并冲突,从合并中删除所有其他文件,然后允许持续集成将所有内容合并到S。
所以,此时你所做的是:
git checkout branch-T1
git merge branch-S
由于合并冲突而停止。此时的图表与上面的图表相同,但包含更多上下文:
S0--sc1---sc2---sc3-----sc4 <-- branch-S
\
T0-------------o----M2 <-- branch-T1 (HEAD)
\ \ /
F0--fc1---fc2---M1 <-- branch-F
合并操作找到合并基础(S0),将其与两个提示提交(M2 和sc4)进行比较,合并生成的更改,并将它们应用于S0 的内容。一个冲突的文件现在作为三个输入副本在索引中,在工作树中作为 Git 的合并努力,但带有冲突标记。同时所有不冲突的文件都在索引中,准备被冻结。
唉,您现在在冲突合并期间删除了一些文件 (git rm)。这将从索引和工作树中删除文件。生成的提交M3 将表明基于合并基础S0 组合提交M2 和sc4 的正确方法是删除这些文件。 (这当然是错误的。)
这会自动合并到S (M4)。
在这里,我假设这意味着系统,使用它所拥有的任何预编程规则,做了相当于:
git checkout branch-S
git merge --no-ff branch-T1
找到了提交sc4(branch-S的提示)和M3的合并基,即M3,与o和M1的合并基相同的方式是M1早些时候。所以新的提交,M4,在内容方面匹配M3,此时我们有:
S0--sc1---sc2---sc3-----sc4----M4 <-- branch-S
\ \ /
T0-------------o----M2---M3 <-- branch-T1
\ \ /
F0--fc1---fc2---M1 <-- branch-F
我立即注意到,排除这大约 200 个文件似乎完全消除了更改,这相当于 2 个团队大约一个月的工作量。我(错误地)决定最好的行动方案是迅速采取行动,并在我的错误进入其他人的本地存储库之前恢复合并提交 M4 和 M3。我首先恢复了M4 (R1),一旦提交,我就恢复了M3 (R2)。
其实,这是一件好事!它得到正确的内容,当你立即执行时这非常有用。使用git checkout branch-s && git revert -m 1 branch-S(或git revert -m 1 <hash-of-M4>)从M4创建R1基本上撤消了内容方面的合并,因此:
git diff <hash-of-sc4> <hash-of-R1>
应该不会产生任何东西。同样,使用git checkout branch-T1 && git revert -m 1 branch-T1(或与哈希相同)从M3 创建R2 撤消在内容方面合并:比较M2 和R2,您应该会看到相同的内容。
撤消合并会撤消内容,但不会撤消历史记录
现在的问题是 Git 认为您的功能分支中的所有更改都已正确合并。任何git checkout branch-T1 或git checkout branch-S 后跟git merge <any commit within branch-F> 都会查看图表,沿着从提交到提交的反向链接,并看到branch-F 内的此提交——例如fc2 或M1——已合并。
让他们进入的诀窍是进行 new 提交,它与从 F0 到 M1 的提交序列所做的事情相同,那是 不是 已经合并。最简单(尽管最丑陋)的方法是使用git merge --squash。更难,或许更好的方法是使用git rebase --force-rebase 创建一个new 功能分支。 (注意:这个选项有三个拼写,最容易打的一个是-f,但是Linus Torvalds' description里面的那个是--no-ff。我觉得最难忘的是--force-rebase这个版本,但我实际上会用@987654516 @我自己。)
让我们快速浏览一下两者,然后考虑使用哪个以及为什么。无论哪种情况,一旦你完成了,这次你必须正确地合并新的提交,而不是删除文件;不过既然你知道git merge 真正在做什么,那应该会容易很多。
我们首先创建一个新的分支名称。我们可以重复使用branch-F,但我认为如果我们不这样做会更清楚。如果我们想使用git merge --squash,我们创建这个新的分支名称指向提交T0(忽略T0之后有提交的事实——记住,任何分支名称都可以指向any提交):
T0 <-- revised-F (HEAD)
\
F0--fc1--fc2--M1 <-- branch-F
如果我们想使用git rebase -f,我们创建这个指向提交fc2的新名称:
T0-----....
\
F0--fc1--fc2--M1 <-- branch-F, revised-F (HEAD)
我们这样做:
git checkout -b revised-F <hash of T0> # for merge --squash method
或:
git checkout -b revised-f branch-F^1 # for rebase -f method
取决于我们要使用的方法。 (^1 或 ~1 后缀——您可以使用其中任何一个——排除 M1 本身,将第一个父步骤退回到 fc2。这里的想法是排除提交 o 和任何其他可访问的提交来自o。在提交的最后一行中,不需要有其他合并到branch-F。)
现在,如果我们想使用“squash 合并”(它使用 Git 的合并机制而不进行合并 commit),我们运行:
git merge --squash branch-F
这使用我们当前的提交,加上branch-F(commit M1)的提示,作为合并的左右两侧,找到它们共同的提交作为合并基础。常见的提交当然只是F0,所以合并result 是M1 中的快照。但是,新的提交只有 一个 父级:它根本不是合并提交,它看起来像这样:
fc1--fc2--M1 <-- branch-F
/
F0-------------F3 <-- revised-F (HEAD)
F3 中的 快照 与 M1 中的匹配,但提交本身是全新的。它会收到一条新的提交消息(您可以编辑),当 Git 将 F3 视为提交时,它的效果是进行从 F0 到 M1 的相同更改集。
如果我们选择变基方法,我们现在运行:
git rebase -f <hash-of-T0>
(您可以改用o 的哈希值,即branch-F^2,即M1 的第二个父项。在这种情况下,您可以从revised-F 开始指向M1 本身。这可能是我会怎么做,以避免不得不剪切和粘贴大量带有潜在拼写错误的哈希 ID,但除非你已经完成了大量的图形操作练习,否则它是如何工作的并不明显。)
也就是说,我们希望将 F0 到 fc2 的提交复制到 new 提交,并使用新的哈希 ID。这就是 git rebase 将要做的事情(请参阅上面的其他 StackOverflow 答案和/或 Linus 的描述):我们得到:
F0'-fc1'-fc2' <-- revised-F (HEAD)
/
T0-----....
\
F0--fc1--fc2--M1 <-- branch-F
现在我们有 revised-F 指向单个提交 (F3) 或提交链(以 fc2' 结尾的链,fc2 的副本),我们可以 git checkout 其他一些分支和git merge revised-F。
基于 cmets,这里有两条重新合并的路径
我假设此时您有一个 squash-merge 结果(不是合并的单父提交,但确实包含所需的快照,我在这里称之为 F3)。我们还需要根据 cmets 对重新绘制的图表进行一些修改,这些 cmets 表明有更多的合并到 branch-F:
S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5 <-- branch-S
\ \ / /
T0-----o-------o----M2---M3--------R2 <---- branch-T1
\ \ \ /
F0--fc1-o-fc2---M1 <--------------- branch-F
现在我们将添加revised-F 分支,它应该有一个提交,它是F0 或T0 的后代。哪一个并不重要。由于我之前使用了F0,所以让我们在这里:
S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5 <-- branch-S
\ \ / /
T0-----o-------o----M2---M3--------R2 <---- branch-T1
\ \ \ /
F0--fc1-o-fc2---M1 <--------------- branch-F
\
---------------------------------F3 <-- revised-F
提交F3 的内容与M1 的内容匹配(所以git diff branch-F revised-F 什么也没说),但F3 的父级是F0。 (注意:有使用git commit-tree创建F3的快捷方式,但只要它已经存在并且与M1内容匹配,我们就可以使用它。)
如果我们现在这样做:
git checkout branch-T1
git merge revised-F
Git 将在提交 R2(分支 T1 的提示)和 F3(revised-F 的提示)之间找到合并基础。如果我们沿着R2 的所有向后(向左)链接,我们可以通过M3 到达T0,然后是M2,然后是一些os,最后是T0,或者我们可以到达@ 987654588@ 通过M3 然后M2 然后M1 然后fc2 回到F0。同时我们可以从F3直接到F0,只需一跳,所以合并基可能是F0。
(要确认这一点,请使用git merge-base:
git merge-base --all branch-T1 revised-F
这将打印一个或多个哈希 ID,每个合并基数一个。理想情况下,只有一个合并基础,即提交 F0。)
Git 现在将运行两个git diffs,比较F0 和F3 的内容——即我们为完成该功能所做的一切——并将F0 的内容与@ 的内容进行比较987654604@,在branch-T1 的顶端。我们会在两个差异更改相同文件的相同行时发生冲突。在其他地方,Git 将获取 F0 的内容,应用合并的更改,并准备好提交结果(在索引中)。
解决这些冲突并提交会给你一个新的提交,结果是:
S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5 <-- branch-S
\ \ / /
T0-----o-------o----M2---M3--------R2-----M6 <---- branch-T1
\ \ \ / /
F0--fc1-o-fc2---M1 <-- branch-F /
\ /
---------------------------------F3 <-- revised-F
现在M6 或许可以合并到branch-S。
或者,我们可以直接合并到branch-S。哪个提交是合并基础不太明显,但它可能又是F0。这又是同一张图:
S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5 <-- branch-S
\ \ / /
T0-----o-------o----M2---M3--------R2 <---- branch-T1
\ \ \ /
F0--fc1-o-fc2---M1 <--------------- branch-F
\
---------------------------------F3 <-- revised-F
从提交sc5 开始,我们向后工作到M5 到R2,我们现在处于与以前相同的情况。所以我们可以git checkout branch-S 并进行相同的合并,解决类似的冲突——这次我们将F0 与sc5 进行比较,而不是R2,因此冲突可能略有不同——并最终提交:
S0--sc1---sc2---sc3-----sc4----M4---R1---M5---sc5----M6 <-- branch-S
\ \ / / /
T0-----o-------o----M2---M3--------R2 <------ / -- branch-T1
\ \ \ / /
F0--fc1-o-fc2---M1 <-- branch-F /
\ /
---------------------------------------F3 <-- revised-F
要验证F0 是合并基,请像以前一样使用git merge-base:
git merge-base --all branch-S revised-F
要查看需要合并的内容,请从合并基础运行两个 git diffs 到两个提示。
(要进行哪种合并取决于您。)