首页 > 解决方案 > 在我 rebase 然后同步之后,历史记录在 PR 中翻倍并显示不相关的更改

问题描述

我今天早些时候问的这个问题很糟糕,现在我尝试了一些我可能有更好理解的东西。

情况:

我做的命令:

git checkout master
git fetch upstream
git merge upstream/master
git push origin/master

git checkout workbranch // up-to-date with origin
git rebase master
git rebase --continue   // after solving merge conflict
git pull .    // not sure why there were changes to be pulled, was this where I went wrong?
git push .

在此之后,结果是我看到了:

在 github.com 上的分支比较概述中,在我的 fork 中,我看到:

我期望看到的是我的原始提交,每个只有一次,不重复,合并提交,并且不应该有来自 master 的提交

我怀疑这与先拉后推有关。

标签: gitgithubrebase

解决方案


这个是正常的。请注意,这是不可取的,但很正常——这是一些人git rebase完全避免使用的原因之一。

龙:为什么会这样

首先记住 Git 提交是什么以及做什么:

  • 每次提交都会存储您所有文件的快照,以及一些元数据:提交人的姓名和电子邮件地址、日期和时间戳等。

  • 每个提交都有一个唯一的编号。这个数字不是一个简单的计数——它不是 1、2、3 等——而是一个大的、丑陋的、看起来随机的(但根本不是随机的)哈希 ID。哈希 ID 是两个 Git 如何判断它们是否都有提交的方式,因为这个哈希 ID 在每个Git 中的计算方式都相同。如果他们的 Git 有提交而您没有,则您的 Git 在其数据库中没有提交编号。如果您的 Git 有提交而他们没有,那么您的 Git 在其数据库中有提交号(和提交),而他们没有。

此外, Git 存储库中的历史只是一组提交。Git 通过在每个提交中存储提交的提交的提交编号(哈希 ID)来解决这个问题,或者,对于合并提交,父提交(复数)。这些是在此提交之前的提交。

如果我们忽略合并提交,我们会得到简单的、向后看的提交链,我们可以这样绘制:

... <-F <-G <-H

这里H代表链中最后一次提交的实际哈希 ID。在H的元数据中,Git 存储了 commit 的实际哈希 ID G。所以通过读取 的内容H,Git 可以找到 的提交号G,这让 Git 读取 commit G,其中包含 的提交号F,依此类推。

Git 中的分支名称只是保存了链中最后一次提交的提交号——又大又丑的哈希 ID 。因此,如果您的分支master有上述提交,我们可以这样绘制:

...--F--G--H   <-- master

我们真的不需要提交之间的向后箭头,只要我们记住它们总是指向向后,因为任何提交中的任何内容都无法更改。这包括父提交的哈希 ID。

但是,分支名称和其他名称(如远程跟踪名称)移动. 所以我们会画出他们的箭头来提醒我们。

绘制你的设置

我们可以像这样绘制您的初始情况:

...--F   <-- master, origin/master, upstream/master
      \
       G--H   <-- workbranch, origin/workbranch

现在我们用它们自己的唯一哈希 ID 更新我们的upstream/master,其中有一些新的提交:

git checkout master
git fetch upstream

这给了我们:

       I--J   <-- upstream/master
      /
...--F   <-- master (HEAD), origin/master
      \
       G--H   <-- workbranch, origin/workbranch

git checkout步骤确保我们当前的分支是master,即,我们正在使用 commit F。这就是为什么我们在这里将特殊名称HEAD附加到分支名称上master

接下来,我们让 Git我们的名称移动master到我们刚刚获得的最后一个新提交upstream

git merge upstream/master

产生:

       I--J   <-- master (HEAD), upstream/master
      /
...--F   <-- origin/master
      \
       G--H   <-- workbranch

注意masternow 如何指向现有的 commit J。at 上的 Gitorigin甚至还没有提交I-J,我们对它的记忆master,在我们的origin/master,仍然指向 commit F

最后,我们运行:

git push origin master    # note: not origin/master

这让我们的 Git 在origin. 这就是为什么 this isorigin master和 not的原因origin/master:我们想在 调用 Git origin,并根据我们的 master发送提交,这也是最后一部分 ismaster和 not的原因origin/master。因此,我们将提交I-J(我们从upstreamvia upstream's获得master)发送到origin,并要求origin它们 master设置为指向 commit J

假设他们服从,这就是我们在此过程结束时在本地拥有的内容:

       I--J   <-- master (HEAD), origin/master, upstream/master
      /
...--F
      \
       G--H   <-- workbranch, origin/workbranch

请注意,在所有这些过程中没有任何提交发生变化。origin整个过程是关于从其他存储库(at 存储库)提交到特定 Git 存储库(我们的存储库和 at存储库upstream),并更新我们的分支名称(master)和masterGit 中的名称origin(我们的 Git 保留记忆在我们的origin/master)。

(这一切都非常令人困惑:习惯所有重复需要很长时间。我发现将每个存储库视为不同的“人”会有所帮助:Upstream 先生知道提交I-J,然后我们了解它们,然后我们告诉起源先生。)

Rebase 假装更改提交

为了git rebase完成它的工作,它必须假装改变一个提交。这实际上是完全不可能的。相反,rebase 接受现有的提交并使用它们进行新的提交,这些提交略有不同,因此具有不同的提交编号。

让我们重新绘制我们的最终情况,而不会在 commit 之后出现向上扭结F。我们可以随心所欲地绘制图形,只要我们可以从名称到提交,然后遵循内部的后向箭头。该命令在图的顶部git log --graph绘制了一个带有较新提交的图形,但是对于 StackOverflow,我更喜欢将较新的提交绘制在右侧。

...--F--I--J   <-- master (HEAD), origin/master, upstream/master
      \
       G--H   <-- workbranch, origin/workbranch

我们想要做的是假装我们G从 commit做出承诺J。当然,我们没有,但git rebase可以:

  • 使用 Git 的分离 HEAD模式提取J对工作树的提交;
  • 用于git cherry-pick在此处复制提交G
  • git cherry-pick再次使用复制提交H;最后
  • 强制名称workbranch标识最后复制的提交。

rebase 操作可能会在每个步骤中遇到障碍git cherry-pick,看起来您的操作似乎曾经这样做过。

我们首先告诉 Git 在此处提取提交H并附加HEAD。这就是git rebase决定要复制哪些提交的方式:它将查看HEAD. 所以我们运行:

git checkout workbranch

这给了我们:

...--F--I--J   <-- master, origin/master, upstream/master
      \
       G--H   <-- workbranch (HEAD), origin/workbranch

同样,没有任何提交发生变化,但我们现在正在处理从 commit 中提取的文件H

然后我们运行:

git rebase master

Git 现在列出了提交的原始哈希 ID,这些提交是 onworkbranch而不是 on master。请注意,master包含提交...-F-I-J,结束于J,而workbranch包含提交...-F-G-H,结束于H。s 和更早的F提交被取消,并且I-J提交根本没有打开workbranch,所以这里要复制的提交列表只是Gand H

(在您的情况下,要复制两个以上的提交,但结果应该足够清楚。)

接下来,因为我们说过git rebase master,Git 对 commit 进行了特殊的 detached-HEAD 模式检出J

...--F--I--J   <-- HEAD, master, origin/master, upstream/master
      \
       G--H   <-- workbranch, origin/workbranch

现在 Git 使用git cherry-pick(或或多或少等效的东西,取决于您的 Git 年份和传递给的标志)将commit 中所做的更改git rebase复制到现在的位置。如果一切顺利,Git 会自行进行新的提交。为了记住它是 的副本,我们称之为:GHEADGG'

             G'  <-- HEAD
            /
...--F--I--J   <-- master, origin/master, upstream/master
      \
       G--H   <-- workbranch, origin/workbranch

rebase 命令继续复制剩余的提交,给出:

             G'-H'  <-- HEAD
            /
...--F--I--J   <-- master, origin/master, upstream/master
      \
       G--H   <-- workbranch, origin/workbranch

现在所有提交都已成功复制(或者您已修复它们并git rebase --continue根据需要使用),Git 将名称拉到workbranch指向H'提交,然后重新附加HEAD

             G'-H'  <-- workbranch (HEAD)
            /
...--F--I--J   <-- master, origin/master, upstream/master
      \
       G--H   <-- origin/workbranch

似乎两个现有提交以某种方式移动,因为新提交具有相同的作者、时间戳和日志消息等。提交编号有什么不同,但谁真正费心查看那些又大又丑的哈希 ID?

我们的 Git 已经故意忘记workbranch曾经指向 commit 的那个H。相反,我们workbranch现在指向 new-and-improved commit H'。但是请注意,我们的 Git 记得 Git over atorigin他们 workbranch记住现有的 commit H

git push

假设我们现在让我们的 Git在 at调用他们的originGit,并向他们发送提交G'-H'

git push origin workbranch

他们将至少暂时将其放入自己的存储库中,然后考虑我们的请求,让他们更改G'名称指向commit 。但现在,他们会说不H'workbranchH'

当我们礼貌地要求他们将他们workbranch从他们的(和我们的)H转移到我们的(现在也是他们的)H'时,他们说不,因为如果他们这样做了,他们会忘记如何找到 commit H。他们不知道这H'是一个新的和改进的替代H。他们只知道,如果他们按照我们的要求去做,他们就会忘记 H。他们不会有一个他们仍然可以找到的名字H

所以,他们说不。

git pull

如果你git pull origin workbranch现在运行. 他们会说:哦,当然,我的,它有这两个非常好的提交你想要它们吗? 如果你的 Git 已经丢弃了你的旧版本,它会获取这些副本。如果没有——而且你的 Git 肯定仍然拥有它们,因为你一直记得它们——你的 Git 说它已经拥有它们,但无论如何谢谢,现在你的 Git 知道它们的提交点。因此,如果需要,您的 Git 会更新您的(不是,因为您还记得):git pull workbranchworkbranchGHG-Horigin/workbranchworkbranchHorigin/workbranchorigin/workbranchH

             G'-H'  <-- workbranch (HEAD)
            /
...--F--I--J   <-- master, origin/master, upstream/master
      \
       G--H   <-- origin/workbranch

现在你的Git 运行任何.git pull

git pull命令实际上由运行两个Git 命令组成:

  • 第一个命令始终是git fetch. 这就是让您的 Git 调用他们的 Git 并询问他们workbranch(可能还有他们的其他分支,具体取决于您的运行方式git fetch)的步骤。这一步带来了他们拥有的任何提交,而你没有,你的 Git 将需要这些提交。然后你的 Gitorigin/*会在需要的地方更新你的名字。

  • 第二个命令默认为git merge. 合并在他们所说的任何提交上运行,这是他们分支的最后一次提交。

所以在这里,你的 Git 运行在commitgit merge的哈希 ID 上——他们的,也就是你的. 因此,您的 Git 现在将您的提交与共享提交合并Hworkbranchorigin/workbranchH'H

             G'-H'-M  <-- workbranch (HEAD)
            /     /
...--F--I--J  <- / -- master, origin/master, upstream/master
      \         /
       G-------H   <-- origin/workbranch

你为改进和丢弃旧的而制作的那些副本G-H仍然存在。老人G-H也还在。新的合并提交M两个分支结合在一起。一个分支包含您认为已经摆脱的提交这一事实并不重要。提交仍然存在,合并将它们合并。

git push, 再次

你的 Git 现在可以发送他们的 Git 提交M,即新的合并。如果他们做出了他们的workbranch识别提交M,他们现有的提交G-H仍然可以在他们的存储库中访问,所以他们对此感到满意。但是此时,您复制了所有提交的git rebase副本(现在它们也将复制)。这根本不是你想要的。

注意:成功git push将更新您origin/workbranch以记住他们workbranch现在记住 commit的事实M,因此现在绘图如下所示:

             G'-H'-M  <-- workbranch (HEAD), origin/workbranch
            /     /
...--F--I--J  <- / -- master, origin/master, upstream/master
      \         /
       G-------H

(我们可以通过将G-H线移到绘图顶部来简化这一点,但我们不要这样做。)

要解决此问题,您必须执行以下操作:

  • 强制你的 Git 假装你根本没有进行合并提交M
  • 强制 Gitorigin他的 workbranch名字设置为记住 commitH'而不是M.

此时最简单的一组 Git 命令是git reset --hardgit push --force. 让我们看看它们是如何工作的。

git reset --hard

我们首先让Git忘记我们的提交M

git checkout workbranch         # if needed - we're probably already there

这确保我们有:

             G'-H'-M  <-- workbranch (HEAD), origin/workbranch
            /     /
...--F--I--J  <- / -- master, origin/master, upstream/master
      \         /
       G-------H

现在在我们的存储库中。然后:

git reset --hard HEAD~1

HEAD~1符号表示从 commit移回一个 first-parentM到 commit H'。这使得我们的名字workbranch指向 commit H'。为了绘制这个,让我们将 commitM移到最下面一行:

             G'-H'  <-- workbranch (HEAD)
            /    \
...--F--I--J   <- \ -- master, origin/master, upstream/master
      \            \
       G-------H----M   <-- origin/workbranch

现在我们workbranch确定了 commit H',我们运行:

git push --force origin workbranch

这让我们的 Git 在 at 调用他们的 Git,origin告诉它有关提交H'的信息——当然,此时他们已经有了它——然后强制命令他们:将您的分支名称设置workbranch为指向 commit H' (这来自--force,并取代了通常的礼貌请求。)

假设他们服从——那部分由他们决定,但如果你可以控制这个存储库,只要确保你给自己强制推送权限——他们会将他们 workbranch的指向 commit H',你的 Git 会相应地更新你的origin/workbranch

             G'-H'  <-- workbranch (HEAD), origin/workbranch
            /    \
...--F--I--J   <- \ -- master, origin/master, upstream/master
      \            \
       G-------H----M   [abandoned]

既然他们和您都没有名称查找commit M,您甚至都看不到它。一切都仿佛从未存在过:

             G'-H'  <-- workbranch (HEAD), origin/workbranch
            /
...--F--I--J   <-- master, origin/master, upstream/master

同样,这对于 rebase 来说是很正常的事情

变基的问题在于它通过将提交复制到新的和改进的提交来工作。

总的来说,Git 的问题是不愿意放弃提交。它想要添加提交,而不是删除它们以支持新的和改进的提交。

每个 Git 存储库都可以轻松添加新的提交。它不会那么容易忘记一个旧的。因此,要将这个特定的提交发送到origin,当origin记住你的旧提交H而不是你的 new-and-improvedH'时,你必须强制推送。您可以使用--force-with-lease,它添加了一种安全检查,他们workbranch仍然记得H而不是其他一些提交。

如果 Git 存储库有其他用户origin,请记住他们也可能正在使用或添加到origin's workbranch. 您应该确保所有这些用户都希望删除和替换提交。否则其他用户会对这种行为感到惊讶。

完全避免 rebase 可以避免这种意外,但最终这取决于您和与您一起工作的任何人。如果你们都同意变基的发生——一些提交可以消失,如果它们应该消失,你不会把它们带回来——那么你可以这样工作。


推荐阅读