多个开发人员如何使用 git rebase 在同一个分支上工作?

How can multiple developers work on the same branch using git rebase?

我们 运行 遇到的问题是我们有主要的 development 分支。我们有一张票,它依赖于多张票才能进入 development 分支。所以假设 ticket A 依赖于 ticket BC 以便合并到 development

我们处理这个问题的方法是从 development 分支出 A,然后从 A 分支出 BC。问题是我们想将开发更新更新到 ABC,我们还想将更新从 B 更新到 C 和反之亦然。当有人在从 development 进行更改后对 A 进行 git push --force 变基时,我们如何使用 git rebase 来做到这一点而不让每个人都删除他们的本地 A 分支?

TL;DR 总结

历史次提交。

Rebase 表示 "copy commits, then try to forget the originals and use the copies instead"。这意味着 "make up a new history".

Git 存储库得到 分发 (广泛复制)。通过变基创建的新历史只会影响一个存储库。这就是为什么您必须在推送期间使用 --force,以强制另一个存储库接受 "history rewrite"。但这只会影响 一个 个其他副本,而不影响 所有 个其他副本。

您可以使用一些技巧来减轻疼痛,但疼痛本身并没有真正消失。您可以选择喜欢哪种疼痛。主要技巧是使用 git rebase:要么完全避免使用 git pull,要么将其配置为 运行 git rebase 作为第二个 Git 命令。但这仅适用于一个级别。

替代方法是不使用 --force。这要简单得多,但确实会给您留下错综复杂的历史记录。那可能还是更可取。

描述

在Git中,在很大程度上,b运行ch names并不重要。它们确实在这里和那里被使用,出于多种目的,但它们非常流动和无常——非常 不像 提交,后者是永久的并且永远不会改变(但 可以复制!)。因此,正如您所说,"make an A branch off development and then branch B and C off A":Git 不关心 names,这对您没有好处,因为它们不断变化;它只关心提交,而不会。

你从这个开始:

...--e--f--g   <-- development

(因为你为 b运行ches 使用了大写 one-letter 名称,我在这里为每个提交切换为小写 one-letter 名称)。然后再添加几个名称:

...--e--f--g   <-- development, A, B, C

现在一旦某人——任何人——拥有这个存储库结构,也就是说,在他们的存储库中确实有这四个 b运行ch 名称,就会:

git checkout development

并进行了一些新的提交,这就是 他们的 存储库中发生的事情:

...--e--f--g   <-- A, B, C
            \
             h--i  <-- development (HEAD)

新提交进入,每个新提交都像往常一样记录其先前的 parent。表示 current b运行ch 的 b运行ch 名称(该用户的 Git 已记录在该用户的 HEAD 文件中)表示current b运行ch在这个仓库中是名为development的那个,所以这个Git保持re-setting的标签是新的做出的承诺是 development。因此,此存储库中的标签 development 已提前指向两个新提交中的最新提交。

其他标签没有移动。


现在,实际上,每个用户都有自己的 存储库。用户必须相互协调,这也通过 b运行ch 名称发生——但现在至少有 两个 Git 参与,所以有 两组或更多组b运行ch名称。一切都必须通过这个镜头来看待:我有 我的 b运行ches,你有 your b运行ches,当我和你说话时,我称你为我的 "remote" 并且我将 你的 重命名为 b运行ches 这样他们就不会干扰我的

每个开发人员都可以直接与其他所有开发人员交谈。例如,如果您的组有 the standard first three people named Alice, Bob, and Carol,Alice 可能有两个遥控器,分别名为 Bob 和 Carol,Bob 可能有两个遥控器,分别名为 Alice 和 Carol,而 Carol 可能有两个遥控器,名字很明显。这显然很难扩展:有 30 名开发人员,每个人都有 29 个遥控器。

因此,您可能拥有一台集中式服务器,甚至可能位于像 GitHub 这样的高级托管站点上。这让每个人都只有 一个 遥控器,他们总是称之为 origin,因为 Git 会自动使用该名称,除非你告诉它使用其他名称。

这就是 origin/masterorigin/development 等名称的来源,也是我们在 git fetch origingit push origin <branch> 时使用名称 origin 的原因。那只是一个普通的遥控器:每个人都可以分享他或她的提交的地方。

这里棘手的部分是这个 origin Git 存储库是一个 Git 存储库。所以它有提交,这些提交是永久性的,但有可怕的、笨拙的 hash-ID 名称,如 9b00c5a...badbeef... 等等,没有人能记住;所以它也有 b运行ch names,比如 development.

A b运行ch 名称的main 功能是记住这些提交哈希ID 之一。这就是为什么我按照我绘制图表的方式绘制图表:b运行ch names "point to" the tip commit of their corresponding b运行国际象棋

请注意,一个提交可以而且经常是同时在多个 b运行 上提交。如果你添加一个新的 b运行ch 名称指向提交 idevelopment 的当前提示),突然所有曾经在 development 上的提交如 e--f--g--h--i) 现在也在这个新的 b运行ch 上。删除这个新的 b运行ch 名称,并提交 hi,正好在两个 b运行ches 上,现在只在一个 b运行ch 上。

A b运行ch 名称具有次要功能:通过让 Git 找到 一个特定的提交,它 保护 承诺不会被 Grim Collector,Git 的 "garbage collector" 或 "gc" 收割。然后,该提交会保护其所有历史记录——导致 b运行ch 提示的所有先前提交。由于 Git 通常通过在尖端增加新提交来移动 b运行ch 名称,新提交(受 b运行ch 名称保护)保护旧尖端,从而保护 even-older 提示,一直回到有史以来的第一次提交。

请注意 Git 向后工作:我们总是从提示开始并通过历史记录向后工作 - 提交 - 通过找到每个提交的 parent。每个提交 "points back" 到它的 parent,并且提交序列 历史。这就是 git log 向我们展示的; gitk 或图形查看器,或将 --graphgit log 一起使用,向我们展示了从提交到他们的 parent 的联系。

A parent 和两个立即数 children 在图中形成一个 b运行ch。为了让这个 b运行ch 继续存在,两个分支现在都需要一个名字。让我们 git checkout B(进入 B b运行ch)并进行新的提交以实现此目的:

             j   <-- B (HEAD)
            /
...--e--f--g   <-- A, C
            \
             h--i  <-- development

这里的parent是g,两个children是hj。当前的 b运行ch name, B, 已经移动:它现在指向 j.

如果我们现在检查 b运行ch C 并进行一两次新的提交,图片将变得更难绘制,因为我们需要离开 A 指向提交g 而 ASCII 艺术并没有那么大的空间:

             j   <-- B
            /_------ A
...--e--f--g--k--l  <-- C (HEAD)
            \
             h--i  <-- development

不过,我们再次假设所有这些都发生在 一个 存储库中——在一种没有 其他 存储库的真空中存在。它也变得太难画了,所以让我们回到只有 b运行ches name Bdevelopments,让我们假设我们已经以某种方式进入了这种状态在 origin 的存储库和一个人的存储库(比如说 Alice 的)上,所以我们现在在 Alice 和 origin 存储库中都有:

...--e--f--g--j  <-- B
            \
             h--i  <-- development

现在如果 Bob 运行s git fetch 来自 origin,并假设 Bob 还没有自己的 b运行(他必须至少有一个,我们只是没有绘制它),这就是 Bob 得到的结果:

...--e--f--g--j  <-- origin/B
            \
             h--i  <-- origin/development

也就是说,Bob 的 Git 记得 Bob 上次 origin 中的内容 Bob 运行 git fetch origin(注意:让我们避免 git pull 现在,但请注意它做的第一件事是 运行 git fetch origin)。它通过 重命名 它在 origin 上找到的 b运行ches 来做到这一点,这样它们就不再是 b运行ches 了。

如果 Bob 现在 运行s git checkout development,Bob 的 Git 发现 Bob have a development yet .它搜索 Bob 的存储库并找到 origin/development,它确实看起来很像 development,所以 Bob 的 Git 创建 一个 new development,指向与 origin/development 相同的提交。现在鲍勃有这个:

...--e--f--g--j  <-- origin/B
            \
             h--i  <-- development (HEAD), origin/development

如果 Bob 运行s git checkout master 他仍然会有名字 origin/B(指向 j),development(指向 i) 和 origin/development(也指向 i);但他会检查其他一些提交(也许 d 我们还没有画进去?),并将他的 HEAD 附加到 master.

您可能想知道为什么爱丽丝没有这些 origin/ 名字。事实上,她 确实 。她有 origin/B 指向 jorigin/development 指向 i。请注意,爱丽丝已经 有她自己的 Bdevelopment 名字。 Bob(还)没有他自己的 B,只有他自己的 development

(虽然 origin 上的存储库可能没有任何 origin/ 名称。它只有自己的 b运行ches。这是因为它没有有用户使用它;non-existent 用户没有 运行 git fetch origin。)


现在让我们回到爱丽丝,假设爱丽丝 运行 是一个 git rebase 变基 development 以便它出现在爱丽丝的 B 之后:

$ git checkout development
$ git rebase B

git rebase所做的是复制一些提交。

它选择提交复制的方式是查看当前b运行ch,然后查看其参数。

当前b运行ch为development,自变量为B。这是 Alice 现在拥有的(与 Bob 非常相似):

...--e--f--g--j  <-- B, origin/B
            \
             h--i  <-- development (HEAD), origin/development

git rebase 要复制的提交是 当前 b运行ch (development), 除了 在 b运行ch B 上的任何提交。嗯,提交 ...--e--f-g--j--idevelopment 上,...--e--f--g--jB 上。所以 Git 从 e--f--g--h--i 集合中减去 j——这很简单,没有 j,所以什么也不会发生。然后Git从集合中减去g,还有...--e--f,留下h--i.

这些是要复制的提交。

地方他们会去的地方,在被复制之后,是提交 B 指向的任何内容,即它们将在提交 j.

之后进行

现在 Git 制作副本(使用 Git 调用的 "detached HEAD" 模式):

                h'-i'   <-- HEAD
               /
...--e--f--g--j  <-- B, origin/B
            \
             h--i  <-- development, origin/development

这些新副本具有不同的哈希 ID。它们是不同的提交!它们是原始h--i副本(有一些变化),因此我们在这里称它们为h'-i'。原件还在那里,因为仍然有名字将它们固定到位。

git rebase的最后一步是强制移动原来的b运行ch名称,development指向tip-most复制的commit,并且re-attach 你的头:

                h'-i'   <-- development (HEAD)
               /
...--e--f--g--j  <-- B, origin/B
            \
             h--i  <-- origin/development

还保留着h--i的名字是origin/development。 Alice 的 Git 现在 t运行 将 development 变成 "commit i'"(不管它的新哈希 ID 是什么),所以她重写的 development 的历史以 [=138 结束=],返回到 h',然后返回到 jgf,等等,以 Git 通常的倒退方式。

强制推送

如果爱丽丝现在尝试git push origin development,她会得到拒绝。这是因为 origin 上的 Git 仍然有这个:

...--e--f--g--j  <-- B
            \
             h--i  <-- development

Alice 的 Git 发送提交 h'i',即她拥有 origin 没有的提交,以及一个提案:

                h'-i'   <-- proposal: move development here
               /
...--e--f--g--j  <-- B
            \
             h--i  <-- development

Origin 的 Git 看着提案说:呃,不,那不是 "fast forward"。拒绝!

记得之前我们看到 Git b运行ches 通过在它们的末端增加新的提交来增长。如果 Alice 将 添加到 的新提交推送到 h--i,那没问题:这是一个 "fast forward" 操作(一次添加多个提交,而不是一次添加一个一次)。但是她正在推动这些新的提交,然后提议将 development 完全从 h--i 中抽离出来,那是 而不是 好的:那不是 "fast forward"。

但是 Alice 可以使用 --force 使其成为命令,而不是提议;然后,只要 origin 的 Git 没有 object 太多,origin 的 Git 服从并将其存储库更改为如下所示:

                h'-i'   <-- development
               /
...--e--f--g--j  <-- B

这是除了爱丽丝(和 origin 自己)之外的所有人痛苦的开始。

请注意,提交 h--i 刚刚从 origin 的存储库中消失。 (如果 origin 的服务器正在使用 Git 的 reflogs,那里的 reflogs 将保留它们一段时间。默认情况下,服务器 不过,不要 保留引用日志。)

现在 Bob 感受到了上游 rebase 的痛苦

此时,当 Bob 运行s git fetch origin——记住,如果你(或 Bob)正在使用 git pull,你真的 运行ning git fetch origin 首先——Bob 得到更新后的 origin/development,因为这些 origin/whatever 名称只是盲目地跟随远程存储库上发生的任何事情。这就是 Bob 想要的:Bob 的b运行chees 没有命名origin/whatever;那些 origin/whateverremote-tracking b运行ches(总是与 Bob 的所有 b运行ches 分开)。

所以,现在 Bob 有了这个(让我们假设 Bob 已经回到 development 所以它也是他的 HEAD):

                h'-i'   <-- origin/development
               /
...--e--f--g--j  <-- origin/B
            \
             h--i  <-- development (HEAD)

由 Bob(以某种方式)弄清楚存在我们所说的 上游 rebaseorigin/development 用于指向提交 i现在指向提交 i'。 Bob 自己没有写提交 h--i,他只在他的 development 上写了它们,因为那是他的 Git 设置他的 development 的地方,当他 运行 git checkout development 创造了 他的 development.

但在 Bob 的 Git 看来,这些提交确实像是 Bob 自己编写的,因为 origin/development 没有它们。

在这种特殊情况下(Bob 没有自己的工作),Bob 可以 运行 git reset --hard origin/development 让他的 Git 移动他的 development 以匹配 origin/development。但是,如果 Bob diddevelopment 上进行了新的提交怎么办?让我们画出这个情况:

                h'-i'   <-- origin/development
               /
...--e--f--g--j  <-- origin/B
            \
             h--i--k  <-- development (HEAD)

鲍勃现在要做的就是做他自己的那种git rebase。他需要 复制 他的提交 k 以在 i'.

之后

使用 fork-point

自动处理上游 rebase

Git 有一个功能,它使用引用日志(上面未说明)到 try 来找出哪些提交实际上是 Bob 的。它通常很有效:Bob 的 Git 可以找出 Bob 的提交 k,并且提交 h--i 是较早的 origin/development 值遗留下来的。

因此,Bob 可以 运行:

git rebase --fork-point origin/development

当鲍勃在(鲍勃的)development--fork-point 选项使 Git 尝试解决所有这些问题。

请注意,--fork-point 是某些 rebase 的 默认值 ,而其他则不是。这个想法是让所有这些工作无缝自动进行。实际上,接缝到处都是。

自动使用--fork-point的地方是没有参数的git rebase,或者git pull 运行s [=351=的git rebase ]if 你让 git pull 运行 git rebase.

默认情况下,git pull 运行s git merge ...并且 合并 原始的 h--i 和新的 h'--i',带回 Alice 试图通过她的历史重写删除的那些提交!因此,要么根本不使用 git pull(我的偏好),要么将其设置为使用 git rebase 作为第二步。 (它的第一步是总是git fetch。)

不幸的是,所有这一切只有当你自动将你的development变基到你的origin/development时才有效,因为你的developmentorigin/development设置为它的[=351] =]上游。如果您 运行 显式 git rebase origin/development,则 关闭 --fork-point 选项,并且您也必须指定 --fork-point。这一切都非常令人困惑......这可能就是为什么有些人更喜欢 git pull,它对你隐藏了所有这些。问题是,它太原始了,无法隐藏:它并不总是有效,你需要知道发生了什么,为什么,这样你就可以修复它。

当自动的东西不起作用时,你必须手动变基

如果 none 的自动化将完成这项工作,则每个用户(Bob、Carol、Dmitry 等)都可以手动 git rebasegit rebase -i。交互式变基允许用户删除不应复制的提交,例如上例中设置的 h--i 。这不是最有趣的练习。它确实给您留下了干净的历史;但这种干净的历史是否值得你来决定。