将 Master 合并到分支然后提交并将更改推送到分支。如果没有强制推动,这怎么能撤消呢?

Merged Master into branch then committed and pushed changes to branch. How can this be undone without a force push?

我错了。

我有一个从 Master 分支出来的分支 (A)。 Master 领先 A 不少。前几天不小心把Master合并成了A然后推了。那天晚些时候我注意到我的错误,但不确定如何修复它,所以我尝试添加一些功能标志来关闭不应在 A 中启用的功能并推送它们。后来,我决定尝试恢复 A 分支以摆脱所有 Master 提交。我完成了所有更改(大约 100 个文件),现在 A 看起来和 Master 之前一样。但是,我现在的问题是,如果不尝试删除 Master 中存在的所有更改,我就无法将 A 合并到 Master 中。 (即,在 Master 中创建的新文件在 A 的还原中被删除,所以现在 git 想要从 Master 中删除文件,如果我尝试合并 A 变成 Master.)

我怎样才能修复我的巨大错误并回到我可以在 A 上做维护补丁的地方并相应地与 Master 合并,这样未来的版本就不会丢失补丁?

I C. 你玩得很开心。最好的方法是删除 master 到 A.... 的合并,但它的代价很高。不太优雅的方法是 unrevert 更改 reverted 从与 master 相关的 A 中,这样它们就不会再消失了。然后,当您将 A 合并到 master 中时,您将不会看到那个大逆转。我不喜欢但是....

如何在没有 force-push 的情况下撤消合并? 的简短回答是:你不能

较长的答案是 你不能,但你也不需要,只要你知道你在做什么以及合并是如何工作的; force-push 只是有时更方便,如果您可以说服所有其他用户相信您以这种方式强制使用的任何存储库。

TL;DR:如果你真的需要恢复合并,那就去做吧;你可以稍后恢复

请参阅 How to revert a merge commit that's already pushed to remote branch? See also MK446's answer to the same question,这几乎是 Linus Torvald 关于恢复合并的描述的 copy-paste。

了解所有这些(长)

理解 为什么 是这种情况的关键,以及如何处理它,是要认识到任何一组提交的 "merge-ness" 是固有的承诺自己。分支名称仅用作 查找 提交的方式。做 force-push 的行为是一种改变 name 指向的方法,这样人们(和 Gits)就不能再 找到一些提交。

一看就明白了,就是不知道怎么解释才好,只能画图说服人了。 Linus Torvalds 是这样总结的——这是准确的,但也很棘手:

[While] reverting a merge commit ... undoes the data that the commit changed, ... it does absolutely nothing to the effects on history that the merge had. So the merge will still exist, and it will still be seen as joining the two branches together, and future merges will see that merge as the last shared state - and the revert that reverted the merge brought in will not affect that at all. So a "revert" undoes the data changes, but it's very much not an "undo" in the sense that it doesn't undo the effects of a commit on the repository history. So if you think of "revert" as "undo", then you're going to always miss this part of reverts. Yes, it undoes the data, but no, it doesn't undo history.

"History" 是 提交图 。该图由提交决定,但我们找到 分支名称 的提交。因此,我们可以通过更改存储在 names 中的哈希 ID 来改变我们可以 看到 的内容。但是,直到您知道并在自己的脑海中看到它是如何工作的,这并没有真正的帮助。

您可能会花一些时间查看 Think Like (a) Git 上的教程,但为了快速复习,请考虑以下事实:

  • 一个 Git 提交由两部分组成:它的主要数据,它是你所有文件的快照——我们将在这里多说一点——和它的元数据,其中包含有关提交本身的信息。大部分元数据都是供您稍后使用的信息:谁进行了提交,何时提交,以及他们的日志消息告诉您他们进行该提交的原因。但是元数据中的一项是针对 Git 本身的,这是 parent 提交哈希 ID.

  • 的列表
  • 任何 Git 提交中存储的所有内容——事实上,在任何 Git object 中,但大多数情况下你处理直接提交 objects——完全是 read-only。这样做的原因是 Git 通过哈希 ID 找到 object。 Git 有一个很大的 key-value 数据库存储这些 object;键是哈希 ID,值是 object 的内容。每个键唯一标识一个 object,并且每个提交都是不同的,1 因此每个提交都有一个唯一的哈希 ID。2

  • 因此,提交的哈希 ID 实际上是该提交的 "true name"。每当我们将该哈希 ID 存储在某处时,例如,在文件中,或电子表格中的一行,或其他任何地方,我们说这个条目 指向 提交。

  • 每个提交中存储的 parent 哈希 ID 因此 指向 以前的提交。大多数提交只有一个 parent 哈希 ID;使提交成为 merge 提交的原因是它具有两个或更多 parent 哈希 ID。 Git 确保无论何时任何人进行 new 提交,该提交中列出的 parent 哈希 ID 是现有提交的哈希 ID。3

所有这一切的结果是大多数普通提交以简单的线性方式向后指向。如果我们绘制一系列提交,将真实的哈希 ID 替换为单个大写字母,并向右更新提交,我们将得到:

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

其中 H 代表链中 last 提交的哈希 ID。提交 H 指向 (包含其 parent 提交 G 的原始哈希 ID);提交 G 指向较早的提交 F;等等。

因为哈希 ID 看起来很随机,4 我们需要一些方法来 找到 链中的最后一次提交。另一种方法是查看存储库中的 每个 提交,构建所有链,并使用它来确定哪些提交是 "last".5 这太慢了:所以 Git 给了我们 分支名称 masterdev 之类的分支名称仅指向一次提交。无论名称指向什么提交,我们都规定这是分支的 尖端提交 。所以给出:

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

我们说提交H是分支master尖端提交6我们说所有这些提交都包含在分支master.

多个名称可以指向任何一个特定的提交。如果我们有:

...--G--H   <-- dev, master

然后两个名字,devmaster,将提交 H 标识为其 branch-tip 提交。通过并包括 H 的提交在 both 分支上。我们将 git checkout 这些名称之一开始 using commit H;如果我们然后添加 new 提交,新提交将具有提交 H 作为其 parent。例如,如果我们在 "on" 分支 master 时添加一个新提交,则新提交将是提交 I,我们可以这样绘制:

          I   <-- master (HEAD)
         /
...--G--H   <-- dev

特殊名称HEAD可以附加到一个分支名称——一次只能附加一个;它指示哪个 branch-name new 提交更新,并向我们展示哪个 commit 是我们的 current commit 分支名称 是我们的 当前分支 .

master 添加另一个提交,然后签出 dev,得到我们这个:

          I--J   <-- master
         /
...--G--H   <-- dev (HEAD)

当前提交现在回退到 H,当前分支是 dev


1这就是提交具有 date-and-time 标记的原因之一。即使两个提交在其他方面相同,如果它们是在不同的 时间 进行的,它们具有不同的时间戳,因此是不同的提交。如果您在完全相同的时间两次进行完全相同的提交,那么您只进行了一次提交......但是如果您在完全相同的时间多次进行完全相同的事情,那么您实际上做了很多事情,还是只做了一件事?

2Pigeonhole Principle, if the space of "all commits" is larger than the space of "commit hash IDs"—and it is—there must be multiple different commits that resolve to the same hash ID. Git's answer to that is partly "you can't use those other commits" but also "so what, it never happens in practice". See also How does the newly found SHA-1 collision affect Git?

3如果不这样做可能会导致 Git 存储库损坏,"connectivity" 不正确。每当您看到有关 "checking connectivity" 的 Git 消息时,Git 就会进行此类检查。一些新的 Git 工作故意削弱这些连接检查,但即使 Git 有时不 检查 ,至少原则上规则仍然存在。

4当然,它们完全是确定性的——它们目前是 SHA-1 散列——但它们足够不可预测 看起来 随机.

5git fsckgit gc 都这样做,以便确定是否有一些提交可以丢弃。 git fsck 命令会告诉您有关它们的信息——它们是 悬空 and/or 无法访问 提交。如果其他条件正确,git gc 命令将删除它们。特别是,它们需要老化超过到期时间。这避免了 git gc 删除仍在构建中的提交。提交和其他 object 可能无法访问,仅仅是因为创建它们的 Git 命令尚未 完成

6这给我们留下了一个难题:branch 这个词在 Git 中是有歧义的。它是指分支名称,还是提示提交,还是一些以指定结尾的提交集commit? 如果是后者,规范是否必须是 分支名称? 这个问题的答案通常只是 yes: 这个词 branch 可以表示所有这些,甚至更多。另见 What exactly do we mean by "branch"? 因此最好尽可能使用 more-specific 术语。


正在合并

现在我们在 dev 并提交 H,我们可以再添加两个提交来生成:

          I--J   <-- master
         /
...--G--H
         \
          K--L   <-- dev (HEAD)

此时,我们可以git checkout master然后git merge dev。如果提交是 Git 的 raison d'être, Git's automatic merging is a significant reason we all use Git, rather than some other VCS.7 What git merge does is perform a three-way merge,则将 merge base 快照与两个 tip commit 快照组合。

合并基础完全由提交图决定。在这个特定的图表中很容易看到,因为合并基础是 best 提交,它在 both branches.8 所以 git merge 会做的是:

  • 将合并基础提交 H 中的快照与我们当前分支提示提交中的快照进行比较,以查看 我们 更改了什么;和
  • 将合并基础提交H中的快照与他们分支提示提交中的快照进行比较,看看他们 改变了,

然后简单地(或复杂地,如果需要的话)合并这两组变化。合并的更改现在可以应用于 base 快照,即在提交 H.

中所有时间保存的文件

合并两个变更集的结果要么是成功——新快照准备好进入新提交——要么是合并冲突。只要 Git 无法自行组合我们的更改和他们的更改,就会发生冲突。如果发生这种情况,Git 在合并的中间停止,留下一团糟,我们的工作变成了 清理混乱并提供正确的最终快照 然后告诉 Git 继续:git merge --continuegit commit(两者做同样的事情)。

已成功合并更改——可能与您的帮助—Git 现在进行新的提交。这个新提交就像任何其他提交一样,因为它有一个数据快照,并且有一些元数据提供我们的姓名和电子邮件地址,当前 date-and-time,等等。但它在一个方面很特别:它有,作为它的 parents(复数),两个提示提交的 both 的哈希 ID。

与任何提交一样,进行提交的行为会更新当前分支名称,因此我们可以这样绘制结果:

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

请记住,我们使用 git checkout master 启动了该过程,因此当前提交是 J,当前分支名称是,现在仍然是 master。当前提交现在是合并提交M,它的两个parent按顺序是J——这个第一个parent如果您愿意,可以稍后使用 JL.


7许多 pre-Git VCS 进行了 built-in 合并,但没有那么多合并如此巧妙和自动。过去和现在都有其他好的版本控制系统,但是 Git 还添加了 distributed 版本控制,并且与 GitHub 和其他站点一起赢得了network effect。所以现在我们陷入了 Git。就 user-friendliness 而言,Mercurial 明显优于 Git,而 Bitbucket 曾经是 Mercurial-only 站点,但现在……不是了。

8在这里,我们使用 branch 这个词来表示 可以从当前 [=558] 到达的提交集=]。我们知道分支名称稍后会移动:在将来的某个时候,master 不会命名 commit J and/or dev 不会命名 commit L,但现在他们这样做了。所以我们发现提交可以从 J 到达并向后工作,提交可以从 L 到达并向后工作,当我们这样做时,明显的 best 提交两个 分支都是提交 H.


边栏:git merge并不总是合并

在一种特定(但常见)的情况下,git merge 不会进行 合并提交,除非您强制它这样做。特别是,假设两个分支上的最佳 shared 提交是 "behind" 分支上的最后一次提交。也就是说,假设我们有:

...--o--B   <-- br1 (HEAD)
         \
          C--D   <-- br2

其中D的parent是CC的parent是B,依此类推。我们已经 br1 签出,如此处 HEAD 所示。如果我们 运行 git merge br2,Git 会像往常一样找到提交 BD,从 DC 向后工作到B,并发现最好的 shared 提交——both 分支上的最佳提交——是提交 B,它也是 当前 提交。

如果我们在此时进行真正的合并,Git 将比较 B 中的快照与 B 中的快照:base 与 HEADB 对比 B。显然这里没有变化。然后 Git 将比较 BD 中的快照。无论这些更改是什么,Git 都会将这些更改应用于 B 中的快照。结果是……D.

中的快照

所以如果 Git 在此时进行真正的合并,它将产生:

...--o--B------M   <-- br1 (HEAD)
         \    /
          C--D   <-- br2

M 中的快照与 D 中的快照完全匹配。

您可以使用 git merge --no-ff 强制 Git 进行真正的合并 ,但默认情况下,Git 将 "cheat" .它会告诉自己:合并快照会匹配D,所以我们可以让名称br1直接指向提交D所以git merge 将简单地 git checkout D,但也会滑动名称 br1 "forward" 以指向提交 D:

...--o--B
         \
          C--D   <-- br1 (HEAD), br2

如果您使用 GitHub 进行合并,请注意 GitHub 始终强制进行真正的合并,这样您就永远不会得到 fast-forward.9


9最接近的是使用GitHub的rebase and merge模式,但是这个复制 否则fast-forward-可合并的提交。它为他们提供了新的提交者 name-and-email-and-time-stamps,并且生成的提交具有新的哈希 ID。所以它从来都不是真正的 fast-forward。这有时很烦人,我希望他们有一个真正的 fast-forward 选项。


合并提交本身的存在对未来的合并很重要

假设我们已经完成这个模式一段时间了,并且有:

...--o--o--o------A-----M   <-- master
         \       /     /  
          o--o--o--o--B--C--D   <-- dev

哪个提交是 dev 合并基础 ?这里有一个很大的提示:它是字母提交之一,而不是更无聊的历史 o 提交。

棘手的部分是要找到合并基础,当我们从分支尖端提交向后走时,我们应该同时访问两个parent .所以合并提交 M 有两个 parent,AB。同时,从 D 开始并向后工作,我们也到达提交 B (经过两步)。所以提交 B 是合并基础

B 是合并基础的原因是 存在合并提交 M。名称 master 指向 MM 指向两个提交,AB。提交 B 是 "on"(包含在)分支 master,显然是 on/contained-in 分支 dev,只要 master 指向 is 提交 Mreaches (通过某些提交链)合并 M.

Git 通常只会 adds 提交到分支,有时通过提交一次一个,有时通过合并或 fast-forwarding 一次提交多个.一旦提交 B 通过提交 M 变为 "on"(包含在)分支 master 中,它将继续为 on/contained-in master。未来的合并可能会发现 B 更好的 提交,但只要这些提交继续在 masterdev 上,提交 B 将永远是 merge-base 候选人。

这就是为什么您不能轻松撤消合并的原因

所以这就是为什么没有 force-pushing 就不能 "undo a merge" 的原因。您可以在新提交中更改 snapshots——这就是 git revert 的意思,例如——但您不能更改 history现有的提交。历史是通过遍历图表找到的提交集,所有现有提交都将一直冻结并保留在图表中,只要它们可以找到:

...--o--o--o------A-----M   <-- master
         \       /     /  
          o--o--o--o--B--C--D   <-- dev

master 的历史记录是 提交 M,然后两者都提交 AB,然后是他们的 parent 和等等dev 的历史记录是 提交 D,然后提交 C,然后提交 B,依此类推

改变master看到的历史,你必须说服Git停止走过提交 M。如果您使用 force-push 从 master 删除 M——它仍然存在,只是无法通过 master 找到它——你得到:

                    ------M   ???
                   /     /
...--o--o--o------A  <-- master
         \       /     /  
          o--o--o--o--B--C--D   <-- dev

(请注意,在此图中没有 名称可以找到 M,因此最终 git gc 将完全丢弃提交 M。另见脚注 5。)

Force-push 是我们告诉 Git 的方式: 是的,此操作将使某些提交无法访问,并且可能永远丢失它们。我们的意思是让这一切发生! 通过完全删除合并提交 M,我们回到从未发生过合并的状态并且提交 B 不会下次做合并基地

(练习:找到合并基。)