【问题标题】:Low Level Difference between git rebase, git rebase -i and git mergegit rebase、git rebase -i 和 git merge 之间的低级区别
【发布时间】:2018-04-28 17:58:34
【问题描述】:

在 rebase 期间,我将本地功能分支同步到上游分支以完成拉取请求,我尝试使用所有三种方法(git rebase、git rebase -i 和 git merge),它们中的每一个都提供了完全不同的经验,当谈到解决冲突。

Git 合并一次向我展示了我所有的冲突。我解决了它们,并在解决所有问题后添加了更改。正如预期的那样,合并搞砸了我的历史,我不得不再次恢复。

Git Rebase 分两步引导我解决冲突。在每一个中,我都添加了我的更改,然后继续进行变基。在这期间,我丢失了一个补丁,不得不重新开始。

交互式变基就像一个魅力。它引导我通过提交提交的冲突,并且在每次解决之后,它再次开始从功能分支的基础快速转发到下一个冲突。我可以确保正确地包含提交共同作者,最后甚至不需要添加“合并”或“变基”提交,完成后坐在分支的头部。

我对何时使用它们中的每一个都有一个概念性的理解,但为什么即使没有交互式编辑修订,rebase 和交互式 rebase 的行为也会如此不同?为什么 git merge 和 git rebase 甚至被使用,当它们看起来做事很糟糕并且更容易在历史上搞砸一些事情时?

【问题讨论】:

  • “果然,合并搞砸了我的历史”?合并不会影响历史记录,因此不确定您的意思。解决冲突后,提交合并,您将获得一个新的合并提交,即两个分支的已解决集成。
  • 我发现合并提交对于完成拉取请求是不可取的。一旦合并了拉取请求,它将留下两个合并提交,它们本质上是相同的。描述只是为了说明我对这三种方法的体验。

标签: git merge rebase git-interactive-rebase


【解决方案1】:

...为什么 rebase 和交互式 rebase 的行为如此不同

作为一般规则,他们不应该。他们有时会这样做,而准确地解释原因很棘手。一个快速的底线外卖是非交互式 git rebase 使用 - 嗯,有时 使用 -git format-patch 并将其输出通过管道传输到 git am,这 可以 em>,虽然通常不会,但与交互式 rebase 做同样的事情,它使用 git cherry-pick 代替。

从历史上看,这是git rebase唯一形式,并且由于它确实表现有点不同——而且可以更好地工作——Git 作者选择不切换每个人都采用“总是挑选樱桃”的方法。

冗长而复杂的答案

当 git merge 和 git rebase 似乎做得很糟糕并且更容易在历史上搞砸某些事情时,为什么还要使用它们?

首先,git mergegit rebase 有不同的目标,因此它们没有那么大的可比性。你已经知道 Git 是关于提交的,分支名称只是一种查找提交的方式——一个特定的提交,Git 从中找到所有以前的提交——但是让我们做一些术语在这里帮我们谈谈:

...--o--*--o--L   <-- master (HEAD)
         \
          o--o--R   <-- develop

请注意,我们可以将其重新绘制为:

          o--L   <-- master (HEAD)
         /
...--o--*
         \
          o--o--R   <-- develop

要强调的是,从提交* 向后,所有这些提交都同时在两个 分支上。名称master,也是当前分支HEAD,标识提交L(用于“左”或“本地”)。名称develop 标识提交R(“正确”或“远程”)。正是这两个提交标识了它们的父提交,如果我们(或 Git)小心地向后跟踪每个父提交,这两个提交流最终会重新加入(在这种情况下是永久地)在提交 *

git merge 的注意事项,我们需要谈谈变基

运行git merge 要求Git 找到合并基,即提交*,然后将该合并基与两个分支提示提交L(本地或--ours)和R 中的每一个进行比较(远程或--theirs)。无论左侧/本地有什么不同,我们一定已经改变了。无论右侧/远程有什么不同,它们一定已经改变了。合并机制,执行合并的动作(“合并”作为动词),将这两组变化结合起来。

git merge 命令(假设它像这样进行真正的合并,即您没有进行快进或挤压)以这种方式使用合并机制来计算应该提交的文件集,然后进行新的合并提交。这种提交——使用单词“merge”作为形容词,或者简称为“a merge”,使用“merge”作为名词——有两个父级:L 是第一个父级,R是第二个。 文件 由 merge-as-a-verb 动作决定;提交本身一个合并。如果我们把它画成:

...--o--o--o--L---M   <-- master (HEAD)
         \       /
          o--o--R   <-- develop

然后我们可以稍后添加更多提交,此时我们可以再次运行git merge,选择一个新的LR

...--o--o--o--o---M--L   <-- master (HEAD)
         \       /
          o--o--o--o--R   <-- develop

这次的merge base不是之前*的commit,而是之前R的commit!因此,合并提交 M 的存在改变了 next git merge 命令的 next 合并基础。

任何变基的基础

git rebase 的作用非常不同:它识别一组提交到复制,然后复制它们。

要复制的提交集是可从当前分支(即HEAD)访问的提交,而不可可从&lt;upstream&gt;访问的提交你提供的论点:

$ git checkout develop
$ git rebase <upstream-hash>   # or, easier, git rebase master

此时,Git 在内部生成一个提交哈希列表。如果提交图仍然如下所示:

...--o--*--F--G   <-- master
         \
          C--D--E   <-- develop (HEAD)

并且git rebase 的参数标识提交*master 之后的任何提交——当然包括G,master 的提示,这通常是什么我们会在这里选择——那么要复制的提交哈希集是 C--D--E 的那些。

这组中的一些提交可能会被故意丢弃。这包括:

  • 根本没有任何合并提交,因为它们不能被复制(但这里没有 - 主要是这会消除从 master 回到 develop 的任何合并);
  • git patch-id 与上游提交匹配的任何提交。

后者意味着 Git 为提交 FG 计算 git patch-id。如果这些与提交CDE 中的git patch-id 匹配,则这些提交将从“复制”列表中丢弃。

(如果使用--fork-point 模式,Git 可能会从列表中抛出额外的提交。描述这一点很困难。请参阅Git rebase - commit select in fork-point mode。)

Git 现在开始复制过程。这是非交互式和交互式 rebase 可能不同的地方。两者都从“分离 HEAD”开始,将其设置为复制的目标。这默认为&lt;upstream&gt; 提交,在我们的例子中,提交G

普通的非交互方式

通常是a non-interactive git rebase runs git format-patch on the selected commits,然后是feeds the output to git am

git format-patch -k --stdout --full-index --cherry-pick --right-only \
        --src-prefix=a/ --dst-prefix=b/ --no-renames --no-cover-letter \
        $git_format_patch_opt \
        "$revisions" ${restrict_revision+^$restrict_revision} \
        >"$GIT_DIR/rebased-patches"
...
git am $git_am_opt --rebasing --resolvemsg="$resolvemsg" \
        $allow_rerere_autoupdate \
        ${gpg_sign_opt:+"$gpg_sign_opt"} <"$GIT_DIR/rebased-patches"

这个git am 反复调用git apply -3。每个git apply 都尝试直接应用差异:找到上下文,验证上下文是否未更改,然后添加和删除嵌入在git format-patch 流中的git diff 输出中显示的行。

如果验证步骤失败,git apply -3-3 很重要)使用回退方法:format-patch 输出中的index 行标识每个的 merge base 版本文件,所以git apply 可以提取该合并基础版本,直接将补丁应用到它——这应该总是有效的——并将其用作“版本 R”。合并基础版本当然是合并基础版本,文件的当前或HEAD 版本充当“版本 L”。我们现在拥有了对那个特定文件进行常规git merge 所需的一切。 此时我们只合并一个文件,这只是“作为动词合并”。 (另见git cherry-pick下面的描述。)

这种三向合并可以像往常一样成功或失败。无论发生哪种情况,Git 都可以继续处理这个特定补丁中的其余文件。如果所有补丁都适用——无论是直接应用,还是作为三向合并回退的结果——Git 将使用保存在git format-patch 流中的消息文本从结果进行提交。这会将原始提交复制到一个新的但至少略有不同的提交,其父提交是 HEAD:

                C'   <-- HEAD
               /
...--o--*--F--G   <-- master
         \
          C--D--E   <-- develop

对于提交 DE 重复此过程,给出:

                C'-D'-E'   <-- HEAD
               /
...--o--*--F--G   <-- master
         \
          C--D--E   <-- develop

当它完成时,git rebase“剥离标签”develop 从旧的提交链上粘贴到新的提交链上。理想情况下,旧的提交被放弃,只能通过 reflogs 和临时的特殊名称 ORIG_HEAD 找到:

                C'-D'-E'   <-- develop (HEAD)
               /
...--o--*--F--G   <-- master
         \
          C--D--E   [abandoned]

虽然如果有其他方法可以找到旧提交(导致它们的现有标签或分支名称),旧提交 并没有毕竟被放弃了,你会看到旧的和新的。

交互式变基

旧式git-rebase--am.shinteractive git-rebase--interactive.sh is that the latter writes a big instructions file including help text 之间的明显区别,并允许您对其进行编辑。但即使你只是按原样写出来,actual code to implement each pick command runs git cherry-pick。 (这段代码在最新版本的Git中进行了修改,现在用C实现,而不是shell脚本,但是shell脚本更清晰,而且两者的行为应该是一样的,所以我已经链接到脚本在这里。)

git cherry-pick 运行时,它总是进行三向合并(至少在任何半现代的 Git 中:可能有一个旧的使用 git format-patch | git am -3,在某些情况下点;我对早期的不同行为有一个模糊的记忆)。这种三向合并的不同寻常之处在于,合并基础是被精心挑选的提交的父级。这意味着如果我们要复制提交D,就像在这个状态:

                C'   <-- HEAD
               /
...--o--*--F--G   <-- master
         \
          C--D--E   <-- develop

这个特定的合并作为动词操作的合并基础不是提交*。它甚至根本不是 master 上的提交:它是提交 C

我们将C 复制到C' 时的合并基是*,因为*C 的父级。 那个是有道理的。这个没有,至少一开始没有。 C 怎么可能是合并基地?但它是:Git 运行 git diff --find-renames C C' 以查看“我们改变了什么”,并将其与 git diff --find-renames C D(“他们改变了什么”)结合起来。

如果其中任何更改重叠,我们将遇到合并冲突。如果没有,Git 将保留“我们更改的内容”并简单地添加“他们更改的内容”。请注意,这两个比较,这两个git diff --find-rename 操作,在commit-wide 上运行,而不仅仅是在一个特定文件上。这允许cherry-pick 查找在两个分支之一中重命名的文件。然后 Git 对 every 文件执行合并作为动词。完成后,如果没有冲突,Git 会从结果文件中进行普通(非合并)提交。

假设一切顺利,D 被复制到 D',Git 继续挑选 E。这次D 是合并基础。该操作和以前一样工作:我们找到重命名,将所有文件合并为动词,然后进行普通的非合并提交,即E'

最后,与非交互式 rebase 一样,Git 将分支名称从旧的提示提交中剥离出来,并将其放在新的提示上。

非交互式与交互式的更多特性

使用git format-patch 进行非交互式变基会产生许多副作用。最重要的是git format-patch 字面上不能产生一个“空”补丁——一个不更改源的提交——所以如果你使用-k 来“保留”这样的提交,非交互式变基使用git cherry-pick .

第二个是因为git format-patch被告知--no-renames(见上面的实际命令),它表示文件重命名为“删除旧文件,添加新文件”。这可以防止 Git 发现一些冲突。 (只要要删除的文件在补丁中,它至少可以检测到删除/修改冲突,但它无法检测到删除/重命名冲突,并且在补丁“超越”重命名时,它会没有什么需要注意的。)当然,如果我们可以构造一个补丁应用的情况,因为 显然 - 有效的上下文,即使三向合并可能会发现匹配上下文来自代码的移动副本,我们可以成功地应用一个补丁,其中三向合并可以检测到冲突,或者将其应用到其他地方。

(我打算在某个时候构建一个示例,但一直没有时间去做。)

如果你使用-m 选项,指定rebase 应该使用合并机制,或者-s &lt;strategy&gt; 选项或-X &lt;extended-option&gt;(这两者都暗示使用合并机制),这也会强制Git 使用cherry-挑选。然而,这实际上是第三种变基!

The rebase type-selection happens in git-rebase.sh, well into the script:

if test -n "$interactive_rebase"
then
        type=interactive
        state_dir="$merge_dir"
elif test -n "$do_merge"
then
        type=merge
        state_dir="$merge_dir"
else
        type=am
        state_dir="$apply_dir"
fi

请注意,隐藏状态文件的位置,跟踪您是否处于正在进行的 git rebase 的中间,该 git rebase 已停止让您编辑(交互式变基)或由于冲突(任何变基),变化取决于变基的类型。

Git 笔记

最后一点不同是基于am 的rebase 不运行git notes copy。其他两个可以。这意味着在使用 git rebase 时会删除您在原始提交上所做的注释,但在使用交互式 rebase 或 git rebase -m 时会保留。

(这对我来说似乎是一个错误,但也许是故意的。保存注释会有点棘手,因为我们需要从旧提交哈希到新提交哈希的映射。这需要 git am 内部的支持。)

【讨论】:

  • 真棒答案!谢谢。
猜你喜欢
  • 2018-03-05
  • 2013-05-15
  • 2020-08-24
  • 1970-01-01
  • 2021-02-05
  • 1970-01-01
  • 2021-10-14
  • 1970-01-01
  • 2015-03-21
相关资源
最近更新 更多