`gitrebase`在底层是如何工作的?
我最近开始使用 git 树和临时索引文件来构建提交,而无需修改我的工作目录,目的是自动化一些任务。最终目标是有效地重新设置某个分支(例如feature/x_y_z在 之上main),但不修改工作目录来执行此操作。显然,我仍然想检测冲突,并且绝对不是破坏性的更改main(就像使用git commit-tree)。我通读了这本书的“Git Internals”一章,它对树、blob、索引等很有教育意义——但没有明确解释 rebase 是如何工作的。
(旁白:这样做的动机是1)它的方式更快,2)我想使开发人员能够揭开序幕测试设置一些提交/支,迅速,消费最新规范的更改,而不是他们的重挫工作目录)。
为此,git rebase引擎盖下是如何工作的?它使用哪些管道命令?它如何分析树以检测冲突?指向有用资源的链接和/或对这些内容的直接解释将非常有帮助。
回答
为此,git rebase 如何在幕后工作?
情况很复杂。由于历史原因,它特别复杂:它最初是一个使用git format-patchand的小型 shell 脚本git am,但它有一些缺陷,因此它被重写为一组更高级的 shell 脚本。这些包括基于合并的后端和交互式后端,将旧的am基于后端拆分为第三个变体。从那时起,它再次被重写,用 C 语言,使用 Git 称为sequencer 的东西。交互式代码也被设计为允许重新执行合并。我将忽略这些案例,因为它们更难绘制和解释。
它使用哪些管道命令?
现在它已经用 C 重写了,它不使用它们中的任何一个。
在过去,主要使用交互式后端git cherry-pick(从技术上讲,这不是一个管道命令),以及git commit --amend用于git rev-list收集提交的哈希 ID 以使用cherry-pick 复制之后的壁球操作。
现在正在修改 C 变体以构建越来越多的部分(主要是为了使 Windows 上的事情变得更快),但目前仍然单独调用合并。
它如何分析树以检测冲突?
这是 的基本工作git cherry-pick:它调用git merge但将合并基础设置为正在复制的提交的父级。此时的当前提交是为了实现rebase而进行扩展的分支顶端的提交。
也就是说,我们有一些类似的东西:
H--I--J <-- to-copy (HEAD)
/
...--o--o--o <-- optional-random-other-stuff-cluttering-up-the-diagram
A--B--C <-- target
我们要“变基”的分支是由名称标识的分支to-copy;我们希望副本出现的提交是 commit 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从副本列表中排除提交。
变基操作首先生成要复制的提交哈希 ID 列表,在这种情况下,是H通过J包含提交的实际原始哈希 ID 。
Rebase 通常会从这个列表中省略某些提交:
- 省略所有合并提交(除非使用我故意忽略的
-r或-p选项);和 - 复制列表中
git patch-id与对称差异的另一半中的提交匹配的任何提交也将被省略。1
对于大多数简单的线性提交链,这个省略步骤什么都不做;我在这里说明的提交就是这种情况。
构建了要复制的提交哈希 ID 列表后,rebase 现在将--onto目标提交检查C为分离的 HEAD。如果没有--onto参数,则目标提交是由命令upstream后面的参数指定的git rebase,或者是在 HEAD 分离步骤之前的分支上游指定的。所以,对于更复杂的--onto变体,我们现在有这个:
...--A--B--C <-- main, HEAD
D--E--F--G <-- feature1
H--I--J <-- to-copy
现在,Rebase 以适当和必要的顺序(H首先,然后I,然后J)挑选每个要复制的提交,一次一个。这些樱桃挑选操作中的每一个都被处理,就好像它是一个git merge,但具有特殊的强制合并基础提交。我稍后会详细介绍,但让我们假设精选的H作品并进行新的提交;让我们调用新的 commit 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拉出原始分支的名称J并将其粘贴到最终复制的提交上,在这种情况下J',然后重新附加 HEAD:
H'-I'-J' <-- to-copy (HEAD)
/
...--A--B--C <-- main
D--E--F--G <-- feature1
H--I--J ???
由于找不到名称commit 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,每个分支都有自己的两个提交,这些提交遵循一些以 commit 结尾的共享提交序列H。
正如您现在通过阅读 Git 内部结构而知道的,每次提交都有每个文件的完整快照。作为一个快照,而不是一组更改,有没有明显的方式查看快照的变化,直到你意识到什么混帐做的是游戏,一遍又一遍,一个游戏现货的差异。我们将两次提交放在某处,作为两个快照,然后以编程方式观察每个提交并找出发生了什么变化。这就是git diff它的作用。
现在,如果我们运行一个从 commitH到 commit的差异J,这将告诉我们这两个快照之间发生了什么变化。就其本身而言,这并不是特别有用,但假设我们将此信息保存在某处。让我们运行:
git diff --find-renames <hash-of-H> <hash-of-J> # what we changed
找出br1自 commit 以来我们所做的更改H。我们将把所有这些保存在某个地方,可能是一个临时文件或(如果我们有足够的内存)内存。
现在让我们重复这个操作,但使用提交的哈希值L:
git diff --find-renames <hash-of-H> <hash-of-L> # what they changed
这告诉我们发生了什么br2。
如果我们将更改添加到一起,注意仅获取两个分支上的任何给定更改的一个副本,并将两组更改的总和应用于快照H,我们将获得正确的合并结果。2
那么,这正是合并所做的。它只运行两个差异——--find-renames用于查找任何树范围的文件重命名操作,以便它知道old/path/to/file合并库中的文件new/name/of/it与左侧和/或右侧提示提交中的文件是“相同的文件” ——然后组合更改-sets 来自两个差异,将它们应用到每个文件。3
如果合并进展顺利,并且合并没有被 禁止--no-commit,4 Git 将继续自己进行合并提交M。J合并提交不是普通的单父级,在这种情况下是 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 通过盲目选择我们或他们的一方来解决冲突的部分。这只影响这些低级冲突:添加/添加、修改/删除,以及其他高级或树级冲突仍然导致合并停止并获得帮助。
(对于cherry-pick,这些选项目前都是通过git-merge-recursive后端处理的,没有办法选择任何其他的merge后端。for git merge,-s参数,例如git merge -s abcd,让Git尝试运行git-merge-abcd。当然没有git-merge-abcdback在git --exec-path目录中结束,所以这只会失败,但这里的重点是常规合并让你选择一个策略。递归合并只是默认的。樱桃采摘不允许策略选择。)
2当然,对于“正确”的某些定义。Git 是完全基于行的:差异是逐行的,合并是逐行的。
3好吧,无论如何,这就是高级概述。在内部,它执行一次重命名查找,然后根据需要进行高级或树级文件命名——这也处理文件创建和删除,并检测添加/添加、修改/删除、重命名/重命名和其他此类冲突 - 然后继续使用git merge-file内置的单独的第二遍来合并每个单独的三个文件组:merge-base、ours 和 theirs。合并过程发生在 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,以 commitH作为其提示提交,因此该提交H是当前提交。我们现在运行:
git cherry-pick <hash-of-C>
Git 所做的是 findC的父级P并将其用作标准合并操作的假合并基础,但请确保在合并过程结束时,我们进行正常的非合并提交:
...--o--P--C--o--...
...
...--G--H--C' <-- cur-branch (HEAD)
CommitC'最终成为 commit 的“副本” 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 行之后添加了一行,这就是我们想要在这里做的。所以这个差异是完全有道理的。但是一开始发现“我们改变了什么”似乎有点奇怪。但事实证明,这正是我们所需要的。
如果他们只更改了一个文件的一行,我们想知道:我们是否触及了该文件的同一行? 如果不是,这些变化很好地结合起来:我们采取所有我们的变化,转换所有的文件P,以匹配所有的文件H,这给了我们回到提交H; 然后我们将他们所做的一项更改添加到一个文件中,同时还需要进行任何行号调整,并添加他们在一个文件中更改的内容。所以这是完全正确的。
如果我们确实接触了该文件的同一行,则会在该行上发生合并冲突。这也完全正确。
当我们考虑所有可能的更改时——包括文件重命名之类的事情——我们会发现,确实,从Pto进行差异化H是正确的做法。这就是我们这样做的原因。这也意味着我们可以使用现有的合并代码。
当然,现有的合并代码在索引上/在索引中操作,并使用我们的工作树作为临时存储。这就是变基相对缓慢和痛苦的原因。对此进行改进的唯一真正方法是直接在内存中进行更多的合并工作。现在有人在这样做:在Git 邮件列表中搜索来自 Elijah Newren 的“merge-ort” 。