为了让 Git 总是为 master 分支做一个壁球“合并”:
$ git config branch.master.mergeOptions "--squash"
说明
默认情况下,您不能让 Git 对 所有 分支执行 squash “合并”,但 可以 使其默认为 执行 squash “merge” em>一些分支。由于您对仅针对 master 实现这一点特别感兴趣,因此这可能正是您想要的。
让我们快速1回顾一下git merge 的真正作用,因为按照通常的 Git 方式,Git 使一切变得复杂。而且,这个:
我们为重要的错误修复和功能使用单独的分支。通过频繁执行git checkout <x>; git merge master,分支与master保持同步。
与许多人认为的 Git 中“正确”的工作流程相反。我对是否可以将任何 Git 工作流程称为“正确”存在一些疑问 :-) ,但有些比其他工作流程更成功,这绝对是最成功的工作流程之一的反面。 (我确实认为它可以很好地工作,如下面的扩展讨论中所述。)
1嗯,我试图保持简短。 :-) 尽管这里有很多重要的材料,但请随意略读。如果 TL;DR,直接跳到最后。
提交图
如您所知,但其他人可能不会,在 Git 中,提交图可以控制很多。每个1 提交都有一些父提交,或者在合并提交的情况下,有两个或更多父提交。为了进行新的提交,我们进入某个分支:
$ git checkout funkybranch
在工作树中做一些工作,git add 一些文件,最后git commit 将结果分支到funkybranch:
... work work work ...
$ git commit -m 'do a thing'
当前 提交是名称funkybranch 指向的(一个,单个)提交。 Git 通过读取HEAD 发现这一点:HEAD 通常包含分支的名称,而分支包含提交的原始 SHA-1 哈希 ID。
为了进行新的提交,Git 从我们所在的分支读取当前提交的 ID,将 index/staging-area 保存到存储库中,2 使用当前提交的 ID 作为新提交的父提交,并且 - 最后 - 将 new 提交的 ID 写入分支信息文件。
这是一个分支的增长方式:从一个提交开始,我们创建一个新的,然后移动分支名称以指向新的提交。当我们将其作为线性链执行时,我们会得到一个很好的线性历史:
... <- C <- D <- E <-- funkybranch
Commit E(实际上可能是 e35d9f... 或其他)是当前提交。它指向D,因为D是当我们进行E时的当前提交; D 指向 C,因为 C 在那个时候是最新的;等等。
当我们使用例如git checkout -b 创建新分支 时,我们所做的只是告诉Git 创建一个新的名称,指向一些现有的提交。通常这只是当前的提交。所以如果我们在funkybranch 和funkybranch 指向提交E 并且我们运行:
git checkout newbranch
然后我们得到这个:
... <- C <- D <- E <-- funkybranch, newbranch
也就是说,两个名称都指向提交E。 Git 知道我们现在在newbranch,因为HEAD 说newbranch。我也喜欢将它包含在这种绘图中:
... <- C <- D <- E <-- funkybranch, HEAD -> newbranch
我还喜欢以更紧凑的方式绘制图表。我们知道提交总是指向他们的父母“时间倒退”,因为在我们提交 D 之前不可能进行新的提交 E。所以这些箭头总是指向左边,我们可以画一两个破折号:
...--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 提交之外的所有提交。在大多数 Git 存储库中,只有一个根提交,即第一次提交。显然它不能有父提交,因为每个新提交的父提交都是我们进行新提交时当前的任何提交。根本没有提交,当我们进行第一次提交时,还没有当前提交。所以它变成了一个根提交,然后所有later提交都是它的孩子、孙子、曾孙等等。
2大部分“保存”工作实际上发生在每个git add。 index/staging-area 包含哈希 ID,而不是实际的文件内容:当您运行 git add 时,文件内容已被保存。这是因为 Git 的图表不仅包含提交对象,还包含存储库中的 每个 对象。这是 Git 比 Mercurial 快的部分原因(它在提交时保存文件而不是添加时间)。幸运的是,与提交图本身不同,这是用户不需要知道或关心的事情。
Git 合并
和以前一样,我们必须在某个分支上。1我们在funkybranch,所以我们都很好:
$ git merge newbranch
在这一点上,大多数人似乎认为魔法发生了。但这根本不是魔法。 Git 现在会在我们当前的提交和我们命名的提交之间找到 merge base,然后运行两个 git diff 命令。
合并基础只是2 两个分支上“共同”的第一个提交——两个 分支上的第一个提交。我们在funkybranch,它指向G。我们为 Git 提供了分支 name newbranch,它指向提交 F。所以我们正在合并提交G 和F,Git 会跟随它们的父指针,直到它到达两个分支上的提交节点。在这种情况下,commit E:commit E 是合并基础。
现在 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 父级。 first 父级(顺序很重要)是 当前提交,就像常规的非合并提交一样。 second 父级是另一个提交。一旦提交安全地写入存储库,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 或任何其他不是分支 name 的东西,它就会进入一种称为“分离 HEAD”的状态。在内部,这是通过将 SHA-1 哈希直接推入 HEAD 文件来实现的,这样 HEAD 会给出提交 ID,而不是分支的名称。但是 Git 做其他所有事情的方式使它工作起来,就好像我们在一个特殊的分支上,它的名字只是一个空字符串。它是(单个)匿名分支——或者,等效地,它是名为 HEAD 的分支。所以从某种意义上说,我们总是在一个分支上:即使 Git 说我们不在任何分支上,Git 也说我们在一个特殊的匿名分支上。
这会引起很多混乱,如果不允许这样做可能会更明智,但 Git 在 git rebase 内部使用它,所以它实际上非常重要。如果变基出错,这个细节就会泄露出去,你最终不得不知道“分离的 HEAD”是什么意思。
2我在这里故意忽略了一个困难的情况,当有多个可能的合并基础提交时会发生这种情况。 Mercurial 和 Git 在这里使用不同的解决方案:Mercurial 随机选择一个(似乎是),而 Git 为您提供选择。不过,这些情况很少见,理想情况下,即使确实发生了,Mercurial 更简单的方法仍然有效。
3实际上是两个或更多:Git 支持 章鱼合并 的概念。但是没有必要去那里。 :-)
Merge 将图从树变为 DAG
合并——真正的合并:有两个或更多父级的提交——有一堆重要的——关键的,甚至——副作用。主要的一个是合并的存在导致提交图数据结构从 树(其中分支只是分叉并自行增长)变为 DAG:有向无环图。
当 Git 遍历图形时,就像它对许多操作所做的那样,它通常会沿着 所有 路径返回。由于合并有两个父级,因此遍历图表的git log 显示了两个父级提交。因此,这被认为是一项功能:
例如,git 将添加所有提交消息,而不是单个“Merge into Master”或“Merge Master into”。
Git 遵循并因此记录原始提交序列——提交H、G、E、D 等等——和合并的提交序列F、E、D 等等。当然,它只显示一次提交;默认情况下,它按日期标记对这些提交进行排序,如果每个分支都有许多日期重叠的提交,则将两个分支混合在一起。
如果您不想看到通过合并的“另一边”进入的提交,Git 有办法做到这一点:--first-parent 告诉每一个运行的 Git 命令图1 仅跟随每个合并的 first 父级。另一边仍然在图中,它仍然会影响 Git 如何计算诸如合并基数之类的东西,但 git log --first-parent 不会显示它。
1这是相当多的 Git 命令。他们使用,或者在 git log 本身的情况下,是,git rev-list 的变体,这是 Git 的通用图形步行程序。这段代码是 push、fetch、bisect、log、blame、rebase 和许多其他代码的核心。它的文档有一系列令人眼花缭乱的选项。作为普通用户需要了解的关键是--first-parent(刚刚在此处讨论); --no-walk(完全禁止图形行走); --ancestry-path(简化源树相关工作的历史); --simplify-by-decoration(简化了 git log 输出的历史记录); --branches、--remotes 和 --tags(通过分支、远程或标签名称选择图形行走的起点); --merges 和 --no-merges(包括或排除合并提交); --since 和 --until(按日期范围限制提交);以及基本的.. 和...(两点和三点)图子集操作。
合并的好处
合并意味着分支上的开发可以在该分支上继续,而后来的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 时,到目前为止我们已经有了这么多(最后的os,甚至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 与 D(我们从 feature2 引入的代码)以及 J 与 K:new 代码(并且只有新的代码)在feature1。如果一切顺利,或者一旦我们修复了合并冲突,这将提交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 时。这次合并基础是提交H:从feature2 直接向后移动很快就会命中H,然后从E 移动到D,然后从master 向上移动到H 也会命中H .所以现在Git比较H和E,这是我们从feature1引入的,H和I,这是我们添加到feature2的新东西,并仅合并那些。
合并的缺点
树有一些非常好的图论属性,例如保证一个简单的合并基础。任意 DAG 可能会失去这些属性。特别是,以两种方式进行合并 - 将 master 合并到 branch 和 将 branch 合并到 master - 导致“交叉合并”,可以为您提供多个合并基础。 p>
合并也使图表 (git log) 很难跟踪。使用 --first-parent 或 --simplify-by-decoration 会有所帮助,尤其是在您练习良好的合并时,但这些图表自然会变得混乱。
壁球合并
Squash 合并避免了这些问题,但为此付出了相当大的代价:它们根本不是合并。 (很快,我们将看到如何处理这个问题。)
当您运行git merge --squash 时,Git 会执行与之前相同的动作来查找合并基础,并进行两个差异:合并基础与当前提交,以及合并基础与其他提交。然后它以与常规提交完全相同的方式组合更改。但随后它会进行普通提交。1新提交只有一个父级,取自当前分支。
让我们看看feature1 和feature2 对同一序列的作用:
o--o--o <-- feature2
/
A--o--B <-- master
\
o--o--J <-- feature1
我们通过git checkout master; git merge --squash feature1 进行新的提交C。 Git 比较 A 与 B 以查看我们在 master 上所做的事情,以及 A 与 J 以查看他们(我们)在 feature1 上所做的事情。 Git 结合了这些更改,我们得到了提交 C,但只有一个父级:
o--o--o <-- feature2
/
A--o--B---C <-- master
\
o--o--J <-- feature1
现在我们将D 制作成feature2 的壁球:
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 是我们的合并基础。
这一次,commit A (仍然)是我们的合并基础。 Git 比较 A 与 D,以及 A 与 K。如果上次我们在C 解决了冲突,我们可能不得不再次解决它们。这很糟糕——但我们还没有迷路。
1普通,相对于合并。因此,squash 合并根本不是合并:它是“让我完成工作”提交,但它不是 merge 提交。我们需要一个真正的合并提交另外;我们将在下一节中讨论。
Git 实际上停在这里并强制你运行git commit 来进行壁球提交。为什么?谁知道呢,是 Git。 :-)
Squash 合并可以工作
要解决上述问题,我们只需重新合并(使用非-squash“真正合并”)从master 回到feature分支机构。也就是说,我们不是简单地从任何功能分支合并到master,然后继续在功能分支上工作,而是这样做:
o--o--o--o--H--*-o--o <-- feature2
/ /
A--o--B---C----D <-- master
\ \
o--o--J---*--o--o--K <-- feature1
这些标记为* 的新提交是(非壁球)合并来自 master,到 feature1 和feature2。我们进行了 squash 合并 C 以获取从 A 到 J 所做的更改。所以我们然后真正合并到feature1,最好直接使用来自master1 的树(它也包含o--B-- 中的任何好东西)。 (我们还在feature2 上制作了*,就像一般准备一样,在制作master 上的D 以引入从A 到H 的所有内容。就像* 上的* 我们可能只是想要直接来自master 的源代码树。)
现在我们已经准备好从feature1 引入更多工作,我们可以再做一次(壁球)合并。 master和feature1的merge-base是commit C,两个tip分别是D和K,这正是我们想要的。 Git 的合并代码会得出一个相当接近的结果;我们修复任何冲突、测试、修复任何破损并提交;然后我们再做一次“准备工作”,像以前一样将 from master 返回 into feature1。
这个工作流程比“合并到主”的工作流程要复杂一些,但是应该给出好的结果。
1Git 并没有让这一切变得微不足道:我们希望与 -s theirs 策略合并,而 Git 根本没有。 有一种使用“管道”命令获得所需效果的简单方法,但我将把它排除在这个答案之外,这已经很长了。
那么,如果一切正常,那么机制呢?
请注意,当合并 到 master 时,我们想要的是merge --squash,但是当合并 from master 时,我们需要常规(非壁球)合并。换句话说:
$ git checkout master && git merge foo
应该使用--squash,但是:
$ git checkout foo && git merge master
不应该使用--squash。 (从上一节的脚注中复制的树可能很好,但应该是不必要的:合并结果基本上应该始终是直接从master 出来的树。)
当git merge 运行时,它会查看当前分支(始终必须如此)。如果那个分支有一个名字——如果我们没有处于“分离的 HEAD”模式——Git 然后会查看你的配置,寻找存储在branch.<em>branch</em>.mergeOptions 下的值。此处的任何字符串值都会被扫描,就好像它是 git merge 命令的一部分一样。
因此:
$ git config branch.master.mergeOptions "--squash"
(引号在技术上不是必需的,您可以在git config 之后添加--global,在branch.master.mergeOptions 之前)设置您当前的存储库以执行压缩合并到master。 (使用--global,它会将其设置为所有存储库的个人默认值。但是,在特定存储库中设置的任何branch.master.mergeOptions 都将覆盖这些全局设置。)