【问题标题】:How does `git rebase` work under the hood?`git rebase` 如何在后台工作?
【发布时间】:2021-03-21 07:36:28
【问题描述】:

我最近开始使用 git 树和临时索引文件来构建提交,而无需修改我的工作目录,以实现某些任务的自动化。最终目标是有效地重新定位某些分支(例如 feature/x_y_zmain 之上),但无需修改工作目录来执行此操作。显然,我仍然想检测冲突,并且绝对不会破坏main 上的更改(因为可以使用git commit-tree)。我通读了"Git Internals" chapter of the book,它对树、blob、索引等很有教育意义——但没有明确解释变基是如何工作的。

(旁白:这样做的动机是 1)它方式更快,并且 2)我希望开发人员能够快速启动一些提交/分支的测试,使用最新的规范更改,而无需破坏他们的工作目录。)

为此,git rebase 如何在幕后工作?它使用什么管道命令?它如何分析树以检测冲突?指向有用资源的链接和/或对这些内容的直接解释会非常有帮助。

【问题讨论】:

  • 无论您尝试做什么,有可能只拥有一个基于相同存储库的单独工作目录(在 git speak 中称为工作树)并在其上使用所有正常命令会更容易.
  • 你说得对,它会更容易。但是,“easy for me”不符合我“fast for users”的要求。事实证明,使用文件系统重复不必要地复制文件效率并不高。
  • 您的速度目标可能只有通过成为 Git 中正在进行的开发序列的早期采用者才能实现:内存合并。但是,一旦我完成它,请查看我的答案。 :-)

标签: git git-rebase git-plumbing


【解决方案1】:

为此,git rebase 如何在后台工作?

这很复杂。由于历史原因,它特别复杂:它最初是一个使用 git format-patchgit am 的小型 shell 脚本,但它有一些缺陷,因此它被重写为一组更精美的 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 但将合并基数 设置为被复制提交的父级。此时的current 提交是为了实现rebase 被扩展的分支尖端的提交。

也就是说,我们有一些类似的东西:

    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 列表,在这种情况下,提交 HJ (含)的实际原始哈希 ID。

Rebase 通常会从这个列表中省略某些提交:

  • 所有 merge 提交都被省略(除非使用我故意忽略的-r-p 选项);和
  • to-copy 列表中git patch-id 与对称差异另一半中的提交匹配的任何提交也将被忽略。1

对于大多数简单的线性提交链,这个省略步骤根本没有任何作用;我在这里说明的提交就是这种情况。

构建了要复制的提交哈希 ID 列表后,rebase 现在将 --onto 目标提交 C 作为一个分离的 HEAD 签出。如果没有--onto参数,则目标提交是git rebase命令之后的upstream参数指定的,或者是分支上游在HEAD-detaching之前指定的步。所以,对于更复杂的--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 的樱桃选择有效并进行了新的提交;让我们将新提交称为H',以表明它是H 的“副本”,并将其绘制在:

             H'  <-- HEAD
            /
...--A--B--C   <-- main
      \
       D--E--F--G   <-- feature1
           \
            H--I--J   <-- to-copy

我们现在用IJ 重复此操作以获得:

             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 的合并引擎

要理解樱桃采摘,让我们从一个更正常的日常操作开始:真正的合并。当我们进行真正的合并时,我们是在合并工作。假设我们有以下提交图:

          I--J   <-- br1 (HEAD)
         /
...--G--H
         \
          K--L   <-- br2

也就是说,我们有两个分支,br1br2,每个分支都有自己的两个提交,它们遵循一些以提交 H 结尾的共享提交序列。

正如您现在通过阅读 Git 内部知识知道的那样,每个提交都有一个每个文件的完整快照。作为一个快照,而不是一组更改,没有明显的方法可以查看快照随着更改,直到您意识到 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 所做的。它只运行两个 diff——使用--find-renames 来查找任何树范围的文件重命名操作,以便它知道合并库中的文件old/path/to/filenew/name/of/it 是“同一个文件”在左侧和/或右侧提示提交中,然后组合来自两个差异的更改集,将它们应用到每个文件。3

如果合并顺利,并且合并没有被--no-commit 抑制,4Git 将继续自己进行合并提交M。而不是普通的单亲,在这种情况下是提交J,合并提交有两个双亲。第一个是普通的,第二个是另一个分支提示commit,commit L:

          I--J
         /    \
...--G--H      M   <-- br1 (HEAD)
         \    /
          K--L   <-- br2

合并完成。

如果存在 冲突,Git 会在其索引(扩展的插槽保持扩展)您的工作树中留下一团糟:内置相当于 @987654395 @ 已在您的工作树文件上乱涂乱画,以便对正确的合并进行最佳猜测,加上合并冲突标记和两个部分 - 或将 merge.conflictStyle 设置为 diff3,所有三个输入文件是合并冲突。

请注意,使用 -X ours-X theirs 会告诉 Git 通过盲目地选择我们或他们的一方来解决冲突的部分。这只会影响这些低级冲突:添加/添加、修改/删除,其他高级树级冲突仍然会导致合并以停止并获得帮助。

(对于cherry-pick,这些选项目前都通过git-merge-recursive后端处理,无法选择任何其他合并后端。对于git merge-s参数,例如,@ 987654403@,让Git尝试运行git-merge-abcd。当然git --exec-path目录中没有git-merge-abcd后端,所以这只会失败,但这里的重点是常规合并可以让你选择一个策略。合并只是默认。樱桃采摘不允许策略选择。)


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,提交H作为它的提示提交,所以提交H是当前提交。我们现在运行:

git cherry-pick <hash-of-C>

Git 所做的是找到 C 的父 P 并将其用作标准合并操作的假合并基础,但请确保在合并过程结束时,我们使 normal ,非合并提交:

...--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 行之后添加了一行,这就是我们想要在这里做的。所以这个差异很有意义。但一开始发现“我们改变了什么”似乎有点奇怪。但事实证明,这正是我们所需要的。

如果他们只更改了一个文件的一行,我们想知道:我们是否触及了该文件的同一行?如果没有,这些更改很好地结合在一起:我们将所有 我们的 更改,将P 中的所有 文件转换为与H 中的所有 文件匹配,这使我们返回提交H;然后我们将他们所做的一项更改添加到一个文件中,同时还需要进行任何行号调整,并将他们所做的更改添加到一个文件中。所以这是完全正确的。

如果我们都接触了该文件的同一行,我们就会在该行发生合并冲突。这也完全正确。

当我们考虑所有可能的变化时——包括文件重命名之类的事情——我们会发现,确实,从PH 的差异是正确的做法。这就是我们这样做的原因。这也意味着我们可以使用现有的合并代码。

当然,现有的合并代码在索引上/中进行操作,并将我们的工作树用作临时存储。这就是变基相对缓慢和痛苦的原因。改善这一点的唯一真正方法是直接在内存中进行更多的合并工作。现在有人在这样做:在 the Git mailing list 中搜索 Elijah Newren 的“merge-ort”。

【讨论】:

  • 这非常棒,而且非常彻底——正是我想要的。谢谢你的信息!
  • 但是,我刚刚注意到您最后的链接似乎给出了连接超时。可能是服务器宕机了,但你能快速检查一下链接是否正确吗?
  • 对我来说很好。请注意,它是一个 insecure (http://) 链接,并且在相应的安全端口上没有服务器:https:// 将不起作用。如果您的浏览器坚持只使用安全连接,您就无法通过这种方式访问​​ vger 服务器。在网络上搜索“git mailing list”以获取一些档案,但我不确定是否有任何或全部支持安全连接(我自己只是在邮件列表上)。
猜你喜欢
  • 2019-01-19
  • 2013-12-23
  • 1970-01-01
  • 1970-01-01
  • 2013-12-10
  • 2018-09-27
  • 2020-02-26
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多