在变基期间,我将本地功能分支同步到上游分支以完成拉取请求,我尝试使用所有三种方法(git rebase,git rebase -i 和 git merge),在解决冲突时,每种方法都提供了完全不同的体验。
Git 合并一次向我展示了我所有的冲突。我解决了它们,并在解决完所有这些问题后添加了更改。不出所料,合并搞砸了我的历史,我不得不再次恢复。
Git Rebase 通过两步引导我解决了冲突。在每个中,我都添加了我的更改,然后继续变基。在这两者之间,我丢失了一个补丁,不得不重新开始。
交互式变基就像一个魅力。它引导我完成提交提交的冲突,并且在每次解决后,它再次开始从功能分支的底部快进到下一个冲突。我可以确保正确包含提交合著者,最后甚至不需要添加"合并"或"变基"提交,完成后坐在分支的开头。
我对何时使用它们中的每一个有一个概念上的理解,但是为什么即使没有交互式编辑修订版,变基和交互式变基的行为也是如此巨大不同?为什么甚至使用 git merge 和 git rebase 来,当它们似乎做得不好并且更容易搞砸历史记录中的某些东西时?
...为什么变基和交互式变基的行为如此不同
作为一般规则,他们不应该。他们有时会这样做,准确解释原因很棘手。 一个快速的底线要点是,非交互式git rebase
使用(嗯,有时使用)git format-patch
并将其输出通过管道传输到git am
,这可以,尽管通常不会,但与交互式变基做同样的事情,后者使用git cherry-pick
代替。
从历史上看,这是唯一的git rebase
形式,并且由于它的行为确实有点不同 - 并且可以更好地工作 - Git作者选择不将每个人都切换到"总是樱桃选择"的方法。
漫长而复杂的答案
为什么甚至使用 git merge 和 git rebase 来,当它们似乎做得不好并且更容易搞砸历史记录中的某些东西时?
首先,git merge
和git 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"作为形容词,或者缩写为"合并",使用"merge"作为名词 - 有两个父级:L
是第一个父级,R
是第二个父级。这些文件由合并为动词操作确定;提交本身是一个合并。 如果我们将其绘制为:
...--o--o--o--L---M <-- master (HEAD)
/
o--o--R <-- develop
然后我们可以稍后添加更多提交,此时我们可以再次运行git merge
,选择一个新L
并R
:
...--o--o--o--o---M--L <-- master (HEAD)
/
o--o--o--o--R <-- develop
这次的合并基础不是以前*
的提交,而是以前R
的提交! 因此,合并提交M
的存在会更改下一个git merge
命令的下一个合并基础。
任何变基的基础知识
git rebase
所做的是非常不同的:它标识一组要复制的提交,然后复制它们。
要复制的提交集是从当前分支(即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
,master 的提示,这通常是我们在这里选择的 — 然后要复制的提交哈希集是用于C--D--E
的提交哈希。
此集合中的某些提交可能会被故意丢弃。 这包括:
- 任何合并提交,因为它们无法复制(但这里没有——大多数情况下,这将消除从
master
合并回develop
); git patch-id
与上游提交匹配的任何提交。
后者意味着 Git 计算提交F
和G
的git patch-id
。 如果这些提交与C
、D
或E
的提交git patch-id
匹配,则这些提交将从"复制"列表中抛出。
(如果使用--fork-point
模式,Git 可能会从列表中抛出其他提交。 描述这很好是困难的。 请参阅 Git 变基 - 在分叉点模式下提交选择。
Git 现在开始复制过程。 这就是非交互式和交互式变基的不同之处。 两者都从"分离 HEAD"开始,将其设置为复制的目标。 这默认为<upstream>
提交,在我们的例子中,提交G
。
普通的非交互式方法
通常,非交互式git rebase
在选定的提交上运行git format-patch
,然后将输出馈送到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
都尝试直接应用 diff:查找上下文,验证上下文是否未更改,然后添加和删除嵌入在git format-patch
流中的git diff
输出中显示的行。
如果验证步骤失败,git apply -3
(-3
很重要)使用回退方法:格式修补程序输出中的index
行标识每个文件的合并基本版本,因此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
此过程重复提交D
和E
,得到:
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.sh
和交互式git-rebase--interactive.sh
之间的明显区别在于,后者编写了一个包含帮助文本的大指令文件,并允许您对其进行编辑。 但即使你只是按原样写出来,实现每个pick
命令的实际代码也会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
操作)在提交范围内运行,而不仅仅是在一个特定文件上运行。 这允许挑选查找在两个分支之一中重命名的文件。 然后,Git在每个文件上执行合并为动词。 完成后,如果没有冲突,Git 会从生成的文件进行普通(非合并)提交。
假设一切顺利,并且D
被复制到D'
,Git 继续挑选E
。 这次D
是合并基础。 该操作的工作方式与以前一样:我们找到重命名,将所有文件合并为动词,并进行E'
的普通非合并提交。
最后,与非交互式变基一样,Git 从旧提示提交中删除分支名称并将其放在新提示上。
非交互式与交互式的更多特点
使用git format-patch
进行非交互式变基有许多副作用。 最重要的是,git format-patch
实际上无法生成"空"补丁(不对源代码进行任何更改的提交),因此,如果您使用-k
来"保留"此类提交,则非交互式变基将使用git cherry-pick
。
第二个是,由于git format-patch
被告知--no-renames
(请参阅上面的实际命令),它将文件重命名为"删除旧文件,添加新文件"。 这可以防止 Git 发现一些冲突。 (只要要删除的文件在补丁中,它至少可以检测到删除/修改冲突,但它无法检测到删除/重命名冲突,并且在"超出"重命名的补丁中,它根本没有任何需要注意的。 而且,当然,如果我们能够构造一个补丁由于明显有效的上下文而应用的情况,即使三向合并可能会发现匹配的上下文来自代码的移动副本,我们也可以成功应用补丁,其中三向合并将检测到冲突,或将其应用于其他地方。
(我打算在某个时候构建一个示例,但一直没有时间这样做。
如果使用-m
选项,指定变基应使用合并机制,或者-s <strategy>
选项或-X <extended-option>
(两者都意味着使用合并机制),这也强制 Git 使用樱桃拾取。 然而,这实际上是第三种变基!
变基类型选择发生在git-rebase.sh
,很好地进入脚本:
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 注释
最后一点区别是 基于am
的变基不会git notes copy
运行。 另外两个这样做。 这意味着您在原始提交时所做的注释在使用git rebase
时被删除,但在使用交互式变基或git rebase -m
时保留。
(这对我来说似乎是一个错误,但也许是故意的。 保留注释会有点棘手,因为我们需要从旧的提交哈希到新的提交哈希的映射。 这需要git am
内部的支持。