Git09-合并

  • Git是一个分布式版本控制系统(Distributed Version Control System,DVCS)。例如,它允许日本的一个开发人员和新泽西州的一个开发人员独立地制作与记录修改,而且它允许两个开发人员在任何时候合并变更,不需要一个中心版本库。
  • 一次合并可以结合两个或多个分支。但是大多数情况下,一次合并只结合两个分支。
  • 在Git中,合并必须发生在一个版本库中——也就是说,所有要进行合并的分支必须在同一个版本库中。版本库中的分支是怎么来的并不重要(Git提供了引用其他版本库和下载远程分支到当前工作目录的机制)。
  • 当一个分支中的修改与另一个分支中的修改不发生冲突的时候,Git会计算合并结果并创建一个新提交来代表新的统一状态。但是当发生冲突时,Git并不解决冲突冲突通常是对同一个文件的同一行进行修改了)。相反,Git把这种争议性的修改在索引中标记为“未合并”(unmerged),留给开发人员来处理。当Git无法自动合并时,你需要在所有冲突都解决后做一次最终提交。

1、合并示例

1.1、为合并做准备

  • 在开始合并之前,最好整理一下工作目录。
  • 在正常合并结束的时候,Git会创建新版本的文件并把它们放到工作目录中。此外,Git在操作的时候还用索引来存储文件的中间版本
  • 如果已经修改了工作目录中的文件,或者已经通过git add或git rm修改了索引,那么版本库里就已经有了一个脏的工作目录或者索引。如果在脏的状态下开始合并,Git可能无法一次合并所有分支及工作目录或索引的的修改。
    • 提示,不必从干净的目录启动合并。例如,当受合并操作影响的文件和工作目录的脏文件无关的时候,Git才进行合并。但是,如果每次合并都从干净的工作目录和索引开始,那么关于Git的操作将会容易得多。

示例1-1:

1、创建一个新版本库

//(1)添加用户配置
]# git config --global user.email "hengha@123.com"
]# git config --global user.name "heng ha"
 
//(2)初始化一个新的版本库
]# mkdir merge-example
]# cd merge-example/
]# git init

2、在master分支中创建一个提交

//创建文件file1
]# cat > file1 << EOF
Line 1 stuff
Line 2 stuff
Line 3 stuff
EOF

]# git add file1
]# git commit -m "Initial 3 line file1"

3、创建一个新分支

]# git branch alternate

1.2、合并两个分支

  • 现在有两个分支了,每个分支都有不同的开发工作。

示例1-2:

  • 从前一节的合并结果开始。

1、在master分支中创建一个新文件file2

  • 版本库里会有一个包含两次提交的分支master,每次提交引人一个新文件。
//创建文件file2
]# echo "Here is stuff on file2" > file2
]# git add file2
]# git commit -m "Initial 1 line file2"

2、在alternate分支中修改file1

//(1)检出到alternate分支
]# git checkout alternate

//(2)修改文件file1
]# echo "Line 4 alternate stuff" >> file1
]# git add file1
]# git commit -m "Add alternate’s line 4 in file1"

3、合并两个分支

  • 在master分支中添加了文件file2,在alternate分支修改了文件file1。因为这两个修改并不影响相同文件的相同部分,所以合并应该会顺利进行,不会发生事故。
  • 建议,最好在工作目录干净的时候开始合并
  • git merge操作是区分上下文的。当前分支始终是目标分支,其他一个或多个分支始终合并到当前分支。
//(1)检出到master分支
]# git checkout master

//(2)查看master分支索引状态,可以看到工作目录是干净的(建议是干净的)
]# git status
On branch master
nothing to commit, working tree clean

//(3)将alternate分支合并到master分支中
]# git merge -m "Merge alternate to master" alternate
Merge made by the 'recursive' strategy.
 file1 | 1 +
 1 file changed, 1 insertion(+)

//(4)查看分支的历史提交
]# git show-branch --more=10
! [alternate] Add alternate’s line 4 in file1
 * [master] Merge alternate to master
--
 - [master] Merge alternate to master
+* [alternate] Add alternate’s line 4 in file1
 * [master^] Initial 1 line file2
+* [master~2] Initial 3 line file1

4、查看提交图

  • git log --graph命令是图形工具很好的替代品,比如gitk。非常适合哑终端
  • 这张图和提交图类似,只不过它是侧过来的,最近的提交在上边而不是右边。两个分支在初始提交3836f3a处分开;每个分支显示一个提交(ccfd7b2和669b564);两个分支在提交23e3966处合并。
]# git log --graph --pretty=oneline --abbrev-commit
*   23e3966 (HEAD -> master) Merge alternate to master
|\  
| * 669b564 (alternate) Add alternate’s line 4 in file1
* | ccfd7b2 Initial 1 line file2
|/  
* 3836f3a Initial 3 line file1
  • 从技术上讲,Git对称地执行每次合并来产生一个相同的、合并后的提交,并添加到当前分支中另一个分支不受合并影响。因为合并提交只添加到当前分支中,所以可以说,“我把一些其他分支合并到了这个分支里”。

1.3、有冲突的合并

  • 合并操作本质上是有问题的,因为它必然会从不同开发线上带来可能冲突的修改。一个分支上的修改可能与一个不同分支上的相似或完全不同。Git可以处理所有这些修改,但是通常需要你的指导来解决冲突。

示例1-3:

  • 从前一节的合并结果开始,在master和aiternate分支上引进独立且冲突的修改。然后,把alternate分支合并到master分支。

1、在master分支中修改file1

//(1)检出到master分支
]# git checkout master

//(2)修改file1
]# cat >> file1 << EOF
Line 5 stuff
Line 6 stuff
EOF
]# git add file1
]# git commit -m "Add line 5 and 6 in file1"

2、在alternate分支中修改file1

//(1)检出到alternate分支
]# git checkout alternate

//(2)修改文件file1
]# cat >> file1 << EOF
Line 5 alternate stuff
Line 6 alternate stuff
EOF
]# git add file1
]# git commit -m "Add alternate line 5 and 6 in file1"

//(3)查看分支的历史提交
]# git show-branch --more=10
* [alternate] Add alternate line 5 and 6 in file1
 ! [master] Add line 5 and 6 in file1
--
*  [alternate] Add alternate line 5 and 6 in file1
 + [master] Add line 5 and 6 in file1
*+ [alternate^] Add alternate’s line 4 in file1
 + [master~2] Initial 1 line file2
*+ [alternate~2] Initial 3 line file1

3、合并两个分支

  • 可以看到合并时产生了冲突,并且合并失败了,没有产生新的提交。
//(1)检出到master分支
]# git checkout master
Switched to branch 'master'

//(2)查看master分支索引状态,可以看到工作目录是干净的(建议是干净的)
]# git status
On branch master
nothing to commit, working tree clean

//(3)将alternate分支合并到master分支中,有冲突
]# git merge -m "Merge alternate to master 20230323-17:32" alternate
Auto-merging file1
CONFLICT (content): Merge conflict in file1
Automatic merge failed; fix conflicts and then commit the result.

//(4)查看分支的历史提交
]# git show-branch --more=10
! [alternate] Add alternate’s line 4 in file1
 * [master] Merge alternate to master
--
 - [master] Merge alternate to master
+* [alternate] Add alternate’s line 4 in file1
 * [master^] Initial 1 line file2
+* [master~2] Initial 3 line file1

4、查看冲突

  • 当出现合并冲突的时候,应该使用git diff命令来查看冲突
//可以看到是文件file1产生了冲突。
]# git diff
diff --cc file1
index 4d77dd1,802acf8..0000000
--- a/file1
+++ b/file1
@@@ -2,5 -2,5 +2,10 @@@ Line 1 stuf
  Line 2 stuff
  Line 3 stuff
  Line 4 alternate stuff
++<<<<<<< HEAD
 +Line 5 stuff
 +Line 6 stuff
++=======
+ Line 5 alternate stuff
+ Line 6 alternate stuff
++>>>>>>> alternate

5、解决冲突

  • 解决冲突就是手动修改冲突文件
    • 可以只取一边或另一边的版本,或者两边的混合,甚至是全新的内容。
//(1)file1修改前
]# cat file1
Line 1 stuff
Line 2 stuff
Line 3 stuff
Line 4 alternate stuff
<<<<<<< HEAD
Line 5 stuff
Line 6 stuff
=======
Line 5 alternate stuff
Line 6 alternate stuff
>>>>>>> alternate

//(2)修改file1,从每个分支选了一行作为我的解决版本
]# vim file1
Line 1 stuff
Line 2 stuff
Line 3 stuff
Line 4 alternate stuff
Line 5 stuff
Line 6 alternate stuff

6、提交冲突

  • 解决冲突后,就应该用git add命令来把文件添加到索引中(暂存每个冲突文件的最终版本),最后使用git commit命令来提交合并
//(1)暂存冲突文件
]# git add file1 

//(2)提交合并
]# git commit -m "Merge alternate to master 20230323-17:32"

//(3)查看分支的历史提交
]# git show-branch --more=10
! [alternate] Add alternate line 5 and 6 in file1
 * [master] Merge alternate to master 20230323-17:32
--
 - [master] Merge alternate to master 20230323-17:32
+* [alternate] Add alternate line 5 and 6 in file1
 * [master^] Add line 5 and 6 in file1
+* [alternate^] Add alternate’s line 4 in file1
 * [master~3] Initial 1 line file2
+* [master~4] Initial 3 line file1

7、查看合并的结果

]# git log --graph --pretty=oneline --abbrev-commit
*   3374727 (HEAD -> master) Merge alternate to master 20230323-17:32
|\  
| * f65d9fc (alternate) Add alternate line 5 and 6 in file1
* | 1eaf0bd Add line 5 and 6 in file1
* | 23e3966 Merge alternate to master
|\| 
| * 669b564 Add alternate’s line 4 in file1
* | ccfd7b2 Initial 1 line file2
|/  
* 3836f3a Initial 3 line file1

2、处理合并冲突

  • 有冲突的修改不能自动合并

示例1-2(2):

  • 从"示例1-1"开始,在master和aiternate分支上引进独立且冲突的修改。然后,把alternate分支合并到master分支。

1、在master分支中修改file1

//(1)检出到master分支
]# git checkout master

//(2)修改file1。删除第二行,添加第四、五行
]# vim file1
Line 1 stuff
Line 3 stuff
Line 4 stuff
Line 5 stuff
]# git add file1
]# git commit -m "update file1"

2、在alternate分支中修改file1

//(1)检出到alternate分支
]# git checkout alternate

//(2)修改文件file1。删除第二行,添加第四、五行
]# vim file1
Line 1 stuff
Line 3 stuff
Line 4 stuff
Line 5 alternate stuff
]# git add file1
]# git commit -m "update alternate file1"

3、合并两个分支

//(1)检出到master分支
]# git checkout master

//(2)查看master分支索引状态,可以看到工作目录是干净的(建议是干净的)
]# git status
On branch master
nothing to commit, working tree clean

//(3)将alternate分支合并到master分支中,有冲突
]# git merge -m "Merge alternate to master 20230323-23:41" alternate
Auto-merging file1
CONFLICT (content): Merge conflict in file1
Automatic merge failed; fix conflicts and then commit the result.

2.1、定位冲突的文件

  • 如果Git命令的输出滚动到屏幕之外或者冲突的文件太多怎么办?幸运的是,Git有两种方法对冲突文件进行跟踪:
    • (1)可以使用git status命令,将冲突文件标记为冲突的(conflicted)或者未合并的(unmerged)。
    • (2)可以使用git is-files -u命令,显示工作树中未合并的一组文件。

示例1-3(2):

//可以看到有一个冲突文件file1
]# git status
On branch master
You have unmerged paths.
  (fix conflicts and run "git commit")
  (use "git merge --abort" to abort the merge)

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   file1

no changes added to commit (use "git add" and/or "git commit -a")

//可以看到有一个冲突文件file1
]# git ls-files -u
100644 8db78cb784b1f3633d153f68281a05c2032b90a4 1	file1
100644 c6613712a18c4b062fc74afef02f7f8dd0f0df33 2	file1
100644 52b28b0773c170c407083a8b3ffffbec833b2efe 3	file1

2.2、检查冲突

  • 当冲突出现时,每个冲突文件在工作目录中都有一个副本(合并后的版本),并通过三方比较或合并标记强调。

示例1-4(2):

  • 冲突文件的合并标记说明可能有两个版本。在第一个版本中,该部分是“Line 5 stuff”。在另一版本中,该部分是“Line 5 alternate stuff”。
//查看合并后的版本
]# cat file1
Line 1 stuff
Line 3 stuff
Line 4 stuff
<<<<<<< HEAD
Line 5 stuff
=======
Line 5 alternate stuff
>>>>>>> alternate

2.2.1、对冲突使用git diff命令

  • Git有一个特殊的、特定于合并的git diff变体来同时显示针对两个父版本做的修改。
  • git diff命令显示冲突文件在当前分支和另一个分支间的差异(即)。在传统的diff命令输出风格里,改变的内容显示在<<<<<<<和=======之间,替代的内容在=======和>>>>>>>之间。然而,在组合diff(combined diff)格式中使用额外的加号和减号来表示相对于最终版本的来自多个源的变化。
    • 三方合并标记线(<<<<<<<,=======和>>>>>>>)是自动生成的,但是它们只是提供给你看的,而不(必须)是给程序看的。
  • git diff命令输出的冲突中有两列信息。
    • 第一列显示相对我们的版本(当前分支)的更改,第二列显示相对于另一版本(另一个分支、他们的版本)的更改。
    • 加号表示添加行,减号表示删除行,空格表示该行没有变化。
  • 在较新版本的Git中:
    • git diff --ours(git diff HEAD)显示“我们的”版本和合并后的版本的区别。
    • git diff --theirs(git diff MERGE_HEAD)显示“他们的”版本和合并后的版本的区别。
    • git diff --base("git diff $(git merge-base HEAD MERGE_HEAD)")显示基础版本和和合并后的版本的区别。

示例1-5(2):

1、查看冲突

  • git diff比较的是两个分支(master和alternate分支):第一个是HEAD版本,第二个是alternate版本(即MERGE_HEAD)。
]# git diff
diff --cc file1
index c661371,52b28b0..0000000
--- a/file1
+++ b/file1
@@@ -1,4 -1,4 +1,8 @@@
  Line 1 stuff
  Line 3 stuff
  Line 4 stuff
++<<<<<<< HEAD
 +Line 5 stuff
++=======
+ Line 5 alternate stuff
++>>>>>>> alternate
  • 可以拿HEAD和MERGE_HEAD版本跟工作目录(“合并的”)版本进行比较。
]# git diff HEAD
diff --git a/file1 b/file1
index c661371..43ea17c 100644
--- a/file1
+++ b/file1
@@ -1,4 +1,8 @@
 Line 1 stuff
 Line 3 stuff
 Line 4 stuff
+<<<<<<< HEAD
 Line 5 stuff
+=======
+Line 5 alternate stuff
+>>>>>>> alternate

]# git diff MERGE_HEAD
diff --git a/file1 b/file1
index 52b28b0..43ea17c 100644
--- a/file1
+++ b/file1
@@ -1,4 +1,8 @@
 Line 1 stuff
 Line 3 stuff
 Line 4 stuff
+<<<<<<< HEAD
+Line 5 stuff
+=======
 Line 5 alternate stuff
+>>>>>>> alternate

2、解决冲突

  • 等一下!奇怪的事情发生了。添加到基础版本中的"Line 5 alternate stuff"显示到哪里去了?从HEAD版本中删除"Line 5 stuff"那行又显示在哪里了?
    • 当你已经解决MERGE_HEAD版本中的冲突时,Git会故意忽略这些差异,因为它认为你可能不再关心那部分了。
//(1)解决冲突,保留alternate分支的内容
]# vim file1
Line 1 stuff
Line 3 stuff
Line 4 stuff
Line 5 alternate stuff

//(2)查看冲突,可以看到file1文件的冲突已经解决了
]# git diff
diff --cc file1
index c661371,52b28b0..0000000
--- a/file1
+++ b/file1
  • 对有冲突的文件执行git diff只会显示真正有冲突的部分。在一个充满很多修改的大文件里,大部分修改是没有冲突的——只是在一边改变了一个特定的部分。当你试图解决冲突时,很少会在乎这些部分,因此git diff命令用一个简单的启发式算法修剪掉不感兴趣的部分:如果只有一边有变化,这部分就不显示
  • 优化有个比较容易让人疑惑的副作用:一旦你通过选择其中一边来解决冲突,它就不再显示了。那是因为你修改的部分变成只有一边有变化(即你没选择的那一边),因此在Git看来这就是从来没发生冲突的部分。
  • 这不仅仅是一个刻意的功能,还是实现上带来的副作用,但是你可能会认为这很有用:git diff命令只显示仍然冲突的文件,因此可以用它来跟踪还没解决的冲突

2.2.2、对冲突使用git log命令

  • 使用一些特殊的git log选项来可以找出变更的确切来源和原因。
    • 将显示这两部分历史中的所有提交,并显示每次提交引入的实际变更。如果你想知道什么时候、为什么、如何和由谁把worlds那行添加到文件中,你可以清楚地看到哪部分变更引入了它。
  • git log的选项如下:
    • --merge:只显示跟产生冲突的文件相关的提交。
    • --left-right:如果提交来自合并的“左”边则显示<(“我们的”版本,就是你开始的版本),如果提交来自合并的“右”边则显示>(“他们的”版本,就是你要合并到的版本)。
    • -p:显示提交消息和每个提交相关联的补丁。

示例1-6(2):

]# git log --merge --left-right -p
commit > 66bdf40a60e5ac316af4b08a3f9d8758c69be775 (alternate)
Author: heng ha <hengha@123.com>
Date:   Thu Mar 23 23:41:06 2023 +0800

    update alternate file1

diff --git a/file1 b/file1
index 8db78cb..52b28b0 100644
--- a/file1
+++ b/file1
@@ -1,3 +1,4 @@
 Line 1 stuff
-Line 2 stuff
 Line 3 stuff
+Line 4 stuff
+Line 5 alternate stuff

commit < a1f82e4fa0803b9650ebb123221cd903f953a98d (HEAD -> master)
Author: heng ha <hengha@123.com>
Date:   Thu Mar 23 23:37:36 2023 +0800

    update file1

diff --git a/file1 b/file1
index 8db78cb..c661371 100644
--- a/file1
+++ b/file1
@@ -1,3 +1,4 @@
 Line 1 stuff
-Line 2 stuff
 Line 3 stuff
+Line 4 stuff
+Line 5 stuff
  • 实际情况可能是更庞大且复杂的。有一种手段可以缓解来自大合并中讨厌而繁多的冲突的痛苦,即使用几个包含单独概念的,定义良好的小提交。Git对小提交处理得很好,因此没有必要等到最后才提交一个又庞大又影响广泛的修改。小规模的提交和更频繁的合并周期可以减少解决冲突的痛苦。

2.3、Git是如何追踪冲突的

  • Git是如何追踪一个合并冲突的所有信息的呢?主要有以下几个部分:
    • .git/MERGE_HEAD包含合并进来的提交的SHA1值。
    • .git/MERGE_MSG包含当解决冲突后执行git commit命令时用到的默认合并消息。
    • Git的索引包含每个冲突文件的三个副本:合并基础“我们的”版本“他们的”版本。并给这三个副本分配了各自的编号1、2、3。
      • 无冲突的文件的编号是0。
    • 冲突的版本(合并标记和所有内容)不存储在索引中。相反,它存储在工作目录中的文件里。当执行不带任何参数的git diff命令时,始终比较索引与工作目录中的内容。

示例1-7(2):

  • 查看索引中的冲突文件。
]# git ls-files -u
100644 8db78cb784b1f3633d153f68281a05c2032b90a4 1	file1
100644 c6613712a18c4b062fc74afef02f7f8dd0f0df33 2	file1
100644 52b28b0773c170c407083a8b3ffffbec833b2efe 3	file1
  • 比较“我们的”版本和“他们的”版本
]# git diff :2:file1 :3:file1
diff --git a/file1 b/file1
index c661371..52b28b0 100644
--- a/file1
+++ b/file1
@@ -1,4 +1,4 @@
 Line 1 stuff
 Line 3 stuff
 Line 4 stuff
-Line 5 stuff
+Line 5 alternate stuff

2.4、完成解决冲突

  • 必须解决索引中记录的所有冲突文件。只要有未解决的冲突,就不能提交
  • 当解决一个文件的冲突之后,可以执行git add(或者git rm、git update-index等)以清除它的冲突状态。
    • 不要对有冲突标记的文件执行git add命令。虽然这会清除索引中的冲突,并允许提交,但文件将是错误的。

示例1-8(2):

1、提交冲突

]# git add file1

]# git commit -m "Merge alternate to master 20230324-01:03"

2、查看提交

]# git show
commit 69aa6ab0d72f67596b2d08f6a4dace72e8359642 (HEAD -> master)
Merge: a1f82e4 66bdf40
Author: heng ha <hengha@123.com>
Date:   Fri Mar 24 01:03:26 2023 +0800

    Merge alternate to master 20230324-01:03
  • 当查看一个合并提交时,应该注意三件有趣的事。
    • 在开头第二行新写着“Merge:”。通常在git log或者git show中不显示父提交,因为一般只有一个父提交,并且一般都正好在日志里显示在后边。但是合并提交通常有两个(有时更多)父提交,而且他们的父提交对理解合并是很重要的。因此,git log和git show始终输出每个祖先的SHA1。
    • 自动生成的提交日志消息有助于标注冲突的文件列表。如果事实证明一个特定的问题是由合并引起的,这将十分有用。通常,问题都是由不得不手动进行合并的文件引起的。
    • 合并提交的差异不是一般的差异。它始终处于组合差异或者“冲突合并”的格式。认为Git中一个成功的合并是完全没有变化的;它只是简单地把其他已经在历史中的变更组合起来。因此,合并提交的内容里只显示与合并分支不同的地方,而不是全部的区别。

2.5、中止或重新启动合并

  • 如果你开始合并操作,但是因为某种原因你不想完成它,Git提供了一种简单的方法来中止操作。在合并提交执行git commit命令前,使用如下命令。
//把工作目录和索引都还原到git merge命令之前。
git reset --hard HEAD
  • 如果要中止或在它已经结束(也就是,引进一个新的合并提交)后放弃,请使用以下命令。
    • 在开始合并操作前,Git把原始分支的HEAD保存在ORIG_HEAD,就是为了这种目的。
git reset --hard ORIG_HEAD
  • 在这里应该非常小心。如果在工作目录或索引不干净的情况下启动合并,可能会遇到麻烦并丢失目录中任何未提交的修改。
  • 可以从脏的工作目录启动git merge请求,但是如果执行git reset --hard,合并之前的脏状态不会完全还原。相反,重置会弄丢工作目录区域的脏状态。换言之,对HEAD状态请求了一个——hard重置。
  • 从Git1.6.1版本开始,有另一种选择。如果你已经把冲突解决方案搞砸了,并且想再返回到尝试解决前的原始冲突状态,你可以使用git checkout -m命令。

3、合并策略

  • 如果有三个程序员在一个版本库上进行开发工作。为了简单起见,假设每个程序员——Alice、Bob和Ca1——可以在一个共享的版本库中的三个不同分支提交变更。
  • 因为程序员都向单独的分支提交代码,所以让Alice一个人来管理各种提交的整合。在此期间,根据需要,每个程序员都可以直接加入或者合并其他的分支来利用其他程序员的成果。
  • (1)三个程序员开发出了一个具有如图9-1所示的提交历史的版本库。

  • (2)不久之后,Alice合并了Bob的修改,而Bob继续工作没把Alice的变更合并回来。如图9-2所示。

  • (3)又不久之后,Bob想获取Cal的最新修改。要想合并Cal,Bob需要在合并基础A(沿着树向上追踪,从Bob开始,穿过Alice,直到你到达她第一次偏离Cal的那点,那就是A,Bob和Cal的合并基础)和Cal的最新版本Q之间做出一系列变化,然后把他们合并到他自己的树中,得到提交K,结果如图9-3所示。
    • 可以用git merge-base来找到两个或两个以上分支之间的合并基础

  • (4)最后,Alice要得到Cal的最新修改,但是她不知道Bob已经把Cal的树合并进他的树中了。所以她只能把Cal的树合并到她的树中。如图9-4所示。

  • (5)紧接着,Alice知道了Bob合并了Cal的最新修改,但希望能再次从BOb的L处进行合并。这次合并基础(L和E之间)是什么?
    • 遗憾的是,答案是有二义性的。如果你沿着所有路从树上倒退,你可能会认为Cal的原始版本是个不错的选择。但是这并没有意义:Alice和Bob现在都有Cal的最新版本。如果你询问Cal的原始版本到Bob最新版本的差异,那么它也包括Cal的新修改,而Alice已经有了,这很可能导致合并冲突。
    • 如果你使用Cal的最新版本作为基础怎么样?这更好一点,但还不太对:如果你寻找从Cal的最新版本到Bob的最新版本的差异,你会得到Bob的所有修改。但是Alice已经有了Bob的一些修改,因此你还是可能得到一个合并冲突。
    • 那如果你使用Alice上次从Bob合并的版本,也就是版本J,行吗?创建从那里到Bob的最新版本的差异会只包含Bob的最新修改,这是你想要的。但是这也包含来自Cal的修改,而这些修改Alice已经有了!
  • 怎么办呢?这种情况称为交叉合并(criss-cross merge),即在分支之间来回合并。如果修改只在一个方向上移动(例如,从Cal到Alice到Bob),那么合并将会很简单。遗憾的是,实际情况并不总是那么简单。
  • Git开发人员最初写了一个简单的机制,用合并提交加入两个分支,但是刚刚描述的情景让他们意识到需要一个更聪明的方法。因此,开发人员将问题普遍化、参数化并提出了可替代、可配置的合并策略来处理不同的情况。

3.1、退化合并

  • 常见的有两种导致合并退化情况:已经是最新的和快进的。
    • 因为这些情况下执行git merge后,实际上都不引入一个合并提交,所以有些人可能认为它们不是真正的合并策略。
  • 已经是最新的(already up-to-date):当来自其他分支(HEAD)的所有提交都存在于目标分支上时,即使目标分支已经在它自己的分支上前进了,目标分支还是已经更新到最新的。因此,没有新的提交添加到目标分支上。
  • 快进的(fast-forward):当目标分支HEAD已经在其他分支中完全存在时,就会发生快进合并。这是“已经是最新的”的反向情景。
  • 因为HEAD已经在其他分支存在了(可能是由于一个共同的祖先),Git简单地把其他分支的新提交钉在HEAD上。然后Git移动分支HEAD来指向最终的新提交。当然,索引和工作目录也做相应调整,以反映新的最终提交状态。
  • 快进的情况是相当常见的,因为他们只是简单获取并记录来自其他版本库的远程提交。你的本地追踪分支HEAD会始终完全存在并表示,因为那里是在前一个获取操作后分支HEAD位于的地方。
  • Git不引入实际的提交来处理这种情况是很重要的。试想一下,如果在快进的情况下Git创建了一个提交会怎么样。把分支A合并进分支B会首先产生图9-5。然后合并B到A会产生图9-6,再合并回来会产生图9-7。
  • 因为每一个新的合并是一个新的提交,所以该序列将永远不会收敛于一个稳定的状态,并显示出两个分支是相同的。

3.2、常规的合并策略

  • 这三种合并策略都会产生一个最终提交,添加到当前分支中,表示合并的组合状态。
    • 解决(Resolve):解决策略只操作两个分支,定位共同的祖先作为合并基础,然后执行一个直接的三方合并,通过对当前分支施加从合并基础到其他分支HEAD的变化。这个方法很直观。
    • 递归(Recursive):递归策略和解决策略很相似,它一次只操作两个分支。但是,它能处理在两个分支之间有多个合并基础的情况。在这种情况下,Git会生成一个临时合并来包含所有相同的合并基础,然后以此为基础,通过一个普通的三方合并算法导出两个给定分支的最终合并。
      • 扔掉临时合并基础,把最终合并状态提交到目标分支。
    • 章鱼(Octopus)。章鱼策略是专为合并两个以上分支而设计的。从概念上讲,它相当简单;在内部,它多次调用递归合并策略,要合并的每个分支调一次。
      • 然而,这个策略不能处理需要用户交互解决的冲突。在这种情况下,必须做一系列常规合并,一次解决一个冲突。

3.2.1、递归合并策略

  • 一个简单的交叉合并,如图9-8所示。

  • 节点a和节点b都是合并A和B的合并基础。任意一个都可以作为合并基础,得到合理的结果。在这种情况下,递归策略会把a和b合并到一个临时合并基础,并以它作为A和B的合并基础。
  • 因为a和b可能有相同的问题,所以合并它们可能需要对更早的提交进行另一个合并。这就是为什么这个算法称为递归。

3.2.2、章鱼合并策略

  • Git支持一次合并多个分支的主要原因是通用性和高雅设计。在Git中,一个提交可以没有父提交(初始提交)、也可以只有一个父提交(正常提交),或者多个父提交(合并提交)。一旦有多个父提交,就没有特别的理由来限制这一数字只能是2,因此Git的数据结构支持多个父提交。要允许灵活的父提交列表,章鱼合并策略是这种通用性设计决策的自然结果。
  • 因为章鱼合并在图上看起来不错,所以Git用户往往尽可能经常使用它们。例如,合并一个程序的6个分支为一个的时候开发人员的内啡肽急剧升高。除了看起来漂亮之外,章鱼合并实际上没有做任何额外的东西。可以很容易完成多个合并提交,每个分支一个,然后完成完全一样的东西。

3.3、特殊的合并策略

  • 有两个特殊的合并策略:我们的和子树。
  • 这两个合并策略都产生一个最终提交,添加到当前分支中,代表合并的组合状态。
    • 我们的(ours):“我们的”策略合并任何数量的其他分支,但它实际上丢弃那些分支的修改,只使用当前分支的文件。“我们的”合并结果跟当前HEAD是相同的,但是任何其他分支也记为父提交。
      • 如果你知道你已经有了其他分支的所有变化,但想要把两个历史合并起来时,这是非常有用的。也就是说,它可以让你记录你已经以某种方式进行合并,也许直接手动,未来的Git操作不应该再尝试合并这些历史。无论它是如何成为合并的,Git都可以把这个当成真实合并。
    • 子树(subtree):子树策略合并到另一个分支,但是那个分支的一切会合并到当前树的一棵特定子树。不需要指定哪棵子树,Git会自动决定。
  • 可以一起使用“我们的”和“子树”这两个合并策略。例如,曾有段时间,gitweb程序(现在是git的一部分)是在git.git主版本库外开发的。但是在版本0a8f4f,把它的整个历史合并到git.git中的gitweb子树下。如果你想做同样的事情,你可以如下操作。
    • (1)把gitweb.git项目中的当前文件复制到项目的gitweb子目录中。
    • (2)像往常一样提交它们。
    • (3)使用“我们的”策略从gitweb.git项目提取:git pull -s ours gitweb.git master
      • 你在这里使用“我们的”策略,因为你知道你已经有了文件的最新版本,而且你已经把他们放在了你想要的位置(不是正常递归策略放的位置)。
    • (4)以后,可以使用subtree策略继续从gitweb.git项目提取最新修改:git pull -s subtree gitweb.git master
      • 因为文件已经存在你的版本库里了,所以Git自动知道你把它们放在哪棵子树中,然后可以执行更新而且不会有冲突。

3.4、应用合并策略

  • 那么Git是如何知道或决定使用哪种策略呢?或者说,如果你不喜欢Git的选择,你怎样指定一个不同的策略呢?
    • 因为Git会尝试使用尽可能简单和廉价的算法,所以如果可能,它会首先尝试使用“已经是最新的”和“快进”策略来消除不重要的、简单的情况。
    • 如果你指定多个其他分支合并到当前分支中,Git别无选择,只能尝试章鱼策略,因为这是唯一一个能够在一次合并中加入两个以上分支的策略。
    • 如果这些情况都失败了,Git会使用在所有其他情况下能可靠工作的一个默认策略
  • 在交叉合并的情况下,即有多个可能的合并基础,resolve策略这样工作:挑选一个可能的合并基础(无论是Bob分支的最后合并还是Cal分支的最后合并),然后希望它是最好的。这其实并不像它听起来那么坏。往往是Alice、Bob和Cal都各自工作于不同的代码部分。在这种情况下,Git会检测到它正在重新合并一些已经存在的修改,然后跳过重复的修改,以避免冲突。或者,如果有轻微的修改会导致冲突,那么至少冲突应该是对开发人员来说相当容易处理的。
  • 指定合并策略的方法是
git merge -s <strategy> [<commit>...]
  • 最初,Git的默认策略是resolve(解决),现在是Recursive(递归)
    • 2005年,Fredrik Kuivinen贡献了新的递归合并策略,这已成为默认策略。它比resolve策略更通用,并在Linux内核上已证明会导致更少的冲突而且没有故障。它也对重命名的合并处理得很好。
  • 在前面的例子中,Alice想要合并Bob的所有工作,递归策略将这样工作。
    • (1)从Alice和Bob都有的Cal的最近版本开始。在这种情况下,也就是Cal的最近版本Q,已经合并到Bob和Alice的分支中了。
    • (2)计算那个版本和Alice从Bob合并来的版本之间的差异,然后打上补丁。
    • (3)计算合并版本和Bob最新版本之间的差异,然后打上补丁。
  • 这种方法称为“递归”,因为可能有额外的迭代,取决于交叉的层次深度和Git遇到的合并基础。而且这样行得通。recursive方法不仅给人直观的感觉,还证明它在现实生活中会比简单的resolve策略导致的冲突更少。这就是递归策略现在是gitmerge的默认策略的原因。
  • 当然,不管Alice选择使用哪种策略,最终的历史看起来都是一样的(见图9-9)。

3.5、合并驱动程序

  • 每个合并策略都使用相关的合并驱动程序来解决和合并每个单独文件。一个合并驱动程序接受三个临时文件名来代表共同祖先、目标分支版本和文件的其他分支版本。驱动程序通过修改目标分支来得到合并结果。
  • 文本(text)合并驱动程序留下三方合并的标志(<<<<<<<,=======和>>>>>>>)。
  • 二进制(binary)合并驱动器简单地保留文件的目标分支版本,在索引中把文件标记为冲突的。实际上,这迫使你手动处理二进制文件。
  • 联合(union)合并驱动程序只是简单地把两个版本的所有行留在合并后的文件里。
  • 通过Git的属性机制,Git可以把特定的文件或文件模式绑定到特定的合并驱动程序。大多数文本文件被text驱动程序处理,大多数二进制文件被binary驱动程序处理。然而,对于特殊的需求,如果要执行特定应用程序的合并操作,可以创建并指定自定义合并驱动程序,然后把它绑定到特定的文件。

4、Git怎么看待合并

4.1、合并和Git的对象模型

  • 在大多数VCS中,每个提交只有一个父提交。在这样的系统中,当合并some_branch到my_branch中的时候,在my_branch上创建一个新提交,包含来自some_branch的修改。相反,如果合并my_branch到some_branch中,就会在some_branch创建一个新提交包含来自my_branch的修改。合并分支A到分支B中和合并分支B到分支A中是两个不同的操作
  • 然而,Git的设计者注意到当完成这两个操作时,结果都产生一组相同的文件。用自然的语言来表达任意一种操作就是“合并来自some_branch和another_branch的所有修改到单个分支中"。
  • 在Git中,合并产生一个新的树对象,该树对象包含合并后的文件,但它只在目标分支上引入了一个新的提交对象
  • 在图9-10中,每个Cx是一个提交对象,每个Tx代表相应的树对象。请注意为什么有一个共同的合并提交(CZC),它有CC和CZ两个父提交,但是只有一组文件出现在TZC树中。合并后的树对象对称地代表源分支两边。但因为my_branch是合并时检出的分支,所以只有my_branch更新为显示新提交;some_branch仍然保留以前的样子。

  • 这不仅是一个语义问题。它反映了Git的基本哲学,所有分支生而平等

4.2、压制合并

  • 假设some_branch包含不止一个新提交,而是5个或10个,甚至上百个提交。
    • 在大多数系统中,把some_branch合并到my_branch中会涉及产生一个差异,把它当成一个单独的补丁打到my_branch,然后在历史中创建一个新元素。这就是所谓的压制提交,因为它把所有的单独提交“压制”为一个大修改。这样只须关心my_branch的历史记录,而some_branch的历史记录将会丢失。
    • 在Git中,因为两个分支被视为平等的,所以压制一边或者另一边是不恰当的。相反,提交的整个历史记录两边都保留着。作为用户,你可以从图9-10看出你为这种复杂性付出的代价。如果Git已经做了一个压制提交,那你就不会看到(或想到)先分叉然后又合并的图了。my_branch的历史记录原本只是一条直线。
  • 提示,根据需要,Git可以完成压制提交。只要在执行git merge或git pull的时候给出--squash选项。然而,请注意,压制提交会扰乱Git的历史记录,而且这将使未来的合并变得复杂,因为压缩的评论改变了提交历史记录。
  • 增加的复杂性可能貌似遗憾,但是实际上它是非常值得的。例如,这个功能意味着git blame和git bisect命令比其他系统中的同等命令更强大。正如你看到的递归合并策略,作为增加复杂性的结果是,Git有能力自动进行非常复杂的合并,并能得到详细的历史记录。
  • 提示,虽然合并操作本身对两个父提交是平等的,但是当你以后回顾历史的时候,你可以选择将第一个父提交作为特殊的提交。一些命令(例如,git log和gitk)支持--first-parent选项,该选项只能跟在每个合并的第一个父提交后面,如果在所有合并中使用了--squash选项、那由此产生的历史记录看起来很相似。

4.3、为什么不合并每个变更

  • 为什么不一个接一个地合并每个变更?
  • 如果你将别人的一系列补丁应用在你的最新版本上,你将创建一系列全新的版本,结合了他们的和你的修改。据推测,你会像往常一样对最终版本进行测试。但是所有这些新的中间版本呢?在现实中,这些版本从来没有存在过:因为没有人真正产生过这些提交,所以没有人可以确定地说它们是可行的。
  • Git保持一个详细的历史记录,以便你在一个特定的时刻可以重新审视你的文件在过去的样子。如果一些你的合并提交反映出从来没有真正存在过的文件版本,那你就失去了最初拥有详细历史记录的原因!
  • 这是Git合并不那么做的原因。如果你问“我合并之前5分钟它是什么样子的”?那么答案将是二义性的。相反,你必须特别问是my_branch还是some_branch,因为这两个在5分钟前都不一样,而且Git可以对每个给出一个回答。
  • 即使你几乎总是要标准合并行为的历史,Git也可以应用一系列补丁,像这里描述的那样。这个过程被为变基。
#                                                                                                                         #
posted @ 2023-03-24 03:59  麦恒  阅读(107)  评论(0编辑  收藏  举报