我的分支在 "squash and merge" 之后发生了分歧。我该如何解决?

My branches have diveregd after a "squash and merge". How do I fix that?

我做了 'squash and merge' 来关闭 PR。虽然现在所有的变化都在master,但还是说分支已经分叉了,前后还有变化。

This branch is 1 commit ahead, 28 commits behind develop. 

我该如何解决这个问题?后面的 28 次提交是在 'squash and merge' 期间被压缩的。我已经通过 https://help.github.com/en/github/administering-a-repository/about-merge-methods-on-github#squashing-your-merge-commits ,但找不到任何联系。

我假设(错误地?)'squash and merge' 会进行快进合并以防止分歧。我不希望 develop 的所有提交都移动到 master,这就是为什么我做了 "squash and merge",而不是变基。我想我遗漏了一些关于压缩和合并的东西。

我在 SO 上经历过类似的问题,但它最终提出了在合并到 master 之前进行交互式变基和压缩提交的建议。但我不想丢失 develop.

中的历史记录

How do I fix this?

只能sort-of 修复。您通常应该现在完全删除该分支。然后,您可以创建一个 new 分支,即使该分支仍命名为 develop。它是一个不同的分支,即使它是相同的名称。

这一系列命令可能修复它,但是要非常小心并确保你理解每一个:

git checkout master
git reset --hard develop
git push --force-with-lease origin master

git reset --hard 命令将丢弃任何 未提交的 工作,因此在您使用此命令时请务必 extra-careful。

我将在下面给出一些备选方案。有许多选项,具有不同的效果,尤其是对可能使用 GitHub 存储库的任何其他人而言。如果 是唯一使用 GitHub 存储库的人,您可以随心所欲。

无论如何,请记住:这是squash-and-merge的目标:杀死分支。这个想法是,在此 squash-and-merge 之后,您将删除分支名称并永远放弃所有 28 次提交。 squash-and-merge 做了一个 new 提交,它包含与之前 28 个提交相同的 work,所以一个新提交是 new-and-improved 替换 这 28 次提交。

要了解您在做什么,请继续阅读。

要真正"get"Git,需要画图

I assumed (wrongly ?) that 'squash and merge' would do a fast forward merge to prevent the divergence. I didn't want all the commits from develop to move to master, which is why I did a "squash and merge", instead of rebasing. I think I'm missing something about squash and merge.

是的。 GitHub 在这里也有一个额外的皱纹——或者可能,需要一个你想要的皱纹 away,这取决于你如何看待它。我们先需要一点边角料。

Git 就是关于 提交 。这些提交形成了一个 graph,特别是 Directed Acyclic GraphDAG。它的工作方式实际上非常简单:

  • 每个提交都有一个唯一的哈希 ID。这个哈希 ID 实际上是提交的真实名称。

  • 每个提交都由两部分组成:它的数据——所有文件的快照——和一些元数据,关于提交本身的信息,比如如谁制作的(您的姓名和电子邮件地址)、制作时间(date-and-time-stamps)以及制作原因(您的日志消息)。大多数元数据只是为了在您查看提交时向您展示,但有一条信息对 Git 本身至关重要:每个提交都列出其 父级 [=401= 的原始哈希 ID ] 提交。

大多数提交只有一个单亲。当某物包含提交的哈希 ID 时,我们说某物 指向 提交。所以提交 指向 它的父级。如果我们有一个连续的提交链,我们可以这样绘制它们:

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

其中大写字母代表实际的哈希 ID。 最新 提交是提交 H。它指向它的父级,就在它之前:commit G。提交 G 指向其父级 F,依此类推。

分支名称,在Git中,只是一个指向最新提交的名称:

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

当我们创建一个新分支时,我们真正在做的是告诉 Git:创建一个指向某个现有提交的新名称。所以如果我们现在创建一个分支 develop,我们会得到:

...--G--H   <-- develop, master

现在我们需要知道我们实际使用的是哪个 name,因此我们将特殊名称 HEAD 附加到该名称:

...--G--H   <-- develop (HEAD), master

当我们进行 new 提交时,我们只需 Git 写出提交——这将计算新提交的唯一哈希 ID——带有快照及其父项设置为 当前 提交(目前 H):

...--G--H
         \
          I

现在名称发生的变化也很简单:HEAD 附加到的名称,Git 将新提交的哈希 ID 写在那里。所有其他的:什么都没有发生。所以我们的新图是:

...--G--H   <-- master
         \
          I   <-- develop (HEAD)

随着我们添加更多提交,它们只会不断增长分支:

...--G--H   <-- master
         \
          I--J--K   <-- develop (HEAD)

(这里的developmaster3 ahead。)

还有另一件重要的事情需要了解:提交 GH,并且在 master 之前的所有提交也都在 develop. 换句话说,提交通常在 多个分支 上。 (这是关于 Git 的一件奇怪的事情:许多其他版本控制系统 不会 以这种方式工作。)

真合并和fast-forwarding

此时,我们可以 运行 git merge,在我们自己的本地存储库中(但不在 GitHub 上)使用:

git checkout master; git merge develop

Git 将采用 short-cut——您提到的 fast-forward。我们稍后会回到这个话题。

或者,我们可以 运行 git merge --no-ff develop,强制 Git 进行真正的合并。 这就是 GitHub 上的 "merge" 按钮的作用,让我们看看它。 "true" 合并总是会产生一个新的 合并提交。真正的合并是 reqired 如果两个分支有 分歧: 例如,如果我们有:

       o--o   <-- branch1
      /
...--o
      \
       o--o   <-- branch2

并且我们想要合并它们,我们将被迫使用真正的合并。那不是我们所拥有的;我们有:

...--G--H   <-- master (HEAD)
         \
          I--J--K   <-- develop

(记住,我们现在在 master:我们做了一个 git checkout master 所以 H 当前 提交。Kdevelop 上的最后一次提交。)

GitHub 仅在 所有 情况下强制进行真正的合并,包括我们自己的简单情况。要进行真正的合并,Git 找到一个 merge base 提交——在本例中是提交 H——并将该合并基础进行两次比较,一次与当前的提交 H,一次反对提交 K.

显然,提交 H 匹配提交 H。所以我们这边没有改变可以继续下去。我们只想要 他们 所做的任何更改,从 HK。结果将与 K 中的快照相同。但是我们正在强制合并,所以 Git 进行了新的合并提交,我将其称为 M for merge:

...--G--H---------M   <-- master (HEAD)
         \       /
          I--J--K   <-- develop

M 的特别之处——使其成为合并——是因为它不仅有一个父级,还有两个。它的 first 父级是提交 H,这是我们刚才所在的位置。它的第二个父项是提交 K。它的 快照 是将我们在 H-vs-H 上所做的工作(根本没有工作)与他们在 [=52 上所做的工作相结合的结果=]-vs-K,所以新的合并提交的快照匹配提交K的快照,但最终结果是这个新的合并提交。

如果我们允许Git快进而不是合并,这就是我们得到的:

...--G--H
         \
          I--J--K   <-- develop, master (HEAD)

(这使我们能够消除绘图中的扭结,但我没​​有费心)。 Git 将此称为 fast-forward 合并,即使没有实际合并发生。不幸的是 GitHub 的按钮不允许我们进行 fast-forward 合并 .

壁球合并是真正的合并,但没有第二个父级

如果我们进行真正的合并,我们会得到这个图:

...--G--H---------M   <-- master (HEAD)
         \       /
          I--J--K   <-- develop

提交 M 将具有与提交 K 相同的快照,并且将有两个父项,HK

做一个挤压合并,我们得到这个图:

...--G--H---------S   <-- master (HEAD)
         \
          I--J--K   <-- develop

提交 S 与提交 K 具有相同的快照,但只有一个父项 H。这里的想法是你想要一个单一的普通提交,它与你在 develop 上所做的所有提交具有相同的效果。这个单一提交是完成这项工作的 new-and-improved 方式。旧的方式——旧的 I-J-K 提交——不再有用,应该放弃;最终,在 30 天或更长时间后的某个时候,您的 Git 将真正删除它们。所以你只是 运行 name develop 的 force-delete,产生:

...--G--H---------S   <-- master (HEAD)
         \
          I--J--K   [abandoned]

如果愿意,您现在可以再次创建 name develop,但这次指向提交 S:

...--G--H--S   <-- develop, master (HEAD)
         \
          I--J--K   [abandoned]

分支名称的目的是让你(和Git)找到提交

请注意,当我们操纵图形以添加提交时,我们移动了分支名称。分支名称 always 指向 latest 提交。这就是 Git 定义工作的方式。

但是,我们可以强制将分支名称移动到我们喜欢的任何地方。如果我们检查了一个特定的分支,就像这个状态:

...--G--H--S   <-- master (HEAD)
         \
          I--J--K   <-- develop

我们可以使用 git reset --hard 来切换提交,并且可以说,将提交 S 推开:

          S   [abandoned]
         /
...--G--H   <-- master (HEAD)
         \
          I--J--K   <-- develop

请注意,提交 S 仍然 存在 。只是从名称 master 开始,我们找到提交 H,然后我们向后工作并找到提交 G,依此类推。我们从不前进,所以我们无法到达 提交S。它已被放弃,就像我们删除名称 develop.

时提交 I-J-K 一样

如果您将提交的原始哈希 ID 保存在某处——例如,将其写在纸上或白板上,或者将其剪切并粘贴到文件中——您可以稍后直接使用该哈希 ID 来让 Git 找到那个提交。如果它仍然 存储库中(默认情况下至少保留 30 天),您可以那样找到它。但是你不会通过大多数普通的 git log 和其他 Git 命令找到它。

请注意,如果提交在 多个 分支上——例如,如果我们有:

...--P   <-- branch1
      \
       Q   <-- branch2
        \
         R   <-- branch3

我们删除名字branch2——它可能仍然是find-able。删除namebranch2的结果是:

...--P   <-- branch1
      \
       Q--R   <-- branch3

毕竟。提交 Q 仍然存在,但这次,我们 可以 找到它,从 R 开始并向后工作。

这就是绘制图表或其中一部分很重要的原因。您可以通过从某个名称开始并向后工作 找到 的提交,也是您明天也能找到的提交——假设您不移动名称左右,即。

到目前为止,这为您提供了两个选择

您可以保留 squash-merge 提交并忘记其他提交,方法是 完全删除 名称 develop:

...--o--o--S   <-- master
         \
          o--o--...--o   [was develop; 28 commits, all abandoned]

或者您可以构建自己的本地 Git 图表,如下所示:

          S   <-- origin/master
         /
...--o--o   <-- master (HEAD)
         \
          o--o--...--o   <-- develop, origin/develop

请注意,您的 master 识别提交 S 之前的一步。名称 origin/master,这是您 Git 的 Git 集线器 master 的本地副本,指向提交 S

如果您的 master 已经 看起来像这样,那么您已经设置好了。如果您的 master 看起来像这样:

          S   <-- master (HEAD), origin/master
         /
...--o--o
         \
          o--o--...--o   <-- develop, origin/develop

你会 运行 git reset --hard HEAD~1 把它往后移一点,让它看起来像第一张图。

既然 你的(本地)Git 存储库设置正确,你必须告诉 Git 结束 Git集线器 its master 后退一步。为此,您需要使用 git push --forcegit push --force-with-lease:

git push --force-with-lease origin master

两者之间的区别在于 --force-with-lease 有他们的 Git(GitHub 上的那个)检查你认为他们有什么作为他们的 master,他们确实拥有 master。所以 --force-with-lease 与更简单的 --force 相比增加了一点安全性,如果其他人也使用这个其他存储库的话。

一个正常的日常 git push 让你的 Git 打电话给另一个 Git - 在这种情况下, GitHub 上的那个 - 并弄清楚你是否有新的为他们做出承诺。如果您确实有新的提交,您的 Git 会将它们发送过来。然后,在最后,你的Git礼貌地向他们提出请求:如果没问题,请将你的分支名称______设置为commit hash _______. Git 将填写两个空白:名称是您使用的分支名称,即 git push origin master 中的 master,哈希 ID 是名称当前在 [=325 中表示的哈希 ID =]你的 Git.

A force-push 做同样的事情只是最后的请求一点都不客气:这是一个需求,设置你的分支名称 _______ 为 ______ __!

git push 中涉及的另一个 Git 如果不会 丢失 任何提交,将接受礼貌的请求。在这种情况下,您的请求或命令专门设计用于使它们 lose commit S。也就是说,他们有:

...--o--o--S   <-- master
         \
          o--o--...--o   <-- develop

并且您希望他们将 S 推开并使他们的 master 指向 S 之前的提交。所以你需要某种有力的推动。

您可以还原 squash-merge,然后变基

git revert 命令通过添加一个新的提交来撤销先前提交的影响。由于这个 adds 提交,Gits 很乐意接受新的提交。这种特殊的方法在这里有点傻,所以虽然我会画出效果,但它可能不是你想要的:

...--o--H--S--U   <-- master
         \
          o--o--...--o   <-- develop

我在这里为 develop 的起点提交 H 命名。原因是提交 U 中的 快照 S 的撤消将匹配提交 H.

中的快照

您现在可以使用 git rebase 上的整个提交系列 develop 复制到新的提交系列中。这些新提交具有不同的哈希 ID 和不同的父链接,但具有与以前相同的 快照

                o--o--...--o   <-- develop (HEAD)
               /
...--o--H--S--U   <-- master, origin/master
         \
          o--o--...--o   <-- origin/develop

您现在所处的情况与意外提交 S 之前的情况相同。您必须 force-push 更新 develop 到 GitHub。 (然后,当然,您仍然必须合并所有内容!)

您可以继续进行真正的合并

您可以保留 squash 提交 S 并进行真正的合并:

git fetch                           # if needed
git checkout master
git merge --ff-only origin/master   # if needed

所以你有:

...--G--H--S   <-- master (HEAD), origin/master
         \
          I--...--R   <-- develop, origin/develop

然后:

git merge develop

生产:

             ,--------<-- origin/master
            .
...--G--H--S--------M   <-- master (HEAD)
         \         /
          I--...--R   <-- develop, origin/develop

您现在可以git push origin master让他们接受合并提交M,给您:

...--G--H--S--------M   <-- master (HEAD), origin/master
         \         /
          I--...--R   <-- develop, origin/develop

在您自己的本地 Git 存储库中。

你可以删除S,fast-forward-合并你的develop,和force-push

最后,这可能是您 喜欢 在执行任何强制推送之前在您的个人本地 Git 存储库中看到的内容:

          S   <-- origin/master
         /
...--G--H--I--...--R   <-- develop, master (HEAD), origin/develop

这也许是你所拥有的:

          S   <-- origin/master
         /
...--G--H   <-- master (HEAD)
         \
          I--...--R   <-- develop, origin/develop

或者您现在拥有:

          S   <-- master (HEAD). origin/master
         /
...--G--H
         \
          I--...--R   <-- develop, origin/develop

要得到你想要的,在任一情况下,你可以:

git checkout master
git reset --hard develop

由于您的 develop 当前指向提交 R,这将检查提交 R,使您的 master 指向提交 R,然后离开你用你想要的图表。然后你可以使用 force-push:

git push --force-with-lease origin master

发送您拥有但他们没有的任何提交(none,他们已经拥有所有这些提交)并命令它们:我认为您的 master 识别提交 S。如果是这样,现在就让它识别提交R他们通常会服从这个命令,你会得到你想要的。

所以您提出的要求是:

1) 应该只有一个提交添加到 master

2) 所有提交都应保留在开发分支上(不会丢失历史记录)

3) master 分支上的新提交应该与原始提交相关,这样 git 就不会认为它们已经分歧

最接近 git 同时满足这三个要求的是一个简单的合并。

$ git checkout master
$ git merge develop

如果你这样做,当 git 显示 master 的历史时,它确实会默认包含来自 develop 的提交;您可以避免使用 --first-parent 选项来执行类似 git log.

的命令

实际的提交图最终看起来像

o -- x -- x -- M <--(master)
 \            /
  x --- x -- x <--(develop)

所以 M(合并提交)被添加到 master,它 "remembers" 它与 develop 上的完整历史记录的关系。

事实上,做 "squash merge" 的全部不同之处在于 M "forgets" 它与 develop 的关系 - 第二个 parent 没有记录。

A rebase 工作流牺牲了要求 (1) - 并且还需要以创建可能处于或可能不处于工作状态的新中间提交的方式重写提交 - 有利于线性历史.

"Squash-and-merge" 可以 "fix" rebase 的限制,但它牺牲了要求 (3) - 除非你将 develop 永远作为它自己的分支分支,然后你失去了要求 (2).

我个人认为,当唯一的回报是线性历史时,这些成本太高了;有些人似乎真的很喜欢这种线性并发誓它更容易理解,但我不太重视它。

不过,无论哪种方式,这些都是 trade-offs 每个团队在决定变更整合策略时需要做出的决定。