git的“ rebase --preserve-merges”到底做了什么(为什么?)


355

Git的命令文档rebase非常简短:

--preserve-merges
    Instead of ignoring merges, try to recreate them.

This uses the --interactive machinery internally, but combining it
with the --interactive option explicitly is generally not a good idea
unless you know what you are doing (see BUGS below).

那么当您使用时实际上会发生什么--preserve-merges呢?它与默认行为(没有该标志)有何不同?“重新创建”合并是什么意思,等等。


20
警告:从Git 2.18(2018年第二季度,5年后)开始,git --rebase-merges最终将取代旧版本git --preserve-merges。请参阅下面的答案
VonC

Answers:


464

与普通的git rebase一样,git with --preserve-merges首先标识在提交图的一部分中进行的提交的列表,然后在另一部分的顶部重播这些提交。--preserve-merges选择要重播的提交与合并提交的重播如何工作有关的差异。

要更明确地了解普通和保留合并基准之间的主要区别:

  • 保留合并的rebase愿意重播(某些)合并提交,而普通的rebase则完全忽略合并提交。
  • 因为它愿意重播合并提交,所以保留合并的rebase必须定义它的含义重播合并提交的,并处理一些额外的问题
    • 从概念上讲,最有趣的部分可能是在选择新提交的合并父母应该是什么。
    • 重播合并提交还需要显式签出特定的提交(git checkout <desired first parent>),而正常的重新设置不必为此担心。
  • 保留合并的rebase考虑要重播的较浅的提交集:
    • 特别是,它将仅考虑重播自最近合并基础以来的重做提交(即,两个分支最近的时间),而正常的重定基础可能重播提交,可以追溯到两个分支的一次背离。
    • 暂时不明确的是,我相信这最终是一种筛选出重放已“合并到”合并提交中的“旧提交”的方法。

首先,我将尝试“足够准确地”描述rebase的--preserve-merges功能,然后将提供一些示例。如果看起来更有用,那么当然可以从示例开始。

“简要”中的算法

如果您真的想深入研究杂草,请下载git源并浏览文件git-rebase--interactive.sh。(Rebase不是Git C核心的一部分,而是用bash编写的。而且,在后台,它与“交互式rebase”共享代码。)

但是在这里,我将概述我认为其实质的内容。为了减少需要考虑的事情,我采取了一些自由措施。(例如,我不会尝试以100%的准确度捕获进行计算的精确顺序,而忽略一些不太集中的话题,例如,如何处理分支之间已经挑剔的提交)。

首先,请注意,非保留的变基相当简单。或多或少:

Find all commits on B but not on A ("git log A..B")
Reset B to A ("git reset --hard A") 
Replay all those commits onto B one at a time in order.

底垫--preserve-merges较为复杂。这就像我能够做到的一样简单,而且不会丢失看起来很重要的东西:

Find the commits to replay:
  First find the merge-base(s) of A and B (i.e. the most recent common ancestor(s))
    This (these) merge base(s) will serve as a root/boundary for the rebase.
    In particular, we'll take its (their) descendants and replay them on top of new parents
  Now we can define C, the set of commits to replay. In particular, it's those commits:
    1) reachable from B but not A (as in a normal rebase), and ALSO
    2) descendants of the merge base(s)
  If we ignore cherry-picks and other cleverness preserve-merges does, it's more or less:
    git log A..B --not $(git merge-base --all A B)
Replay the commits:
  Create a branch B_new, on which to replay our commits.
  Switch to B_new (i.e. "git checkout B_new")
  Proceeding parents-before-children (--topo-order), replay each commit c in C on top of B_new:
    If it's a non-merge commit, cherry-pick as usual (i.e. "git cherry-pick c")
    Otherwise it's a merge commit, and we'll construct an "equivalent" merge commit c':
      To create a merge commit, its parents must exist and we must know what they are.
      So first, figure out which parents to use for c', by reference to the parents of c:
        For each parent p_i in parents_of(c):
          If p_i is one of the merge bases mentioned above:
            # p_i is one of the "boundary commits" that we no longer want to use as parents
            For the new commit's ith parent (p_i'), use the HEAD of B_new.
          Else if p_i is one of the commits being rewritten (i.e. if p_i is in R):
            # Note: Because we're moving parents-before-children, a rewritten version
            # of p_i must already exist. So reuse it:
            For the new commit's ith parent (p_i'), use the rewritten version of p_i.
          Otherwise:
            # p_i is one of the commits that's *not* slated for rewrite. So don't rewrite it
            For the new commit's ith parent (p_i'), use p_i, i.e. the old commit's ith parent.
      Second, actually create the new commit c':
        Go to p_1'. (i.e. "git checkout p_1'", p_1' being the "first parent" we want for our new commit)
        Merge in the other parent(s):
          For a typical two-parent merge, it's just "git merge p_2'".
          For an octopus merge, it's "git merge p_2' p_3' p_4' ...".
        Switch (i.e. "git reset") B_new to the current commit (i.e. HEAD), if it's not already there
  Change the label B to apply to this new branch, rather than the old one. (i.e. "git reset --hard B")

--onto C参数重新设置应该非常相似。与其在B的HEAD上开始提交回放,不如在C的HEAD上开始提交回放。(并使用C_new而不是B_new。)

例子1

例如,以提交图

  B---C <-- master
 /                     
A-------D------E----m----H <-- topic
         \         /
          F-------G

m是与父母E和G的合并提交。

假设我们使用正常的不保留合并的基准在主数据库(C)上重新建立主题(H)。(例如,checkout主题; rebase master。)在这种情况下,git将选择以下提交进行重播:

  • 选择D
  • 选择E
  • 选择F
  • 选择G
  • 选择H

然后像这样更新提交图:

  B---C <-- master
 /     \                
A       D'---E'---F'---G'---H' <-- topic

(D'是D的重播等,等等。)

请注意,未选择合并提交m进行重播。

如果我们改为在--preserve-mergesC的基础上对H进行重新基准(例如,checkout主题; rebase --preserve-merges master。)在这种新情况下,git将选择以下提交进行重播:

  • 选择D
  • 选择E
  • 选择F(在“ subtopic”分支中的D上)
  • 选择G(在“子主题”分支中的F上)
  • 选择合并分支“子主题”为主题
  • 选择H

现在我选中进行重放。另请注意,在合并提交m之前已选择合并父E和G进行包含。

这是生成的提交图:

 B---C <-- master
/     \                
A      D'-----E'----m'----H' <-- topic
        \          / 
         F'-------G'

同样,D'是D的精选版本(即重新创建的版本)。E'相同,依此类推。每次重播不在master上的提交。E和G(m的合并父级)都已重新创建为E'和G',以用作m'的父级(重新设置基数后,树的历史仍保持不变)。

例子2

与普通的rebase不同,保留合并的rebase可以创建上游头的多个子代。

例如,考虑:

  B---C <-- master
 /                     
A-------D------E---m----H <-- topic
 \                 |
  ------- F-----G--/ 

如果我们将H(主题)基于C(主)作为基础,那么选择用于基础的提交是:

  • 选择D
  • 选择E
  • 选择F
  • 选择G
  • 选择米
  • 选择H

结果是这样的:

  B---C  <-- master
 /    | \                
A     |  D'----E'---m'----H' <-- topic
       \            |
         F'----G'---/

例子3

在以上示例中,合并提交及其两个父级都是重播的提交,而不是原始合并提交所具有的原始父级。但是,在其他基准库中,重播的合并提交可能以合并之前已经存在于提交图中的父级结束。

例如,考虑:

  B--C---D <-- master
 /    \                
A---E--m------F <-- topic

如果我们将主题重新设置为主主题(保留合并),则重播的提交将为

  • 选择合并提交m
  • 选择F

重写的提交图将如下所示:

                     B--C--D <-- master
                    /       \             
                   A-----E---m'--F'; <-- topic

在这里重播的合并提交m'获得了在提交图中预先存在的父级,即D(主控的HEAD)和E(原始合并提交m的父级之一)。

例子4

在某些“空提交”情况下,保留合并的rebase可能会造成混乱。至少只有某些旧版本的git(例如1.7.8)是正确的。

采取以下提交图:

                   A--------B-----C-----m2---D <-- master
                    \        \         /
                      E--- F--\--G----/
                            \  \
                             ---m1--H <--topic

请注意,提交m1和m2都应合并了B和F的所有更改。

如果我们尝试将git rebase --preserve-mergesH(主题)执行到D(母版),则选择以下提交进行重播:

  • 选择m1
  • 选择H

请注意,在m1中合并的更改(B,F)应该已经合并到D中。(这些更改应该已经合并到m2中,因为m2将B和F的子级合并在一起。)因此,从概念上讲,在m1的顶部重播m1 D可能应该是空操作或创建一个空的提交(即,连续修订之间的差异为空的提交)。

但是,相反,git可能会拒绝尝试在D顶部重播m1的尝试。您可能会得到如下错误:

error: Commit 90caf85 is a merge but no -m option was given.
fatal: cherry-pick failed

看起来好像忘记了将标志传递给git,但是潜在的问题是git不喜欢创建空的提交。


6
我注意到这比不使用git rebase --preserve-merges慢得多。这是找到正确提交的副作用吗?有什么办法可以加快速度吗?(顺便说一句……感谢您的详细回答!)rebase--preserve-merges
David Alan Hjelle 2013年

7
听起来您应该始终使用--preserve-merges。否则,可能会丢失历史记录,即合并提交。
DarVar 2013年

19
@DarVar您始终会在重新基准上释放历史记录,因为您声称对实际代码所在的位置进行了更改。
慢性的

5
这仍然是“临时答案”吗?
安德鲁·格林

5
@Chronial当然,您是对的,重新定级总是包含松散的历史记录,但是DarVar可能暗示了这样一个事实,即您不仅会松散历史记录,而且还会更改代码库。冲突解决方案包含的信息可能会以所有可能的方式丢失,而无法进行重新设置基准。您始终必须重做。真的没有办法让git重做您的冲突解决方案吗?为什么不能git cherry-pick合并提交?
Nils_M 2015年

94

Git 2.18(2018年第二季度)将--preserve-merge通过添加新选项大大改善该选项。

git rebase学习” --rebase-merges提交图的整个拓扑移植到其他地方

(注:Git 2.22,Q2 2019,实际上 已弃用 --preserve-merge,而Git 2.25(2020年第一季度)停止在“ git rebase --help”输出中投放广告

提交25cff9f提交7543f6f提交1131ec9提交7ccdf65提交537e7d6提交a9be29c提交8f6aed7提交1644c73提交d1e8b01提交4c68e7d提交9055e40提交cb5206e提交a01c2a5提交2f6b1d1提交bf5c057(2018年4月25日)由Johannes Schindelin(dscho
参见Stefan Beller()的commit f431d73(2018年4月25日stefanbeller
参见Phillip Wood(提交2429335(2018年4月25日(由Junio C Hamano合并--commit 2c18e6a中,2018年5月23日)phillipwood
gitster

pull:接受--rebase-merges以重新创建分支拓扑

preserve仅将--preserve-merges 选项传递给rebase命令的merges模式相似,该模式仅将选项传递给模式 --rebase-merges

这将使用户在拉动新提交时可以方便地为非平凡的提交拓扑建立基础,而无需对其进行展平。


git rebase手册页现在有一个完整的章节,致力于通过合并来重新建立历史

提取:

开发人员可能想要重新创建合并提交有正当的理由:在多个相互关联的分支上工作时,保留分支结构(或“提交拓扑”)。

在以下示例中,开发人员在一个重构按钮定义方式的主题分支上工作,并在另一个使用该重构实现“报告错误”按钮的主题分支上工作。
的输出git log --graph --format=%s -5可能如下所示:

*   Merge branch 'report-a-bug'
|\
| * Add the feedback button
* | Merge branch 'refactor-button'
|\ \
| |/
| * Use the Button class for all buttons
| * Extract a generic Button class from the DownloadButton one

开发人员可能希望master 在保留分支拓扑的同时将这些提交重新设置为较新的版本,例如,当第一个主题分支预计master要比第二个分支早得多集成时,例如,解决合并冲突和对该DownloadButton类所做的更改 它成master

可以使用--rebase-merges选项执行此基准。


参见commit 1644c73的一个小例子:

rebase-helper --make-script:引入一个标志来重新合并

音序器刚刚学习了旨在重建分支结构的新命令(本质上与相似--preserve-merges,但设计基本没有中断)。

让我们允许通过rebase--helper使用由新--rebase-merges选项触发的这些命令来生成待办事项列表。
对于这样的提交拓扑(HEAD指向C):

- A - B - C (HEAD)
    \   /
      D

生成的待办事项列表如下所示:

# branch D
pick 0123 A
label branch-point
pick 1234 D
label D

reset branch-point
pick 2345 B
merge -C 3456 D # C

有什么区别--preserve-merge
提交8f6aed7说明:

曾几何时,这名开发人员曾想过:如果说,如果将Git for Windows核心Git之上的补丁表示为分支的灌木丛,并在核心Git之上重新建立基础,那会不会很好?维护一套可挑选的补丁系列?

回答此问题的最初尝试是:git rebase --preserve-merges

但是,该实验从来没有打算用作交互选项,它只是背负着它,git rebase --interactive因为该命令的实现看起来已经非常非常熟悉:它是由--preserve-merges真正设计您的人设计的。

并以“真正的您”来指称dscho自己: Johannes Schindelin(,这是我们拥有Windows的Git的主要原因(与其他一些英雄– Hannes,Steffen,Sebastian等)。追溯到2009年的今天-这并不容易()。自2015年9月以来
他一直在Microsoft工作,考虑到Microsoft现在大量使用Git并需要他的服务,这很有意义。 这种趋势实际上始于2013年的TFS。从那时起,Microsoft管理着地球上最大的Git存储库!而且,自2018年10月以来,微软收购了GitHub

您可以 2018年4月的Git Merge 2018 视频中看到Johannes的讲话

一段时间后,其他开发人员(我在看着你,安德里亚斯!;-))决定,允许--preserve-merges--interactive(附带警告!)和Git维护者(以及临时Git维护者)结合使用是一个好主意。这是在Junio不在期间引起的,那是当--preserve-merges设计的魅力开始迅速而毫不掩饰地瓦解的时候。

乔纳森(Jonathan)在这里谈论苏斯(Suse)的安德里亚斯·施瓦布Andreas Schwab)
您可以在2012年看到他们的一些讨论

原因?--preserve-merges模式下,未明确说明合并提交(或就此而言,任何提交)的父级,但 由传递给命令的提交名隐含pick

例如,这使得无法重新排列提交
更不用说在分支之间移动提交,或者,神禁止将主题分支分成两个。

las,这些缺点还阻止了该模式(其最初目的是为Windows的Git服务,另外希望它也可能对其他人有用)无法为Windows的Git服务。

五年后,当在Git for Windows中拥有一个笨拙,庞大的,杂乱无章的补丁系列变得不切实际时,它不时地基于核心Git的标签(引起了开发者的不义之怒)不幸的 git-remote-hg系列最初使Git for Windows的竞争方法过时,后来被弃置而没有维护人员)确实站不住脚,“ Git花园剪诞生了:一个脚本,在交互式资源库的基础上piggy带支持,首先确定要重新定标的补丁的分支拓扑,创建伪待办事项列表以进行进一步编辑,然后将结果转换为实际待办事项列表(大量使用exec 命令以“实施”缺少的待办事项列表命令),最后在新的基本提交之上重新创建补丁系列。

(此补丁在提交9055e40中引用了Git花园剪脚本)

那是在2013年。
花了大约三周的时间才提出设计并将其实现为树外脚本。毋庸置疑,实现需要相当长的时间才能稳定下来,而设计本身就证明了自己的合理性。

有了这个补丁,Git花园剪的优点就体现git rebase -i出来了
传递该--rebase-merges选项将生成一个待办事项列表,该列表很容易理解,并且很明显如何重新排列提交
可以通过插入label命令并调用来引入新分支merge <label>
并且一旦该模式变得稳定并被普遍接受,我们就可以摒弃以前的设计错误--preserve-merges


Git 2.19(Q3 2018)--rebase-merges通过使其与一起改进了新选项--exec

--exec”选项“ git rebase --rebase-merges”将exec命令放在错误的位置,该位置已得到纠正。

提交1ace63b(2018年8月9日),并提交f0880f7通过(2018年8月6日)约翰内斯Schindelin( )dscho
(通过合并JUNIOÇ滨野- gitster-提交750eb11 8月20日2018)

rebase --exec:使其与 --rebase-merges

的想法--execexec在每个之后追加一个呼叫pick

自从引入fixup!/ s quash!commits 以来,该思想已扩展到适用于“选择,可能后跟一个修正/压扁链”,即,不会在a pick及其任何对应的 fixupor squash行之间插入exec 。

当前实现使用一个卑鄙的手段来实现这一目标:它假设有只挑选/修正/壁球命令,然后 插入exec之前的任何线pick,但第一,并附加最后一节。

随着所产生的待办事项列表git rebase --rebase-merges,这个简单的实现显示了它的问题:它产生的时候有确切的错误的东西labelresetmerge命令。

让我们更改实现以完全实现我们想要的功能:查找 pick行,跳过所有修正/压扁链,然后插入exec line。泡沫,冲洗,重复。

注意:我们会尽一切可能注释行之前插入,因为空提交由注释掉的选择行表示(并且我们希望在该行之前而不是之后插入之前的选择的exec行)。

同时,还要execmerge命令后添加行,因为它们在本质上与pick命令相似:它们添加新的提交。


Git 2.22(2019年第二季度)修复了使用refs / rewrite /层次结构来存储变基中间状态的问题,这固有地使每个工作树成为层次结构。

参见commit b9317d5提交90d31ff提交09e6564通过(2019年3月7日)阮泰玉维战(pclouds
(通过合并JUNIOÇ滨野- gitster-提交917f2cd,2019年4月9日)

确保ref / rewrite /是每个工作树

a9be29c(序列器:由labelworktree-local命令生成的make refs,2018-04-25,Git 2.19)添加refs/rewritten/为每个工作树参考空间。
不幸的是(我不好),有几个地方需要更新以确保它确实是每个工作树。

- add_per_worktree_entries_to_dir()已更新以确保引用列表查看每个工作树refs/rewritten/而不是每个回购目录。

  • common_list[]已更新,以便git_path()返回正确的位置。这包括“ rev-parse --git-path”。

这个烂摊子是我创造的。
我开始尝试通过引入refs/worktree,所有参照均按工作树进行处理而无需特殊处理的方法来修复它。
不幸的ref / rewrite早于ref / worktree,所以这就是我们所能做的。


在Git 2.24(2019年第四季度)中,“ git rebase --rebase-merges”学会了驱动不同的合并策略并将策略特定的选项传递给他们。

参见Elijah Newren(提交476998d(2019年9月4日。 请参阅提交e1fac53提交a63f990提交5dcdd74提交e145d99提交4e6023b提交f67336d提交a9c7107提交b8c6f24提交d51b771提交c248d32提交8c1e240提交5efed0e(由Junio C Hamano合并-newren
提交68b54f6提交2e7bbac提交6180b20提交d5b581f(31 2019年7月)约翰内斯Schindelin( 。dscho -提交917a319,2019年9月18日)
gitster


在Git 2.25(2020年第一季度)中,用于区分工作树本地引用和存储库全局引用的逻辑是固定的,以促进保留合并。

提交f45f88b提交c72fc40提交8a64881提交7cb8c92提交e536b1f通过(2019年10月21日)SZEDER的Gabor( )szeder
(通过合并JUNIOÇ滨野- gitster-提交db806d7,二零一九年十一月十日)

path.c:不要在match没有值的情况下调用函数trie_find()

签名人:SZEDERGábor

'logs / refs'不是工作树特定的路径,但是自从提交b9317d55a3(确保refs / rewrite /是每个工作树,2019-03-07,v2.22.0-rc0)' git rev-parse --git-path'已经返回了伪造的路径如果尾随'/ ':

$ git -C WT/ rev-parse --git-path logs/refs --git-path logs/refs/
/home/szeder/src/git/.git/logs/refs
/home/szeder/src/git/.git/worktrees/WT/logs/refs/

我们使用 trie数据结构来有效地确定路径是属于公共目录还是特定于工作树的路径。

发生这种情况时,b9317d55a3触发了一个与trie实现本身一样古老的错误,已在4e09cf2acf中添加(“ path:优化公共目录检查”,2015-08-31,Git v2.7.0-rc0- 合并批次2中列出)。

  • 根据描述的注释trie_find(),它仅应为给定的匹配函数'fn'调用“ trie包含值的键的/-或-\ 0终止的前缀”。
    这是不正确的:trie_find()在三个地方调用match函数,但其​​中之一缺少对值是否存在的检查。

  • b9317d55a3trie:添加了两个新密钥:

    • ' logs/refs/rewritten
    • ' logs/refs/worktree,旁边已存在的' logs/refs/bisect'。
      这导致trie了路径为“ logs/refs/” 的节点,该节点以前不存在,并且没有附加值。
      对“ logs/refs/” 的查询找到了该节点,然后命中了该match函数的那个​​不检查该值是否存在的调用站点,从而match使用NULLas值调用该函数。
  • 使用值调用该match函数时,它返回0,这表明查询的路径不属于公共目录,最终导致上面显示的虚假路径。check_common()NULL

将缺少的条件添加到,trie_find()这样它就永远不会调用不存在值的match函数。

check_common() 然后将不再需要检查它是否具有非NULL值,因此删除该条件。

我相信没有其他途径可以导致类似的虚假输出。

AFAICT导致使用NULL值调用match函数的唯一其他键是' co'(由于键' common'和' config')。

但是,由于它们不在属于公共目录的目录中,因此预期会出现生成的工作树特定路径。


3
我认为这应该是最好的答案,--preserve-merges实际上并没有根据需要“保留”合并,这非常幼稚。这使您可以保留合并提交及其父提交关系,同时为您提供交互式变基的灵活性。这个新功能很棒,如果不是写得很好的答案,我将不知道!
egucciar

@egucciar谢谢。这不是Git 2.18(stackoverflow.com/search?q=user%3A6309+%22git+2.18%22)和Git 2.19(stackoverflow.com/search?q=user%3A6309+%22git+2.19%的唯一功能22日
VonC

1
如果您要像本次问答中那样移动大量提交,则非常有用,stackoverflow.com
questions

1
哦,那确实是我一段时间以来一直在寻找的东西!对于这种情况,我有一个手动的解决方法,在这种情况下,应该创建一个虚拟的提交来加入所有合并。
Carnicer

典型的Git。敢问一个简单的问题,您很可能必须学习Git的历史,内部算法,所有凌乱的实现细节,并且还需要图形理论专业才能了解正在发生的事情。
Dimitris
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.