`git rebase` 是如何工作的?
How does `git rebase` work under the hood?
我最近开始使用 git 树和临时索引文件来构建提交,而无需修改我的工作目录,以实现某些任务的自动化。最终目标是有效地变基某些分支(例如 main
之上的 feature/x_y_z
),但无需修改工作目录即可。显然我仍然想检测冲突,绝对不是 main
上的破坏性变化(就像使用 git commit-tree
一样)。我通读了 "Git Internals" chapter of the book,它对树、blob、索引等很有教育意义——但没有明确解释 rebase 是如何工作的。
(旁白:这样做的动机是 1)它 方式 更快,并且 2)我想让开发人员能够快速启动某些 commit/branch 的测试,使用最新的规范更改,而不会破坏其工作目录。)
为此,git rebase
是如何工作的?它使用什么管道命令?它如何分析树来检测冲突?指向有用资源的链接,and/or 对这些内容的直接解释,将非常有帮助。
To that end, how does git rebase work under the hood?
这很复杂。由于历史原因,它特别复杂:它最初是一个使用 git format-patch
和 git am
的小 shell 脚本,但它有一些缺陷,所以它被重写为一组更漂亮的 [=338] =] 脚本。其中包括一个基于合并的后端和一个交互式后端,将旧的基于 am
的后端拆分为第三个变体。从那时起,它又被用 C 语言重写,以使用 Git 所谓的 sequencer。交互式代码也被设计为允许重新执行合并。我将忽略这些案例,因为它们更难描绘和解释。
What plumbing commands does it use?
既然已经用C重写了,就不用它们了。
在过去,交互式后端主要使用 git cherry-pick
(从技术上讲这不是一个管道命令),在使用 git rev-list
之后加上 git commit --amend
用于压缩操作收集要使用 cherry-pick 复制的提交的哈希 ID。
现在正在修改 C 变体以构建越来越多的部分(主要是为了让事情在 Windows 上运行得更快)但目前仍然单独调用合并。
How does it analyze trees to detect conflicts?
这是 git cherry-pick
的基础工作:它调用 git merge
但将 merge base 设置为正在复制的提交的父级。此时的current commit是为了实现rebase而扩展的分支顶端的commit。
也就是说,我们有一些类似的东西:
H--I--J <-- to-copy (HEAD)
/
...--o--o--o <-- optional-random-other-stuff-cluttering-up-the-diagram
\
A--B--C <-- target
我们要“变基”的分支是由名称 to-copy
标识的分支;我们希望副本出现在提交 C
之后的提交。所以我们 运行:
git checkout to-copy
为了确保我们从正确的地方开始,然后 运行:
git rebase target
或者,如果我们得到的是这样的:
...--A--B--C <-- main
\
D--E--F--G <-- feature1
\
H--I--J <-- to-copy (HEAD)
我们只想复制 H-I-J
到 C
之后登陆,我们 运行:
git rebase --onto main feature1
以便从复制列表中排除提交 D-E
。
rebase 操作首先生成要复制的提交哈希 ID 列表,在这种情况下,提交的实际原始哈希 ID H
到 J
(含)。
Rebase 通常会从此列表中省略某些提交:
- 所有 merge 提交都被省略(除非使用我故意忽略的
-r
或 -p
选项);和
- 待复制列表中
git patch-id
与对称差异的另一半中的提交相匹配的任何提交也将被忽略。1
对于大多数简单的线性提交链,这个省略步骤根本不做任何事情;我在这里说明的提交就是这种情况。
建立要复制的提交哈希 ID 列表后,rebase 现在将 --onto
目标提交 C
作为 分离的 HEAD 签出。如果没有 --onto
参数,则目标提交是由 upstream
命令后的 git rebase
参数指定的,或者是由 git rebase
命令指定的在 HEAD 分离步骤之前的分支上游。因此,对于更复杂的 --onto
变体,我们现在有:
...--A--B--C <-- main, HEAD
\
D--E--F--G <-- feature1
\
H--I--J <-- to-copy
Rebase 现在以适当和必要的顺序挑选每个要复制的提交,一次一个,(H
首先,然后 I
,然后 J
).这些 cherry-pick 操作中的每一个都被当作 git merge
来处理,但是有一个特别强制的合并基础提交。我稍后会详细介绍,但我们假设 H
的 cherry-pick 有效并进行了新的提交;让我们将新提交称为 H'
,以表明它是 H
的“副本”,并将其绘制在:
H' <-- HEAD
/
...--A--B--C <-- main
\
D--E--F--G <-- feature1
\
H--I--J <-- to-copy
我们现在用 I
和 J
重复这个得到:
H'-I'-J' <-- HEAD
/
...--A--B--C <-- main
\
D--E--F--G <-- feature1
\
H--I--J <-- to-copy
一旦要复制的最后一个提交被复制,git rebase
将原始分支的 name 从原始提交 J
并将其粘贴到指向最终复制的提交,在本例中为 J'
,然后重新附加 HEAD:
H'-I'-J' <-- to-copy (HEAD)
/
...--A--B--C <-- main
\
D--E--F--G <-- feature1
\
H--I--J ???
由于没有 name 来查找提交 J
,它从我们的视图中消失了,现在似乎 Git 以某种方式更改了三个提交。 (它还没有——原来的 仍在存储库中。您可以通过 reflogs 或通过 ORIG_HEAD
找到它们,尽管 rebase 的 C 重写引入了一个错误,其中 ORIG_HEAD
有时是错误的。)
1实际使用的对称差是HEAD...target
,差不多。 (因为它是对称的,您可以交换左右两侧,只要您记得哪一侧是哪一侧即可。)所以这些是计算了补丁 ID 的提交。 Git 甚至可以为合并提交计算补丁 ID,尽管 rebase 通常会忽略合并。我从来没有深入研究过当你告诉它复制合并时它是否计算它们,以及在这种情况下如果合并提交 确实 有重复项会发生什么,但那是一个有趣的问题。
Git 的合并引擎
要理解 cherry-picking,让我们从更正常的日常操作开始:真正的合并。当我们进行真正的合并时,我们合并工作。假设我们有以下提交图:
I--J <-- br1 (HEAD)
/
...--G--H
\
K--L <-- br2
也就是说,我们有两个分支,br1
和 br2
,每个分支都有自己的两个提交,它们遵循一些共享的提交序列,以提交 H
结束。
正如您现在通过阅读 Git 内部信息知道的那样,每个提交都有一个 每个文件的完整快照 。作为 快照,不是一组更改,没有明显的方法来 查看 快照 as 更改, 直到你意识到 Git 所做的是一遍又一遍地玩 Spot the Difference 的游戏。我们将两个提交放在某个地方,作为两个快照,然后以编程方式观察每个提交并弄清楚发生了什么变化。这就是 git diff
所做的。
现在,如果我们 运行 从提交 H
到提交 J
的差异,这将告诉我们这两个快照之间发生了什么变化。就其本身而言,这并不是特别有用,但假设我们将此信息保存 某处。让我们 运行:
git diff --find-renames <hash-of-H> <hash-of-J> # what we changed
找出自提交 H
以来我们在 br1
上所做的更改。我们会将所有这些保存在某个地方,可能是在一个临时文件或(如果我们有足够的内存)内存中。
现在让我们重复这个操作但是使用提交的散列L
代替:
git diff --find-renames <hash-of-H> <hash-of-L> # what they changed
这告诉我们 br2
.
上发生了什么
如果我们将更改加在一起,请注意只对两个分支上的任何给定更改只取一份副本,然后应用对快照H
的两组更改的总和,我们将得到正确的合并结果。2
这正是 merge 所做的。它只是 运行s 两个 差异——使用 --find-renames
查找任何树范围的文件重命名操作,以便它知道文件 old/path/to/file
在合并基础与左侧 and/or 右侧提示提交中的 new/name/of/it
是“同一文件”——然后合并来自两个差异的变更集,将它们应用到每个文件。 3
如果合并顺利,并且合并没有被 --no-commit
抑制,4 Git 将继续进行合并提交 M
自己。合并提交有 两个 父项,而不是正常的单父项,在本例中是提交 J
。第一个是正常的,第二个是另一个branch-tip commit,commit L
:
I--J
/ \
...--G--H M <-- br1 (HEAD)
\ /
K--L <-- br2
合并完成。
如果有 冲突,Git 会在其索引(扩展的插槽保持扩展) 和 中留下混乱你的工作树:git merge-file
的内置等价物已经在你的工作树文件上涂鸦,以对正确的合并进行最佳猜测,加上合并冲突标记和两个部分 - 或者 merge.conflictStyle
设置为 diff3
,所有三个存在合并冲突的输入文件。
请注意,使用 -X ours
或 -X theirs
告诉 Git 通过盲目地选择我们或他们的一方来解决冲突的部分。这只会影响这些 low-level 冲突:add/add、modify/delete 和其他 high-level 或 树级 冲突仍然导致合并停止并寻求帮助。
(对于cherry-pick,这些选项目前都是通过git-merge-recursive
后端处理的,无法选择任何其他合并后端。对于git merge
,-s
参数,例如 git merge -s abcd
,使得 Git 尝试 运行 git-merge-abcd
。当然 [=96= 中没有 git-merge-abcd
后端] 目录,所以这只会失败,但这里的重点是常规合并让你 select 一个策略。递归合并只是 默认值 。樱桃采摘不允许策略 selection.)[=122=]
2对于“正确”的一些定义,当然。 Git 完全基于行:差异是逐行进行的,合并是逐行进行的。
3好吧,这就是 高级概述。在内部,它一次完成重命名查找,然后根据需要进行高级或树级文件命名——这也处理文件创建和删除,并检测 add/add、modify/delete、rename/rename,以及其他类似的冲突——然后继续使用单独的第二遍,其中内置了 git merge-file
来合并每个单独的三个文件组:merge-base、我们的和他们的。合并过程发生在 Git 的 index 中,该索引被临时扩展为最多容纳每个文件的三个副本,并使用槽号来区分哪个是合并基础版本(插槽 1),哪个是 --ours
版本(插槽 2),哪个是 --theirs
版本(插槽 3)。
4注意--squash
开启--no-commit
目前没有办法再次关闭,所以--squash
总是需要手册 git commit
最后。
cherry-pick 如何使用 Git 的合并引擎
为了实现精选,Git 只需 运行 将其合并引擎与强制父级合并。
假设我们有这个提交图:
...--o--P--C--o--...
...
...--G--H <-- cur-branch (HEAD)
我们在当前分支 cur-branch
上,提交 H
作为其尖端提交,因此提交 H
是当前提交。我们现在 运行:
git cherry-pick <hash-of-C>
Git 所做的是找到 C
的父 P
并将其用作标准合并操作的假合并基础,但要确保在合并过程,我们进行 正常的非合并提交:
...--o--P--C--o--...
...
...--G--H--C' <-- cur-branch (HEAD)
提交 C'
最终成为提交 C
的“副本”。要了解原因,让我们看看出现了什么差异。
git diff --find-renames <hash-of-P> <hash-of-H> # what we changed
git diff --find-renames <hash-of-P> <hash-of-C> # what they changed
现在,找到“他们改变了什么”似乎很自然。如果他们在某个文件的第 42 行之后添加了一行,这就是我们在这里要做的。所以这个差异非常有意义。但是一开始发现“我们改变了什么”似乎有点奇怪。但事实证明,这正是我们所需要的。
如果他们只更改了一个文件的一行,我们想知道:我们是否触及了该文件的同一行?如果没有,这些更改很好地结合在一起:我们采取所有 our 更改,转换 all P
中的文件以匹配 all 文件在 H
中,这让我们返回提交 H
;然后我们将他们所做的一项更改添加到一个文件中,同时还需要进行任何行号调整,并将他们所做的更改添加到一个文件中。所以完全正确。
如果我们都接触了该文件的同一行,我们就会在这一行上发生合并冲突。说的也对。
当我们考虑所有可能的更改时——包括文件重命名之类的事情——我们会发现,确实,从 P
到 H
进行差异化是正确的做法。这就是我们这样做的原因。这也意味着我们可以使用现有的合并代码。
当然,现有的合并代码操作on/in索引,并使用我们的工作树作为临时存储。这就是为什么 rebase 相对缓慢且痛苦的原因。对此进行改进的唯一真正方法是直接在内存中执行更多合并工作。现在有人在这样做:在 the Git mailing list.
中搜索来自 Elijah Newren 的“merge-ort”
我最近开始使用 git 树和临时索引文件来构建提交,而无需修改我的工作目录,以实现某些任务的自动化。最终目标是有效地变基某些分支(例如 main
之上的 feature/x_y_z
),但无需修改工作目录即可。显然我仍然想检测冲突,绝对不是 main
上的破坏性变化(就像使用 git commit-tree
一样)。我通读了 "Git Internals" chapter of the book,它对树、blob、索引等很有教育意义——但没有明确解释 rebase 是如何工作的。
(旁白:这样做的动机是 1)它 方式 更快,并且 2)我想让开发人员能够快速启动某些 commit/branch 的测试,使用最新的规范更改,而不会破坏其工作目录。)
为此,git rebase
是如何工作的?它使用什么管道命令?它如何分析树来检测冲突?指向有用资源的链接,and/or 对这些内容的直接解释,将非常有帮助。
To that end, how does git rebase work under the hood?
这很复杂。由于历史原因,它特别复杂:它最初是一个使用 git format-patch
和 git am
的小 shell 脚本,但它有一些缺陷,所以它被重写为一组更漂亮的 [=338] =] 脚本。其中包括一个基于合并的后端和一个交互式后端,将旧的基于 am
的后端拆分为第三个变体。从那时起,它又被用 C 语言重写,以使用 Git 所谓的 sequencer。交互式代码也被设计为允许重新执行合并。我将忽略这些案例,因为它们更难描绘和解释。
What plumbing commands does it use?
既然已经用C重写了,就不用它们了。
在过去,交互式后端主要使用 git cherry-pick
(从技术上讲这不是一个管道命令),在使用 git rev-list
之后加上 git commit --amend
用于压缩操作收集要使用 cherry-pick 复制的提交的哈希 ID。
现在正在修改 C 变体以构建越来越多的部分(主要是为了让事情在 Windows 上运行得更快)但目前仍然单独调用合并。
How does it analyze trees to detect conflicts?
这是 git cherry-pick
的基础工作:它调用 git merge
但将 merge base 设置为正在复制的提交的父级。此时的current commit是为了实现rebase而扩展的分支顶端的commit。
也就是说,我们有一些类似的东西:
H--I--J <-- to-copy (HEAD)
/
...--o--o--o <-- optional-random-other-stuff-cluttering-up-the-diagram
\
A--B--C <-- target
我们要“变基”的分支是由名称 to-copy
标识的分支;我们希望副本出现在提交 C
之后的提交。所以我们 运行:
git checkout to-copy
为了确保我们从正确的地方开始,然后 运行:
git rebase target
或者,如果我们得到的是这样的:
...--A--B--C <-- main
\
D--E--F--G <-- feature1
\
H--I--J <-- to-copy (HEAD)
我们只想复制 H-I-J
到 C
之后登陆,我们 运行:
git rebase --onto main feature1
以便从复制列表中排除提交 D-E
。
rebase 操作首先生成要复制的提交哈希 ID 列表,在这种情况下,提交的实际原始哈希 ID H
到 J
(含)。
Rebase 通常会从此列表中省略某些提交:
- 所有 merge 提交都被省略(除非使用我故意忽略的
-r
或-p
选项);和 - 待复制列表中
git patch-id
与对称差异的另一半中的提交相匹配的任何提交也将被忽略。1
对于大多数简单的线性提交链,这个省略步骤根本不做任何事情;我在这里说明的提交就是这种情况。
建立要复制的提交哈希 ID 列表后,rebase 现在将 --onto
目标提交 C
作为 分离的 HEAD 签出。如果没有 --onto
参数,则目标提交是由 upstream
命令后的 git rebase
参数指定的,或者是由 git rebase
命令指定的在 HEAD 分离步骤之前的分支上游。因此,对于更复杂的 --onto
变体,我们现在有:
...--A--B--C <-- main, HEAD
\
D--E--F--G <-- feature1
\
H--I--J <-- to-copy
Rebase 现在以适当和必要的顺序挑选每个要复制的提交,一次一个,(H
首先,然后 I
,然后 J
).这些 cherry-pick 操作中的每一个都被当作 git merge
来处理,但是有一个特别强制的合并基础提交。我稍后会详细介绍,但我们假设 H
的 cherry-pick 有效并进行了新的提交;让我们将新提交称为 H'
,以表明它是 H
的“副本”,并将其绘制在:
H' <-- HEAD
/
...--A--B--C <-- main
\
D--E--F--G <-- feature1
\
H--I--J <-- to-copy
我们现在用 I
和 J
重复这个得到:
H'-I'-J' <-- HEAD
/
...--A--B--C <-- main
\
D--E--F--G <-- feature1
\
H--I--J <-- to-copy
一旦要复制的最后一个提交被复制,git rebase
将原始分支的 name 从原始提交 J
并将其粘贴到指向最终复制的提交,在本例中为 J'
,然后重新附加 HEAD:
H'-I'-J' <-- to-copy (HEAD)
/
...--A--B--C <-- main
\
D--E--F--G <-- feature1
\
H--I--J ???
由于没有 name 来查找提交 J
,它从我们的视图中消失了,现在似乎 Git 以某种方式更改了三个提交。 (它还没有——原来的 仍在存储库中。您可以通过 reflogs 或通过 ORIG_HEAD
找到它们,尽管 rebase 的 C 重写引入了一个错误,其中 ORIG_HEAD
有时是错误的。)
1实际使用的对称差是HEAD...target
,差不多。 (因为它是对称的,您可以交换左右两侧,只要您记得哪一侧是哪一侧即可。)所以这些是计算了补丁 ID 的提交。 Git 甚至可以为合并提交计算补丁 ID,尽管 rebase 通常会忽略合并。我从来没有深入研究过当你告诉它复制合并时它是否计算它们,以及在这种情况下如果合并提交 确实 有重复项会发生什么,但那是一个有趣的问题。
Git 的合并引擎
要理解 cherry-picking,让我们从更正常的日常操作开始:真正的合并。当我们进行真正的合并时,我们合并工作。假设我们有以下提交图:
I--J <-- br1 (HEAD)
/
...--G--H
\
K--L <-- br2
也就是说,我们有两个分支,br1
和 br2
,每个分支都有自己的两个提交,它们遵循一些共享的提交序列,以提交 H
结束。
正如您现在通过阅读 Git 内部信息知道的那样,每个提交都有一个 每个文件的完整快照 。作为 快照,不是一组更改,没有明显的方法来 查看 快照 as 更改, 直到你意识到 Git 所做的是一遍又一遍地玩 Spot the Difference 的游戏。我们将两个提交放在某个地方,作为两个快照,然后以编程方式观察每个提交并弄清楚发生了什么变化。这就是 git diff
所做的。
现在,如果我们 运行 从提交 H
到提交 J
的差异,这将告诉我们这两个快照之间发生了什么变化。就其本身而言,这并不是特别有用,但假设我们将此信息保存 某处。让我们 运行:
git diff --find-renames <hash-of-H> <hash-of-J> # what we changed
找出自提交 H
以来我们在 br1
上所做的更改。我们会将所有这些保存在某个地方,可能是在一个临时文件或(如果我们有足够的内存)内存中。
现在让我们重复这个操作但是使用提交的散列L
代替:
git diff --find-renames <hash-of-H> <hash-of-L> # what they changed
这告诉我们 br2
.
如果我们将更改加在一起,请注意只对两个分支上的任何给定更改只取一份副本,然后应用对快照H
的两组更改的总和,我们将得到正确的合并结果。2
这正是 merge 所做的。它只是 运行s 两个 差异——使用 --find-renames
查找任何树范围的文件重命名操作,以便它知道文件 old/path/to/file
在合并基础与左侧 and/or 右侧提示提交中的 new/name/of/it
是“同一文件”——然后合并来自两个差异的变更集,将它们应用到每个文件。 3
如果合并顺利,并且合并没有被 --no-commit
抑制,4 Git 将继续进行合并提交 M
自己。合并提交有 两个 父项,而不是正常的单父项,在本例中是提交 J
。第一个是正常的,第二个是另一个branch-tip commit,commit L
:
I--J
/ \
...--G--H M <-- br1 (HEAD)
\ /
K--L <-- br2
合并完成。
如果有 冲突,Git 会在其索引(扩展的插槽保持扩展) 和 中留下混乱你的工作树:git merge-file
的内置等价物已经在你的工作树文件上涂鸦,以对正确的合并进行最佳猜测,加上合并冲突标记和两个部分 - 或者 merge.conflictStyle
设置为 diff3
,所有三个存在合并冲突的输入文件。
请注意,使用 -X ours
或 -X theirs
告诉 Git 通过盲目地选择我们或他们的一方来解决冲突的部分。这只会影响这些 low-level 冲突:add/add、modify/delete 和其他 high-level 或 树级 冲突仍然导致合并停止并寻求帮助。
(对于cherry-pick,这些选项目前都是通过git-merge-recursive
后端处理的,无法选择任何其他合并后端。对于git merge
,-s
参数,例如 git merge -s abcd
,使得 Git 尝试 运行 git-merge-abcd
。当然 [=96= 中没有 git-merge-abcd
后端] 目录,所以这只会失败,但这里的重点是常规合并让你 select 一个策略。递归合并只是 默认值 。樱桃采摘不允许策略 selection.)[=122=]
2对于“正确”的一些定义,当然。 Git 完全基于行:差异是逐行进行的,合并是逐行进行的。
3好吧,这就是 高级概述。在内部,它一次完成重命名查找,然后根据需要进行高级或树级文件命名——这也处理文件创建和删除,并检测 add/add、modify/delete、rename/rename,以及其他类似的冲突——然后继续使用单独的第二遍,其中内置了 git merge-file
来合并每个单独的三个文件组:merge-base、我们的和他们的。合并过程发生在 Git 的 index 中,该索引被临时扩展为最多容纳每个文件的三个副本,并使用槽号来区分哪个是合并基础版本(插槽 1),哪个是 --ours
版本(插槽 2),哪个是 --theirs
版本(插槽 3)。
4注意--squash
开启--no-commit
目前没有办法再次关闭,所以--squash
总是需要手册 git commit
最后。
cherry-pick 如何使用 Git 的合并引擎
为了实现精选,Git 只需 运行 将其合并引擎与强制父级合并。
假设我们有这个提交图:
...--o--P--C--o--...
...
...--G--H <-- cur-branch (HEAD)
我们在当前分支 cur-branch
上,提交 H
作为其尖端提交,因此提交 H
是当前提交。我们现在 运行:
git cherry-pick <hash-of-C>
Git 所做的是找到 C
的父 P
并将其用作标准合并操作的假合并基础,但要确保在合并过程,我们进行 正常的非合并提交:
...--o--P--C--o--...
...
...--G--H--C' <-- cur-branch (HEAD)
提交 C'
最终成为提交 C
的“副本”。要了解原因,让我们看看出现了什么差异。
git diff --find-renames <hash-of-P> <hash-of-H> # what we changed
git diff --find-renames <hash-of-P> <hash-of-C> # what they changed
现在,找到“他们改变了什么”似乎很自然。如果他们在某个文件的第 42 行之后添加了一行,这就是我们在这里要做的。所以这个差异非常有意义。但是一开始发现“我们改变了什么”似乎有点奇怪。但事实证明,这正是我们所需要的。
如果他们只更改了一个文件的一行,我们想知道:我们是否触及了该文件的同一行?如果没有,这些更改很好地结合在一起:我们采取所有 our 更改,转换 all P
中的文件以匹配 all 文件在 H
中,这让我们返回提交 H
;然后我们将他们所做的一项更改添加到一个文件中,同时还需要进行任何行号调整,并将他们所做的更改添加到一个文件中。所以完全正确。
如果我们都接触了该文件的同一行,我们就会在这一行上发生合并冲突。说的也对。
当我们考虑所有可能的更改时——包括文件重命名之类的事情——我们会发现,确实,从 P
到 H
进行差异化是正确的做法。这就是我们这样做的原因。这也意味着我们可以使用现有的合并代码。
当然,现有的合并代码操作on/in索引,并使用我们的工作树作为临时存储。这就是为什么 rebase 相对缓慢且痛苦的原因。对此进行改进的唯一真正方法是直接在内存中执行更多合并工作。现在有人在这样做:在 the Git mailing list.
中搜索来自 Elijah Newren 的“merge-ort”