如何使--squash成为合并的默认选项?

7
我们使用单独的分支来修复非微不足道的错误和添加功能。通过频繁执行git checkout <x>; git merge master,使得这些分支与主分支保持同步。
我注意到在合并时,Git 会用多个、无关紧要的消息污染日志文件。例如,Git 不会只添加一个“将<X>合并到主分支”或“将主分支合并到<X>”,而是会添加所有提交消息。这是 Master 上治理(流程和规程)的问题,因为开发过程中可能存在但在主分支上从未存在过的错误。
更糟糕的是,不同分支和主分支之间的行为不同。当将主分支合并到分支时,会生成类似于“将主分支合并到<X>”的日志条目。然而,当将分支合并到主分支时,没有“将<X>合并到主分支”的记录。根据日志,就好像开发分支从未存在过,合并也从未发生过。
我了解到我需要做一些特殊的事情才能让 Git 表现符合预期;即如何使用 git merge --squash?(这是 Git 的经典工作方式:把简单的东西变得困难)。
我的问题是,如何使--squash成为合并的默认操作?

http://build.pixelunion.net/automatically-squash-and-fixup-your-git-rebase/ - Bryce Drew
请原谅我的无知,Bryce... git config --global merge.squash true 就足够了吗?博客文章没有讨论这个。 - jww
很抱歉,我不知道。这似乎是一个针对你遇到的问题的复制粘贴解决方案,所以我为你提供了链接。我没有在我的机器上尝试复制它。无论如何,如果你计划在git中进行任何不确定的操作,你应该备份你的数据。 - Bryce Drew
关于您的“没有合并就消失的开发分支”问题,请使用git merge --no-ff命令来创建合并提交,即使可以通过快进合并解决该问题也是如此。 - knittl
@knittl - 我们停止使用Git开发分支。现在所有这些问题都已经解决了。我使用理由问题无法再现投出了第一个关闭投票。 - jww
2个回答

17

要让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   <-- funkybranch

提交 E (实际上可能是e35d9f ...或其他)是当前提交。它指向D,因为在我们进行E提交时D 是当前提交; 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

我也喜欢以更紧凑的方式绘制我的图表。我们知道提交总是“向后指向”它们的父级,因为在创建提交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 没有移动:它仍然指向提交 FHEAD 也没有改变:它仍然包含名称为 funkybranch。只有 funkybranch 发生了变化:它现在指向新的合并提交 HH 又同时指向了 GF


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分支上完成的两个早期提交外,所有开发都在feature1feature2分支上进行。提交CDEFG都是合并(在这种情况下,严格合并到master),当功能准备好时将其带入master中。

请注意,在我们在master上进行提交C时,我们执行了以下操作:

$ git checkout master; git merge feature1

当我们进行合并时,发现A是合并基础,BJ是两个要合并的提交。在我们进行D提交时:

$ git checkout master; git merge feature2

我们将 A 作为合并基础,CH 作为两个端点提交。到目前为止,这没什么特别的。但是当我们创建 E 时,我们已经完成了这么多(最终 o ,甚至 Ifeature2 上可能已经存在或不存在 - 它们没有影响):

  o--o--o--o--H--o--o           <-- feature2
 /             \
A--o--B---C-----D               <-- master
 \       /
  o--o--J--o--o--K              <-- feature1
masterfeature1的合并基础是两个分支上都有的第一个提交,即提交J,这是我们合并成C的那个提交。因此,为了进行这个合并,Git会比较J与从feature2带入的D代码以及Jfeature1代码(仅新代码)的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新提交只有一个父提交,取自当前分支。

让我们看看在使用feature1feature2的同一序列中使用--squash的情况:

  o--o--o                       <-- feature2
 /
A--o--B                         <-- master
 \ 
  o--o--J                       <-- feature1

为了创建新的提交 C,我们执行 git checkout master; git merge --squash feature1 命令。Git通过比较master上的AB以查看我们做了什么,以及比较feature1上的AJ以查看他们(我们)做了什么。 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对比AC,以及AH,与上次一样。现在我们得到了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-DJ-vs-K,因为提交J是我们的合并基础。

这一次,提交A仍然是我们的合并基础。Git 比较A vs DA 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)合并到feature1feature2的。我们在C处进行了Squash merge以获取从AJ所做更改。然后,我们进行了真正的合并到feature1,最好使用直接从master1获取的树(其中包括o--B--中的任何好东西)。我们还在feature2上进行了*标记,只是一般准备,在D上进行了标记,以将AH的所有内容带入。与feature1上的*一样,我们可能只想要来自master的源树。
现在我们已经准备好从feature1中获取更多工作,我们可以进行另一个(Squash)合并。masterfeature1的merge-base是提交C,而两个tips是DK,这正是我们想要的。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都将覆盖这些全局设置。)


7
td,dr:$ git配置分支.master.mergeOptions“--squash” - Joanvo
2
@Joanvo:是的,我试图在一开始就用“跳到结尾”这个词来表明。 - torek
我已经在顶部添加了命令,其余的帖子标记为“解释”。 - M. Justin

0

最终我创建了一个bash shell命令别名,如下所示

alias gmg='git merge --squash'

此外,我也不希望更改自动进行,因此最终创建了以下Bash函数。
gitmerge() {
        git merge --squash "$1"
        git restore --staged .
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接