...为什么变基和交互式变基的行为如此不同
作为一般规则,他们不应该。他们有时会这样做,而准确地解释原因很棘手。一个快速的底线外卖是非交互式git rebase
使用 - 嗯,有时使用 - <code>git format-patch 并将其输出通过git am
管道传输到rebase,而是使用git cherry-pick
。
从历史上看,这是.git rebase
冗长而复杂的答案
为什么 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
强调一下,从*
向后提交,所有这些提交同时在两个分支上。namemaster
也是 current branch HEAD
,用于标识提交L
(对于“左”或“本地”)。该名称develop
标识提交R
(“正确”或“远程”)。正是这两个提交标识了它们的父提交,如果我们(或 Git)小心地向后跟踪每个父提交,这两个提交流最终会重新加入(在这种情况下是永久地)在 commit 处*
。
上的注释git merge
,我们需要讨论变基
Runninggit merge
要求 Git 找到合并基础,即 commit *
,然后将该合并基础与两个分支提示提交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
再次运行,选择一个新的L
and 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
——当然,包括masterG
的提示,这通常是我们在这里选择的——然后要复制的提交哈希集是那些 for C--D--E
。
这组中的一些提交可能会被故意丢弃。这包括:
- 根本就没有任何合并提交,因为它们不能被复制(但这里没有——大多数情况下这会消除从
master
back into的任何合并develop
);
- 任何
git patch-id
与上游提交匹配的提交。
后者意味着 Git 计算git patch-id
提交F
和G
. 如果那些与git patch-id
commits C
、D
或匹配E
,则这些 commits 将从“to copy”列表中丢弃。
(如果--fork-point
使用模式,Git 可能会从列表中抛出额外的提交。很难描述这一点。请参阅Git rebase - commit select in fork-point mode。)
Git 现在开始复制过程。这是非交互式和交互式 rebase 可能不同的地方。两者都从“分离 HEAD”开始,将其设置为复制的目标。这默认为<upstream>
提交,在我们的例子中是 commit 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
尝试直接应用差异:找到上下文,验证上下文是否未更改,然后添加和删除git diff
嵌入在git format-patch
流中的输出中显示的行。
如果验证步骤失败,git apply -3
(这-3
很重要)使用回退方法:index
format-patch 输出中的行标识每个文件的合并基础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
old-style和interactivegit-rebase--interactive.sh
之间的明显区别在于后者编写了一个包含帮助文本的大指令文件,并允许您对其进行编辑。但即使你只是按原样写出来,实现每个pick
命令的实际代码也会运行git cherry-pick
。(这段代码在最新版本的Git中已经修改,现在用C实现,而不是shell脚本,但是shell脚本更清晰,而且两者的行为应该是一样的,所以我已经链接到脚本这里。)
运行时git cherry-pick
,它总是进行三向合并(至少在任何甚至半现代的 Git 中:可能有一个旧的git format-patch | git am -3
在某个时候使用过;我对早期的不同行为有一个模糊的记忆)。这种三向合并的不同寻常之处在于,合并基础是被精心挑选的提交的父级。这意味着如果我们要复制 commit D
,就像在这种状态下:
C' <-- HEAD
/
...--o--*--F--G <-- master
\
C--D--E <-- develop
此特定合并作为动词操作的合并基础*
不是 commit 。它甚至根本不是一个提交master
:它是 commit 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 对每个文件执行合并作为动词。完成后,如果没有冲突,Git 会从结果文件中进行普通(非合并)提交。
假设一切顺利,D
并被复制到D'
,Git 继续进行 cherry-pick E
。这次D
是合并基地。该操作与以前一样工作:我们找到重命名,将所有文件合并为动词,然后进行普通的非合并提交,即E'
.
最后,与非交互式 rebase 一样,Git 将分支名称从旧的提示提交中剥离出来,并将其放在新的提示上。
非交互式与交互式的更多特性
使用git format-patch
. 最重要的是它git format-patch
实际上不能产生一个“空”补丁——一个不对源代码进行任何更改的提交——所以如果你-k
用来“保留”这样的提交,非交互式 rebase 使用git cherry-pick
.
第二个是因为git format-patch
被告知--no-renames
(见上面的实际命令),它代表一个文件重命名为“删除旧文件,添加新文件”。这可以防止 Git 发现一些冲突。(只要要删除的文件在补丁中,它至少可以检测到删除/修改冲突,但它无法检测到删除/重命名冲突,并且在补丁“超越”重命名时,它会没有什么要注意的。)当然,如果我们可以构建一个补丁应用的情况,因为显然是有效的上下文,即使三向合并可能会发现匹配的上下文来自移动的副本代码,我们可以成功地应用一个补丁,其中三向合并可以检测到冲突,或者将其应用到其他地方。
(我打算在某个时候构建一个示例,但一直没有时间去做。)
如果你使用-m
选项,指定 rebase 应该使用合并机制,或者一个-s <strategy>
选项或-X <extended-option>
(这两者都暗示使用合并机制),这也会强制 Git 使用cherry-pick。然而,这实际上是第三种变基!
变基类型选择发生在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
基于 rebase 不会运行git notes copy
。其他两个可以。这意味着您在原始提交上所做的注释在使用时会被删除git rebase
,但在使用交互式变基或时会保留git rebase -m
。
(这对我来说似乎是一个错误,但也许是故意的。保留注释会有点棘手,因为我们需要从旧提交哈希到新提交哈希的映射。这需要内部支持git am
。)