git rebase、git rebase -i 和 git merge 之间的低级差异

Low Level Difference between git rebase, git rebase -i and git merge

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

Git 合并一次向我展示了我所有的冲突。我解决了它们并在解决所有问题后添加了更改。不出所料,合并弄乱了我的历史,我不得不再次恢复。

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

交互式变基非常有效。它带领我解决了一个接一个的冲突,在每次解决之后,它又开始从功能分支的基础快速转发到下一个冲突。我可以确保正确包含提交的共同作者,最后甚至不需要添加 'merge' 或 'rebase' 提交,完成后坐在分支的头部。

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

... why exactly did the rebase and interactive rebase behave so wildly different

一般来说,他们不应该。他们有时会这样做,并且准确解释原因很棘手。一个快速的底线 take-away 是 non-interactive git rebase 使用——嗯,有时 使用——git format-patch 并将其输出通过管道传输到 git am,而这个可以,虽然通常不会,做与交互式变基相同的事情,它使用git cherry-pick代替。

从历史上看,这是 git rebase 唯一 形式,因为它 确实 表现得有点不同——并且可能工作得更好——Git 作者选择不让每个人都使用 "always cherry pick" 方法。

冗长而复杂的答案

Why are git merge and git rebase even used, when they seem to do things badly and make it easier to mess up something in the history?

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

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

请注意,我们可以 re-draw 此为:

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

强调,从提交 * 开始,所有这些提交都同时在 两个 分支上。名称 master,也是当前分支 HEAD,标识提交 L(对于 "left" 或 "local")。名称 develop 标识提交 R("right" 或 "remote")。正是这两个提交标识了它们的 parent 提交,如果我们——或 Git——仔细地向后跟踪每个 parent,这两个提交流最终会重新加入——在这种情况下是永久地——在提交 *.

关于git merge的注释,这里需要讲到rebase

运行 git merge 要求 Git 找到合并基础,即提交 *,然后将该合并基础与两个分支提示提交中的每一个进行比较L(本地或 --ours)和 R(远程或 --theirs)。 left/local 方面有什么不同,我们一定已经改变了。无论 right/remote 方面有什么不同,他们都必须改变。执行合并操作("merge" 作为动词)的合并机制结合了这两组更改。

git merge 命令(假设它像这样进行真正的合并,也就是说,你没有做 fast-forward 或挤压)以这种方式使用合并机制来计算集合应该提交的文件,然后进行新的合并提交。这种提交——使用 "merge" 作为形容词,或者缩写为 "a merge",使用 "merge" 作为名词——有两个 parent:L是第一个parent,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

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

任何 rebase 的基础知识

git rebase 的作用非常不同:它识别一些提交集以 copy,然后复制它们。

要复制的提交集是可从当前分支(即HEAD)访问的提交,不是 可从您提供的 <upstream> 参数访问:

$ 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 tip of master,这通常是我们在这里选择的——那么要复制的提交哈希集就是那些 C--D--E.

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

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

后者意味着 Git 为提交 FG 计算 git patch-id。如果那些匹配 git patch-id 的提交 CDE,这些提交将从 "to copy" 列表中丢弃。

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

Git 现在开始复制过程。这是 non-interactive 和交互式变基可能不同的地方。两者都以 "detaching HEAD" 开头,将其设置为复制的目标。这默认为 <upstream> 提交,在我们的例子中,提交 G.

正常non-interactive方法

通常,a non-interactive git rebase runs git format-patch on the selected commits, then 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重复dly 调用 git apply -3。每个 git apply 尝试直接应用差异:找到上下文,验证上下文未更改,然后添加和删除 git format-patch 流中嵌入的 git diff 输出中显示的行。

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

这个 three-way 合并可以像往常一样成功或失败。无论发生哪种情况,Git 都可以继续处理此特定补丁中的其余文件。如果所有补丁都适用——直接应用,或者作为 three-way 合并回退的结果——Git 将使用保存在 git format-patch 流中的消息文本从结果中进行提交。这会将原始提交复制到一个新的但至少略有不同的提交,其 parent 是 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 "peels the label" develop 离开旧的提交链并将其粘贴到新的提交链上。理想情况下,旧的提交被放弃,find-able 仅通过 reflogs 和临时的特殊名称 ORIG_HEAD:

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

虽然如果有其他方法可以找到旧的提交(现有的标签或指向它们的分支名称),旧的提交毕竟不会被放弃,而且你将看到旧的和新的。

交互式变基

old-stylegit-rebase--am.shinteractive git-rebase--interactive.sh is that the latter writes a big instructions file including help text, and lets you edit it. But even if you just write it out as is, the actual code to implement each pick command runs git cherry-pick的明显区别。 (这段代码在最近的Git版本中已经修改,现在用C实现,而不是shell脚本,但是shell脚本更清晰,两者应该行为相同,所以我在这里链接到脚本。)

git cherry-pick 运行 时,它 总是 进行 three-way 合并(至少在任何偶数 semi-modern Git:可能有一个旧的在某个时候使用了 git format-patch | git am -3;我对早期的不同行为记忆模糊)。这个 three-way 合并的不寻常之处在于合并基础是 parent 的提交是 cherry-picked。这意味着如果我们要复制提交 D,就像这个状态:

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

此特定 merge-as-a-verb 操作的 合并基础 未提交 *。它甚至根本不是 master 上的提交:它是 C.

上的提交

我们将C复制到C'时的合并基础是*,因为*C的parent。 那个 有道理。这个没有,至少一开始是这样。 C怎么会是合并基呢?但它是: Git 运行s git diff --find-renames C C' 为了看到 "what we changed",并将其与 git diff --find-renames C D ("what they changed").

如果这些更改中有任何重叠,我们将遇到合并冲突。如果不是,Git 将保留 "what we changed" 并简单地添加到 "what they changed"。请注意,这两个比较,这两个 git diff --find-rename 操作, 运行 commit-wide,不只是针对一个特定的文件。这允许 cherry-pick 找到在两个分支之一中重命名的文件。 Git 然后对 每个 文件执行 merge-as-a-verb。完成后,如果没有冲突,Git 从生成的文件中进行普通 (non-merge) 提交。

假设一切顺利,D 被复制到 D',Git 继续到 cherry-pick E。这次 D 是合并基础。该操作与以前一样工作:我们找到重命名,merge-as-a-verb 所有文件,并进行普通的 non-merge 提交,即 E'.

最后,与 non-interactive 变基一样,Git 从旧的 tip 提交中剥离分支名称并将其放在新的 tip 上。

non-interactive 与交互式

的更多特性

non-interactive 使用 git format-patch 变基会产生一些副作用。最重要的是 git format-patch 字面上不能产生 "empty" 补丁——一个不对源代码进行任何更改的提交——所以如果你使用 -k 到 "keep" 这样的提交, non-interactive rebase 使用 git cherry-pick.

第二个是因为git format-patch被告知--no-renames(见上面的实际命令),它表示文件重命名为"delete old file, add new file"。这可以防止 Git 发现一些冲突。 (只要to-be-deleted文件在patch里,至少能检测到delete/modify冲突,但是不能检测到delete/rename冲突,而在patches里"beyond" 重命名,它根本没有什么可注意的。)而且,当然,如果我们可以构建一个应用补丁的情况,因为 apparently-有效续xt,即使 three-way 合并可能会发现匹配的上下文来自代码的 移动副本 ,我们也可以成功应用 three-way 合并的补丁会检测到冲突,或将其应用到其他地方。

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

如果您使用 -m 选项,指定 rebase 应该使用合并机制,或者 -s <strategy> 选项或 -X <extended-option>(两者都暗示使用合并机制),这也迫使 Git 使用 cherry-pick。然而,这实际上是第三种变基!

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 时对原始提交所做的注释会被删除,但在使用交互式变基或 git rebase -m.

时会保留

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