要让Git始终对master
分支执行压缩合并(squash merge):
$ git config branch.master.mergeOptions "--squash"
解释
默认情况下,您无法使Git针对所有分支执行Squash“合并”,但是您可以使其默认针对某些分支执行Squash“合并”。由于您特别希望仅对master
执行此操作,因此这可能正是您想要的。
让我们快速回顾一下git merge
实际上做了什么,因为在通常的Git方式中,Git会使一切变得复杂。以及这个:
我们使用单独的分支进行重要的错误修复和功能。通过执行频繁的git checkout <x>; git merge master
将这些分支与主分支保持同步。
这与许多人认为的Git的“正确”工作流程相反。我对任何Git工作流程是否可以被称为“正确”持有一些怀疑 :-) ,但是有些比其他的更成功,而这绝对是其中一个更成功的反向。(正如下面的详细讨论所述,我确实认为它可以很好地工作。)
1嗯,我尝试保持简洁。 :-) 可以随意浏览,虽然这里有一些重要材料。如果太长,直接跳到结尾。
提交图
正如您所知,在Git中,提交图控制着很多东西。 每个1提交都有一些父提交,或在合并提交的情况下,有两个或更多个父提交。要创建新的提交,我们进入某个分支:
$ git checkout funkybranch
在工作树中进行一些工作,git add
一些文件,最后将结果提交到分支funkybranch
:
... work work work ...
$ git commit -m 'do a thing'
当前的提交是名称为funkybranch
的指针所指向的(一个单一的)提交。Git通过读取HEAD
来找到它:通常HEAD
包含分支的名称,而该分支包含提交的原始SHA-1哈希ID。
要创建新的提交,Git从当前所在的分支中读取当前提交的ID,将索引/暂存区保存到仓库中,2使用当前提交的ID作为新提交的父提交编写新提交,最后将新提交的ID写入分支信息文件。
这就是分支增长的方式:从一个提交开始,我们创建一个新的提交,然后将分支名称移动到指向新的提交。当我们按线性链条这样做时,我们会得到一个漂亮的线性历史:
... <- C <- D <- E <
提交 E
(实际上可能是e35d9f ...
或其他)是当前提交。它指向D
,因为在我们进行E
提交时D
是当前提交; D
指向C
,因为在那个点上C
是当前提交;以此类推。
当我们使用例如git checkout -b
创建新分支时,我们所做的就是告诉Git创建一个新的名称,指向某个现有的提交。通常这只是当前提交。因此,如果我们在funkybranch
上并且funkybranch
指向提交E
,则运行:
git checkout newbranch
那么我们得到了这个:
... <- C <- D <- E <
也就是说,这两个名称都指向提交 E
。Git 知道我们现在在 newbranch
上,因为 HEAD
指向 newbranch
。我喜欢在这种绘图中包含这一点:
... <- C <- D <- E <
我也喜欢以更紧凑的方式绘制我的图表。我们知道提交总是“向后指向”它们的父级,因为在创建提交E
之前无法创建提交D
。因此,这些箭头总是向左指,并且我们可以只画一条或两条破折号:
...--C--D--E <-- funkybranch, HEAD -> newbranch
如果我们不需要知道每个提交是哪个,可以为每个提交绘制一个圆形节点o
,但现在我将坚持在这里使用单个大写字母。
现在,如果我们现在进行新的提交-提交F
-它会导致newbranch
前进(因为从HEAD
中可以看到,我们正在newbranch
上)。所以让我们画出来:
...--C--D--E <-- funkybranch
\
F <-- HEAD -> newbranch
现在让我们再次执行 git checkout funkybranch
命令,在此分支下完成一些工作并提交,创建新的提交记录 G
:
...--C--D--E--G <-- HEAD -> funkybranch
\
F <-- newbranch
(并且HEAD
现在指向funkybranch
)。现在我们有了可以合并的东西。
1好吧,除了根提交(root commits)之外的每个提交。在大多数Git存储库中,只有一个根提交,即第一个提交。显然,它不能有一个父提交,因为每个新提交的父提交是我们制作新提交时当前提交。在没有任何提交的情况下,当我们进行第一次提交时,还没有当前提交。因此,它变成了一个根提交,然后所有后续的提交都是它的子代、孙辈、曾孙辈等。
2大部分“保存”工作实际上发生在每个git add
时。索引/暂存区包含哈希ID,而不是实际的文件内容:文件内容在运行git add
时保存。这是因为Git的图不仅是提交对象,还包括存储库中的每个对象。这就是使Git比例如Mercurial(将文件保存到提交时间而不是添加时间)快的部分原因。幸运的是,与提交图本身不同,用户无需知道或关心这一点。
Git合并
如前所述,我们必须在某个分支上。1 我们在funkybranch
上,所以一切都很好:
$ git merge newbranch
目前,大多数人似乎认为魔法发生了。但其实这并不是魔法。现在Git会找到我们当前提交和我们命名的那个提交之间的合并基础,然后运行两个git diff
命令。
合并基础简单地说就是这两个分支上“共同”的第一个提交——也就是两个分支上都有的第一个提交。我们在上,指向
现在Git运行这两个git diff
命令。其中一个将合并基础与当前提交进行比较:git diff <id-of-E> <id-of-G>
。第二个比较合并基础与另一个提交:git diff <id-of-E> <id-of-F>
。
最后,Git尝试将这两组更改结合起来,并将结果写入我们的当前工作树。如果这些更改看起来独立,Git将接受它们。如果它们看起来冲突,Git将停止合并,并让我们清理它们。如果它们看起来是相同的更改,Git只采用一个更改副本。
所有这些“看起来”都是基于纯文本进行的。Git并不了解代码。它只看到像“删除一行读取++x;
”和“添加一行读取y *= 2;
”这样的东西。这些看起来不同,所以只要它们看起来在不同的区域中,就会对合并基础文件执行一个删除和一个添加操作,将结果放入工作树中。
最后,假设合并顺利完成且没有发生冲突,Git会创建一个新的提交。新的提交是一个合并提交,这意味着它有两个3父节点。第一个父节点(顺序很重要)是当前提交,与常规的非合并提交一样。第二个父节点是另一个提交。一旦提交安全地写入仓库,Git会像往常一样将新提交的ID写入分支名称中。因此,假设合并成功,我们得到以下结果:
...--C--D--E--G--H <-- HEAD -> funkybranch
\ /
F <-- newbranch
请注意,
newbranch
没有移动:它仍然指向提交
F
。
HEAD
也没有改变:它仍然包含名称为
funkybranch
。只有
funkybranch
发生了变化:它现在指向新的合并提交
H
,
H
又同时指向了
G
和
F
。
1Git 对此有点精神分裂。如果我们 git checkout
一个原始的 SHA-1 或任何不是分支名的东西,它会进入一种被称为“脱离状态”的状态。内部上,这个工作方式是将 SHA-1 哈希直接推送到 HEAD
文件中,使得 HEAD
给出提交 ID,而不是分支名称。但 Git 的所有其他操作都像我们在一个特殊的分支上,其名称只是空字符串。它是(唯一的)匿名分支或等效地说,它是命名为 HEAD
的分支。因此,在某种意义上,我们总是在一个分支上:即使 Git 说我们不在任何分支上,Git 也说我们在特殊的匿名分支上。
这导致了很多混淆,如果不允许它可能更明智,但 Git 在 git rebase
中内部使用了它,因此它实际上非常重要。如果重新基于操作出了问题,这个细节将泄漏,你最终需要知道什么是“脱离状态”。
2 我故意忽略了一个困难的情况,即存在多个可能的合并基础提交时。Mercurial 和 Git 在这里使用不同的解决方案:Mercurial 随机选择一个(似乎如此),而 Git 则提供了选项。尽管这些情况很少见,但理想情况下,即使发生了这种情况,Mercurial 的简单方法也能够工作。
3实际上是两个或更多个:Git 支持“章鱼合并”的概念。但没必要去那里 :-)
合并将图形从树形结构变成 DAG
合并-真正的合并:具有两个或更多个父提交-有一堆重要的、关键的副作用。其中一个主要的作用是,合并的存在会导致提交图数据结构从一个 树 ,其中分支仅仅是分叉然后独立增长,变成一个 DAG:有向无环图。
当 Git 遍历图形时,就像它进行的许多操作一样,通常会跟随 所有 路径返回。由于合并有两个父提交,因此git log
会显示两个父提交。因此,这被认为是一个功能:
例如,Git不是单独显示“合并到主分支”或“合并主分支”,而是将所有提交消息添加进去。
Git正在跟踪和记录原始提交顺序-提交H、G、E、D等,以及合并后的提交顺序F、E、D等。当然,它只显示每个提交一次;默认情况下,按其日期时间戳排序这些提交,并交错两个分支,如果每个分支具有许多具有重叠日期的提交,则会发生这种情况。
如果您不想看到通过合并“另一侧”进行的提交,则Git有一种方法来实现:--first-parent告诉每个Git命令遍历图形仅跟随每个合并的第一个父项。另一侧仍在图形中,它仍会影响Git计算合并基础等内容,但git log --first-parent不会显示它。
合并的好处是可以在分支上继续开发,并且稍后的git merge会找到更新(因此更简单)的合并基础。 o--o--o--o--H--o--o--I <-- feature2
/ \ \
A--o--B---C-----D--E-----F--G <-- master
\ / / /
o--o--J--o--o--K--o--o--L <-- feature1
除了在根提交A
之后在master
分支上完成的两个早期提交外,所有开发都在feature1
和feature2
分支上进行。提交C
、D
、E
、F
和G
都是合并(在这种情况下,严格合并到master
),当功能准备好时将其带入master
中。
请注意,在我们在master
上进行提交C
时,我们执行了以下操作:
$ git checkout master; git merge feature1
当我们进行合并时,发现A
是合并基础,B
和J
是两个要合并的提交。在我们进行D
提交时:
$ git checkout master; git merge feature2
我们将 A
作为合并基础,C
和 H
作为两个端点提交。到目前为止,这没什么特别的。但是当我们创建 E
时,我们已经完成了这么多(最终 o
,甚至 I
在 feature2
上可能已经存在或不存在 - 它们没有影响):
o--o--o--o--H--o--o <-- feature2
/ \
A--o--B---C-----D <-- master
\ /
o--o--J--o--o--K <-- feature1
master
和feature1
的合并基础是两个分支上都有的第一个提交,即提交J
,这是我们合并成C
的那个提交。因此,为了进行这个合并,Git会比较J
与从feature2
带入的D
代码以及J
与feature1
上新代码(仅新代码)的K
。如果一切顺利,或者一旦我们解决了合并冲突,这将生成提交E
,现在我们有了: o--o--o--o--H--o--o--I <-- feature2
/ \
A--o--B---C-----D--E <-- master
\ / /
o--o--J--o--o--K--o--o <-- feature1
当我们再次合并feature2
时,Git会以提交H
为基础进行合并:从feature2
直接回溯很快就会到达H
,从master
转移到D
然后向上到H
也是如此。因此,现在 Git 比较的是 H
和我们从 feature1
带来的 E
以及 H
和我们添加到 feature2
的新内容 I
,然后合并它们。
合并的缺点
树具有一些非常好的图论性质,例如保证了单个简单合并基线。任意的有向无环图可能会丢失这些性质。特别地,双向合并——将master
合并到branch
和将branch
合并到master
——会产生“十字型合并”,从而导致多个合并基线。
合并还会使得图形(git log
)非常难以跟踪。使用--first-parent
或--simplify-by-decoration
可以帮助解决问题,尤其是如果你进行了良好的合并,但这些图形自然会变得混乱。
压缩合并
压缩合并避免了这些问题,但是代价相当高:它们根本不是合并。(很快,我们将看到如何解决这个问题。)
当你运行git merge --squash
时,Git 执行与之前相同的步骤,找到合并基础,生成两个差异文件:合并基础和当前提交的差异以及合并基础和其他提交的差异。然后,它以和普通提交一样的方式合并变更。但是接下来它只做了一个普通的提交。1新提交只有一个父提交,取自当前分支。
让我们看看在使用feature1
和feature2
的同一序列中使用--squash
的情况:
o--o--o <-- feature2
/
A--o--B <-- master
\
o--o--J <-- feature1
为了创建新的提交 C
,我们执行 git checkout master; git merge --squash feature1
命令。Git通过比较master
上的A
和B
以查看我们做了什么,以及比较feature1
上的A
和J
以查看他们(我们)做了什么。 Git合并这些更改,我们就得到了提交C
,但只有一个父节点:
o--o--o <-- feature2
/
A--o--B---C <-- master
\
o--o--J <-- feature1
现在,我们将从 feature2
创建D
作为压缩版本:
o--o--o--o--H <-- feature2
/
A--o--B---C <-- master
\
o--o--J--o--o <-- feature1
Git对比A
与C
,以及A
与H
,与上次一样。现在我们得到了D
。到目前为止,除了两个分支没有合并的点外,情况基本相同。但现在是制作E
的时候:
o--o--o--o--H--o--o <-- feature2
/
A--o--B---C-----D <-- master
\
o--o--J--o--o--K <-- feature1
像之前一样,我们运行git checkout master; git merge --squash feature1
。
上次,Git 比较了J
-vs-D
和J
-vs-K
,因为提交J
是我们的合并基础。
这一次,提交A
仍然是我们的合并基础。Git 比较A
vs D
和A
vs K
。如果我们上次在C
解决了冲突,那么我们可能需要再次解决。虽然这很糟糕,但我们还没有走投无路。
1普通的,与合并相对。因此,平滑合并根本不是合并:它是一个“让我完成工作”的提交,但它不是一个合并提交。在下一节中,我们除了这个以外还需要一个“真正的”合并提交。
Git 实际上会在这里停止,并强制您运行git commit
来进行平滑提交。为什么?谁知道呢,这就是 Git 的风格。 :-)
平滑合并可以奏效
要解决以上问题,我们只需要从master
回到feature
分支进行非平滑的“真正合并”。也就是说,不是简单地将特性分支合并到master
中,然后继续在特性分支上工作,而是这样做:
o--o--o--o--H--*-o--o <-- feature2
/ /
A--o--B---C----D <-- master
\ \
o--o--J---*--o--o--K <-- feature1
这些新提交标记为*
,是从主分支(non-squash)合并到feature1
和feature2
的。我们在C
处进行了Squash merge以获取从A
到J
所做更改。然后,我们进行了真正的合并到feature1
,最好使用直接从master
1获取的树(其中包括o--B--
中的任何好东西)。我们还在feature2
上进行了*
标记,只是一般准备,在D
上进行了标记,以将A
到H
的所有内容带入。与feature1
上的*
一样,我们可能只想要来自master
的源树。
现在我们已经准备好从feature1
中获取更多工作,我们可以进行另一个(Squash)合并。master
和feature1
的merge-base是提交C
,而两个tips是D
和K
,这正是我们想要的。Git的合并代码会得出一个相当接近的结果;我们解决任何冲突、测试、解决任何故障,并提交;然后我们再次从master
,像之前一样,进行另一个“准备工作”的合并到feature1
中。
这个工作流比“合并到主分支”要复杂一些,但应该能够产生良好的结果。
注意,我们想要的是在将内容合并到主分支时使用merge --squash
,但在从主分支合并出来时使用常规(non-squash)合并。换句话说:$ git checkout master && git merge foo
应该使用--squash
,但是:
$ git checkout foo && git merge master
不应使用--squash
。(从前一节的脚注复制树可能很好,但是应该是不必要的:合并结果基本上应该始终是直接从master
获取的树。)
当git merge
运行时,它会查看当前分支(正如它总是必须的)。 如果该分支有一个名称,如果我们不在“分离 HEAD”模式下,Git然后查看您的配置,查找存储在branch.branch.mergeOptions
下的值。 任何字符串值都将被扫描,就好像它是git merge
命令的一部分。
因此:
$ git config branch.master.mergeOptions "--squash"
(引号在技术上不是必需的,您可以在git config
之后,在branch.master.mergeOptions
之前添加--global
)将您当前的存储库设置为对master
进行合并压缩。 (使用--global
,它会将此设置为所有存储库的个人默认设置。 但是,特定存储库中设置的任何branch.master.mergeOptions
都将覆盖这些全局设置。)
git config --global merge.squash true
就足够了吗?博客文章没有讨论这个。 - jwwgit merge --no-ff
命令来创建合并提交,即使可以通过快进合并解决该问题也是如此。 - knittl