如何在没有强制推送的情况下撤消合并?的简短回答是:你不能。
更长的答案是你不能,但你也不需要,只要你知道你在做什么以及合并是如何工作的;如果您可以说服所有其他用户使用您以这种方式强制执行的任何存储库,那么强制推送有时会更方便。
TL;DR:如果您确实需要还原合并,请执行此操作;您可以稍后恢复还原
请参阅How to revert a merge commit that's already pushed to remote branch? 另请参阅MK446's answer to the same question,这几乎是 Linus Torvald 关于还原合并还原的描述的复制粘贴。
了解所有这些(长篇)
理解为什么会出现这种情况以及如何处理的关键是要认识到任何一组提交的“合并性”都是提交本身所固有的。分支名称仅作为查找提交的方式。执行强制推送的行为是一种更改name 指向的方法,以便人们(和Gits)不再find 某些提交。
一旦你得到它就很容易看到,但我仍然不知道如何正确解释它,除了说服人们画图表。 Linus Torvalds 是这样总结的——虽然准确,但也很棘手:
[虽然] 恢复合并提交...撤消提交更改的数据,...它对合并对历史的影响绝对没有任何作用。所以合并仍然存在,它仍然会被视为将两个分支连接在一起,并且未来的合并会将合并视为最后一个共享状态 - 恢复引入的合并的恢复根本不会影响它。因此,“还原”撤消了数据更改,但从某种意义上说,它不是“撤消”,因为它不会撤消提交对存储库历史的影响。因此,如果您将“还原”视为“撤消”,那么您将永远错过这部分还原。是的,它会撤消数据,但不,它不会撤消历史记录。
“历史”是提交图。该图由提交决定,但我们找到提交由分支名称。因此,我们可以通过更改存储在 names 中的哈希 ID 来更改我们可以 看到 的内容。但是,除非您知道并在自己的脑海中看到它是如何工作的,否则这并没有真正的帮助。
您可能会花一些时间在 Think Like (a) Git 上查看教程,但为了快速查看,请考虑以下事实:
Git 提交由两部分组成:它的主要数据,它是所有文件的快照——我们将在这里不多说——以及它的元数据,它包含有关提交本身的信息。大多数元数据都是供您稍后自己了解的信息:谁进行了提交,何时以及他们的日志消息告诉您他们为什么进行该提交。但是元数据中有一项是针对 Git 本身的,即父提交哈希 ID 列表。
存储在任何 Git 提交中的所有内容(实际上,在任何 Git object 中,但大多数情况下您直接处理提交对象)都是完全只读的。这样做的原因是 Git 通过哈希 ID 找到对象。 Git 有一个存储这些对象的大键值数据库;键是哈希 ID,值是对象的内容。每个键唯一标识一个对象,每个提交都是不同的,1因此每个提交都有一个唯一的哈希 ID。2
因此,提交的哈希 ID 实际上是该提交的“真实名称”。每当我们将该哈希 ID 存储在某处时,例如,在文件中,或电子表格中的一行,或其他任何地方,我们说这个条目指向提交。
存储在每个提交中的父哈希 ID 因此指向之前的提交。大多数提交只有一个父哈希 ID;使提交成为 merge 提交的原因在于它具有两个或多个父哈希 ID。 Git 确保无论何时任何人进行 new 提交,该提交中列出的父哈希 ID 都是现有提交的 ID。3
所有这一切的结果是,大多数普通提交以简单的线性方式向后指向。如果我们绘制一系列提交,用单个大写字母替换真实的哈希 ID,将较新的提交放在右边,我们得到:
... <-F <-G <-H
H 代表链中 last 提交的哈希 ID。提交H 指向(包含原始哈希ID)它的父提交G;提交G 指向更早的提交F;等等。
因为哈希 ID 看起来非常随机,4 我们需要一些方法来找到链中的最后一个提交。另一种方法是查看存储库中的每个提交,构建所有链,并使用它来确定哪些提交是“最后一个”。5太慢了:所以 Git 给了我们分支名称。像master 或dev 这样的分支名称只是指向一个提交。无论名称指向什么提交,我们都认定这是分支的 tip commit。所以给定:
...--F--G--H <-- master
我们说提交H 是分支master 的提示提交。6我们说所有这些提交都包含在 分支master.
多个名称可以指向任何一个特定的提交。如果我们有:
...--G--H <-- dev, master
然后两个名称dev 和master 将提交H 标识为它们的分支提示提交。通过并包括 H 的提交都在 both 分支上。我们将git checkout 这些名称之一开始使用 提交H;如果我们随后添加一个 new 提交,则新提交将具有提交 H 作为其父提交。例如,如果我们在“开启”分支master 时添加一个新提交,新提交将是提交I,我们可以这样绘制:
I <-- master (HEAD)
/
...--G--H <-- dev
特殊名称HEAD 可以附加到一个分支名称——一次只能附加一个;它指示哪个分支名称 new 提交更新,以及向我们显示哪个 commit 是我们的 当前提交 以及哪个 分支名称 是我们的当前分支。
添加另一个提交到master,然后签出dev,给我们这个:
I--J <-- master
/
...--G--H <-- dev (HEAD)
当前提交现在回退到H,当前分支是dev。
1这是提交具有日期和时间戳的原因之一。即使两个提交在其他方面相同,如果它们是在不同的时间进行的,它们具有不同的时间戳,因此是不同的提交。如果您在完全相同的时间进行两次完全相同的提交,那么您只进行了一次提交......但是如果您在完全相同的时间多次执行完全相同的事情,您实际上做了很多事情,还是只做一件事? ?
2通过Pigeonhole Principle,如果“所有提交”的空间大于“提交哈希ID”的空间——而且确实如此——必须有多个不同的提交解析为相同的哈希 ID。 Git 对此的回答部分是“你不能使用那些其他提交”,但也是“那又怎样,它在实践中从未发生过”。另见How does the newly found SHA-1 collision affect Git?
3不这样做可能会导致 Git 存储库损坏,“连接”不正确。每当您看到有关“检查连接性”的 Git 消息时,Git 都会进行此类检查。一些新的 Git 工作故意削弱了这些连接性检查,但即使 Git 有时不检查,至少原则上这些规则仍然存在。
4当然,它们完全是确定性的——它们目前是 SHA-1 哈希值——但它们具有足够的不可预测性,看起来是随机的。
5git fsck 和 git gc 都这样做,以便确定是否有一些可以丢弃的提交。 git fsck 命令会告诉你它们的相关信息——它们是 dangling 和/或 unreachable 提交。如果其他条件正确,git gc 命令将删除它们。特别是,它们需要老化超过过期时间。这避免了 git gc 删除仍在构建的提交。提交和其他对象可能无法访问,因为创建它们的 Git 命令尚未完成。
6这给我们留下了一个难题:在 Git 中,branch 这个词是模棱两可的。是分支名称,还是提示提交,还是一些以指定提交结束的提交?表示后者,规范是否必须是分支名称? 这个问题的答案通常只是是的: branch 这个词可以表示所有这些,也许更多。另见What exactly do we mean by "branch"? 所以最好尽可能使用更具体的术语。
合并
现在我们在 dev 和提交 H 上,我们可以再添加两个提交来生成:
I--J <-- master
/
...--G--H
\
K--L <-- dev (HEAD)
此时,我们可以先git checkout master,然后再git merge dev。如果提交是 Git 的 raison d'être,那么 Git 的自动 合并 是我们所有人使用 Git 的一个重要原因,而不是其他一些 VCS。7 什么@ 987654369@ 确实是执行three-way merge,将 merge base 快照与两个 tip commit 快照结合起来。
合并基础完全由提交图确定。在这个特定的图表中很容易看到,因为合并基础是 两个分支上的 最佳 提交。8 那么git merge 将做的是:
- 将合并基础提交
H 中的快照与我们当前分支提示提交中的快照进行比较,看看我们 改变了什么;和
- 比较合并基础提交
H 中的快照与他们的 分支提示提交中的快照,看看他们 改变了什么,
然后简单地(或复杂地,如果需要)组合这两组更改。现在可以将合并的更改应用到 base 快照,即在提交 H 中一直保存的文件。
组合这两个变更集的结果要么是成功——一个准备好进入新提交的新快照——要么是一个合并冲突。每当 Git 无法自行组合我们的更改及其更改时,就会发生冲突情况。如果发生这种情况,Git 会在合并过程中停止,留下一团糟,我们的工作就是清理混乱并提供正确的最终快照,然后告诉 Git 继续:git merge --continue 或git commit(两者都做同样的事情)。
在成功合并更改后——也许在我们的帮助下——Git 现在进行了新的提交。这个新的提交就像任何其他提交一样,因为它有一个数据快照,并且有一些元数据给出我们的姓名和电子邮件地址、当前日期和时间等等。但它的特殊之处只有一个:作为其父项(复数),它具有两个提示提交的 both 的哈希 ID。
与任何提交一样,提交的行为会更新当前分支名称,因此我们可以绘制如下结果:
I--J
/ \
...--G--H M <-- master (HEAD)
\ /
K--L <-- dev
请记住,我们以git checkout master 开始该过程,因此当前提交是J,当前分支名称是,现在仍然是master。当前提交现在是合并提交M,它的两个父级依次是J——如果你愿意,可以稍后使用J 的这个第一个父级——和@ 987654383@.
7许多前 Git VCS 具有内置的合并功能,但没有那么多智能和自动的合并功能。过去和现在都有其他优秀的版本控制系统,但 Git 还添加了分布式版本控制,并与 GitHub 和其他网站一起赢得了network effect。所以现在我们被Git困住了。 ? Mercurial 在用户友好性方面明显优于 Git,而 Bitbucket 曾经是 Mercurial 专用网站,但现在......不是了。
8这里,我们用branch这个词来表示可以从当前分支提示到达的提交集。我们知道分支名称稍后会移动:在将来的某个时候,master 不会将提交命名为J 和/或dev 不会将提交命名为L,但现在他们会这样做。因此,我们发现可以从 J 访问并向后工作的提交,以及从 L 可访问的提交并向后工作,当我们这样做时,明显的 best 提交在 both 分支是提交H。
侧边栏:git merge 并不总是合并
在一个特定(但常见的)条件下,git merge 不会进行 合并提交,除非您强制它这样做。特别是,假设两个分支上最好的 shared 提交是“后面”分支上的最后一个提交。也就是说,假设我们有:
...--o--B <-- br1 (HEAD)
\
C--D <-- br2
其中D 的父级是C,C 的父级是B,依此类推。我们已签出br1,如HEAD 所示。如果我们运行git merge br2,Git 会像往常一样找到提交B 和D,从D 向后工作到C 再到B,并发现最好的共享提交——both 分支上最好的提交——是提交 B,这也是 当前 提交。
如果我们此时进行真正的合并,Git 会比较 B 中的快照和 B 中的快照:base vs HEAD 是 B vs B。显然这里没有变化。然后 Git 会比较 B 和 D 中的快照。无论这些更改是什么,Git 都会将这些更改应用于B 中的快照。结果是……D 中的快照。
因此,如果 Git 在此时进行真正的合并,它将产生:
...--o--B------M <-- br1 (HEAD)
\ /
C--D <-- br2
M 中的快照与D 中的快照完全匹配。
您可以使用git merge --no-ff 强制 Git 进行真正的合并,但默认情况下,Git 会“作弊”。它会告诉自己:合并快照将匹配D,所以我们可以让名称br1 直接指向提交D。 所以git merge 将只是git checkout D,还要滑动名称br1“forward”指向提交D:
...--o--B
\
C--D <-- br1 (HEAD), br2
如果您使用 GitHub 进行合并,请注意 GitHub 始终强制进行真正的合并,这样您就永远不会快进。9
9你能得到的最接近的方法是使用 GitHub 的 rebase and merge 模式,但这 复制 其他方式快进的提交-可合并。它为他们提供了新的提交者名称、电子邮件和时间戳,并且生成的提交具有新的哈希 ID。所以它从来都不是真正的快进。这有时很烦人,我希望他们有一个真正的快进选项。
合并提交本身的存在对未来的合并很重要
假设我们已经做了一段时间的这种模式,并且有:
...--o--o--o------A-----M <-- master
\ / /
o--o--o--o--B--C--D <-- dev
哪个提交是master 和dev 的合并基础?这里有一个很大的提示:它是字母提交之一,而不是更无聊的历史 o 提交。
棘手的部分是要找到一个合并基础,当我们从一个分支提示提交向后走时,我们应该同时访问 两个父节点。所以合并提交M 有两个父级,A 和B。同时,从D 开始并向后工作,我们也到达了提交B(经过两跳)。所以 commit B 是合并基础。
原因B 是合并基础是存在合并提交M。名称 master 指向 M 和 M 指向两个提交,A 和 B。提交B 是“on”(包含在)分支master,并且显然是在/包含在分支dev 上,只要master 指向一个提交,这将继续为真em>是提交M,或达到(通过一些提交链)合并M。
Git 通常只添加 提交到分支,有时通过提交一次一个,有时通过合并或快进一次多个。一旦提交B 通过提交M 变为“开启”(包含在)分支master,它将继续开启/包含在master。未来的合并可能会找到比提交 B 更好的提交,但只要这些提交继续在 master 和 dev 上,提交 B 将始终是合并基础候选人。
这就是你不能轻易撤消合并的原因
所以这就是为什么你不能在没有强制推送的情况下“撤消合并”。您可以更改新提交中的快照(例如,git revert 就是这样),但您不能更改现有提交的历史。历史记录是通过遍历图找到的提交集合,所有现有的提交都会一直冻结,只要可以找到就保留在图中:
...--o--o--o------A-----M <-- master
\ / /
o--o--o--o--B--C--D <-- dev
master 的历史是提交M,然后都提交A 和B,然后是他们的父母等等。 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不会成为合并下次打基地。
(练习:找到合并基。)