我们使用具有最新开发的主分支进行开发,并且发布分支经常从该主分支中分离出来并构成一个版本。 错误在这些发布分支上得到修复,这些错误修复被合并回主分支。我们所有的更改都通过 PR,您无法手动(强制)推送任何这些重要分支。
现在,人为错误导致主分支被合并到发布分支中(通过 PR)。这是通过包含错误合并提交的还原提交的 PR 还原的。因此,发布分支是"好的"(除了这两个额外的提交)。 随后,此发布分支合并到主分支中。 接下来发生的事情是出乎意料的:从主分支到版本的错误合并以某种方式被忽略了(这是合乎逻辑的),但后续的还原提交撤消了错误,有效地删除了自发布分支被拆分以来主分支上的所有更改。
不幸的是,我没有关于这究竟是如何发生的细节,但这可以以某种方式解释为"预期"行为。我计划编写一个小的 git 命令脚本,尽快重复这种顺序,并将在此处更新问题。
我的问题是:有没有办法(无需强制推动和消除错误提交)能够将发布分支合并到主分支中,而不会恢复提交对主分支的文件产生影响?现在看来,这总是会导致恢复提交更改不应更改的内容。
是的,这是正常的。 TL;DR:你可能想还原还原。 但是您询问的更多是机制,而不是快速解决方案,因此:
长
理解 Git 合并的方法是理解:
- Git 使用(存储)快照;
- 提交是历史记录:它们链接回较旧的提交; 提交首先"在分支上
- "意味着什么,并且提交通常在多个分支上;
git merge
定位合并库,即两个分支上的最佳共享提交;和
合并- 的工作原理,使用合并库和两个提示提交。
快照部分非常简单:每次提交都包含每个文件的完整副本,就像您(或任何人)提交时的状态一样。1有一个怪癖,那就是 Git 从其索引AKA暂存区中的任何内容而不是某个工作树中的内容进行提交,但这主要解释了为什么你必须运行这么多git add
。
第 2 点和第 3 点相互关联:提交是历史记录,因为每次提交都存储一些早期提交的原始哈希 ID。 这些向后指向的链接让 Git 在时间上向后移动:从提交到父级,然后从父级到祖父级,依此类推。 像main
或master
这样的分支名称只是标识我们要声明的最后一个提交是分支上的最后一次提交。
这意味着您需要同时理解第 2 点和第 3 点。 最初,这并不难,因为我们可以像这样绘制提交:
... <-F <-G <-H
在这里,H
代表上次(最新)提交的哈希 ID。 我们可以看到,H
"指向"早期的提交G
(提交H
字面上包含提交G
的原始哈希 ID)。 因此G
是H
的父母。 同时提交G
包含更早的提交F
的原始哈希ID:F
是G
的父级,这使得它成为H
的祖父级。
对于此图,我们只需在末尾添加一个分支名称,例如,main
指向H
:
...--F--G--H <-- main
当我们向分支添加新提交时,Git :
- 使用索引/暂存区域中的快照进行新提交;
- 用元数据包装它,说明谁进行了提交,他们现在进行了提交,父级是提交
H
(当前提交),等等; - 写出所有这些以获得一个新的随机外观哈希ID,我们将它称为
I
;并且 - 这是棘手的一点 - 然后 - 将
I
的哈希 ID 写入名称main
中。
最后一步更新分支,以便我们有:
...--F--G--H--I <-- main
main
这个名字现在选择I
,而不是H
;我们使用I
来查找H
,我们用来查找G
,我们用来查找F
,等等。
Git 知道main
更新名称,因为(或者更确切地说,如果)这是我们在进行新提交I
时"在"的分支。 如果我们有多个分支名称,它们可能都指向同一个提交:
...--G--H <-- develop, main, topic
在这里,所有三个分支名称都选择提交H
。 这意味着我们git checkout
或git switch
哪一个并不重要,就我们签出的内容而言:无论如何,我们都会得到提交H
签出。 但是,如果我们选择develop
作为我们在这里使用的名称,那么 Git 也会告诉develop
也是当前的名称:
...--G--H <-- develop (HEAD), main, topic
请注意,所有提交(包括提交H
)都在所有三个分支上。
现在,当我们进行新的提交I
时,Git 更新的名称将被develop
:这是HEAD
附加的特殊名称的名称。 因此,一旦我们制作了I
我们就有了:
I <-- develop (HEAD)
/
...--G--H <-- main, topic
如果我们再提交一次,我们会得到:
I--J <-- develop (HEAD)
/
...--G--H <-- main, topic
到H
的提交仍在所有三个分支上。 提交I
和J
- 至少目前 - 仅在develop
上提交。
如果我们现在git switch topic
或git checkout topic
,我们移回提交H
,同时将特殊名称附加到新选择的分支名称:
I--J <-- develop
/
...--G--H <-- main, topic (HEAD)
如果我们现在再做两次提交,那么这次移动的是名称topic
:
I--J <-- develop
/
...--G--H <-- main
K--L <-- topic (HEAD)
从这里开始,事情变得有点复杂和混乱,但我们现在准备研究合并基础的概念。
1这些完整副本经过重复数据消除,因此,如果连续 3 次提交每次重复使用数百个文件,并且在新提交中只有一个文件一遍又一遍地更改,则数百个文件的每个文件只有一个副本,在所有 3 个提交之间共享;它是一个更改的文件,有三个副本, 三个提交各一个。 重用适用于所有时间:今天进行的新提交,将所有文件设置回去年的方式,重用去年的文件。 (Git也进行了增量压缩,后来是无形的,并且以与大多数 VCS 不同的方式进行,但旧文件的即时重用意味着这并不像看起来那么重要。
Merge 有多种风格:现在让我们看看快进合并
运行git merge
总是会影响当前分支,因此第一步通常是挑选正确的分支。 (只有当我们已经在正确的分支上时,我们才能跳过此步骤。 假设我们想签出main
并合并develop
,所以我们运行git checkout main
或git switch main
:
I--J <-- develop
/
...--G--H <-- main (HEAD)
K--L <-- topic
接下来,我们将运行git merge develop
。 Git 将找到合并库:两个分支上的最佳提交。main
上的提交是所有提交,包括 - 结束于 - 提交H
。 那些在develop
上都是通过J
,沿着中间和顶线提交的。 Git 实际上通过向后工作而不是向前工作来找到这些,但重要的是它发现通过H
提交的提交是共享的。
提交H
是最好的共享提交,因为它在某种意义上是最新的。阿拉伯数字仅通过目测图表也很明显。 但是:请注意,合并库的提交H
与我们现在所在的提交相同。 我们在main
上,它选择提交H
。 在git merge
中,这是一个特例,Git 称之为快进合并。3
在快进合并中,不需要实际合并。 在这种情况下,Git 将跳过合并,除非您告诉它不要这样做。 相反,Git 将只签出由其他分支名称选择的提交,并拖动当前分支名称以满足该要求并保持HEAD
附加,如下所示:
I--J <-- develop, main (HEAD)
/
...--G--H
K--L <-- topic
请注意没有发生新的提交。 Git 只是将名称main
"向前"(移动到顶行的末尾),而不是 Git 通常移动的方向(从提交向后移动到父级)。 这就是行动中的快进。
您可以强制 Git 针对此特定情况进行真正的合并,但出于我们的说明目的,我们不会这样做(这对您自己的情况没有任何帮助)。 相反,我们现在将继续进行另一个合并,其中 Git无法进行快进。 我们现在将运行git merge topic
.
2这里的"最新">不是由日期定义的,而是由图表中的位置定义的:例如,H
比G
"更接近"J
。 从技术上讲,合并基是通过解决有向无环图扩展的最低共同祖先问题来定义的,在某些情况下,可以有多个合并基提交。 我们会小心翼翼地忽略这个案例,希望它永远不会出现,因为它相当复杂。 找到我的其他一些答案,看看 Git 在出现时会做什么。
3快进实际上是标签运动(分支名称或远程跟踪名称)的属性,而不是合并,但是当你使用git merge
实现这一点时,Git 称之为快进合并。 当你用git fetch
或git push
得到它时,Git 称之为快进,但通常什么也不说;当无法进行提取或推送时,在某些情况下会出现非快进错误。 不过,我将把这些排除在这个答案之外。
真正的合并更难
如果我们现在运行git merge topic
,Git 必须再次找到合并库,即最佳共享提交。 请记住,我们现在处于这种情况:
I--J <-- develop, main (HEAD)
/
...--G--H
K--L <-- topic
通过J
提交是在main
,我们当前的分支。 提交到H
,加上K-L
,都在topic
上。 那么哪个提交是最好的共享提交呢? 好吧,从J
向后工作:你从J
开始,然后点击提交I
,然后H
,然后G
,依此类推。 现在从L
向后工作K
到H
:提交H
是共享的,它是"最右边"/最新的可能共享提交,因为G
先于H
。 所以合并基础再次提交H
.
但是,这一次,提交H
不是当前提交:当前提交是J
。 所以 Git 不能使用快进作弊。 相反,它必须进行真正的合并。注意:这是您最初问题出现的地方。合并是关于合并更改。 但提交本身并不包含更改。 他们持有快照。 我们如何找到更改的内容?
Git 可以将提交H
与提交I
进行比较,然后将I
提交到提交J
,一次一个,以查看main
上发生了什么变化。 但这不是它的作用:它采用稍微不同的快捷方式,并将H
直接与J
进行比较。 但是,如果它一次提交一次并不重要,因为它应该接受所有更改,即使这些更改之一是"撤消某些更改"(git revert
)。
比较两个提交的 Git 命令是git diff
(无论如何,如果你给它两个提交哈希 ID)。 所以这基本上等同于:4
git diff --find-renames <hash-of-H> <hash-of-J> # what we changed
在弄清楚了自共同起点以来您更改了什么之后,Git 现在需要弄清楚它们更改了什么,这当然只是另一个git diff
:
git diff --find-renames <hash-of-H> <hash-of-L> # what they changed
git merge
现在的工作是将这两组更改结合起来。 如果您更改了README
文件的第 17 行,Git 会将您的更新带到README
的第 17 行。 如果他们在main.py
的第 40 行之后添加了一行,Git 会将它们添加到main.py
中。
Git 获取这些更改中的每一个(您的和他们的更改),并将这些更改应用于合并库提交H
中的快照。 这样,Git 会保留你的工作并添加他们的工作——或者,通过同样的论点,Git 保留他们的工作并添加你的工作。
请注意,如果您在提交H
后某处进行了还原,而他们没有,那么您的还原是自合并基础以来的更改,并且自合并基础以来他们没有更改任何内容。 所以 Git 也拿起了还原。
在某些情况下,您和他们可能更改了同一文件的相同行,但方式不同。 换句话说,您可能会遇到冲突的更改。5对于这些情况,Git 会声明合并冲突,并给您留下必须自己清理的混乱。 但在令人惊讶的情况下,Git 的合并只是自己工作。
如果 Git能够自己成功合并所有内容——或者即使不能,但只要它认为它这样做了——Git 通常会继续自己进行新的提交。 这个新提交在一个方面很特别,但让我们先画它:
I--J <-- develop
/
...--G--H M <-- main (HEAD)
/
K--L <-- topic
请注意名称main
如何像往常一样向前拖动一跳,以便它指向刚刚进行的新提交 Git。 提交M
具有快照,就像任何其他提交一样。 快照是从 Git 的索引/暂存区域中的文件创建的,就像任何其他提交一样。6
事实上,新合并提交M
的唯一特别之处在于,它不仅仅是一个父提交J
,而是有两个。 对于通常的第一个父级,Git 会添加第二个父级L
。 这是我们在git merge
命令中命名的提交。 请注意,其他分支名称也不会受到影响:名称main
已更新,因为它是当前分支。 而且,由于"在"分支上的提交集是通过从上次提交向后工作找到的,所以现在所有提交都在main
上。 我们从M
开始,然后我们返回一跳到提交J
和L
。 从这里开始,我们向后移动一跳到提交I
和K
。 从那里,我们向后移动一跳以提交H
:向后移动一跳解决了分支先前分叉点的"多路径"问题。
4--find-renames
部分处理您使用git mv
或等效物的情况。 合并会自动打开重命名查找;git diff
在最新版本的 Git 中默认自动打开它,但在旧版本中,您需要显式--find-renames
.
5如果您更改的区域仅接触(毗邻)他们更改的区域,则 Git 也会声明冲突。 在某些情况下,可能存在排序约束;一般来说,在 Merge 软件上工作的人发现这提供了最好的整体结果,在适当的时候会产生冲突。 当不是真正需要冲突时,您可能偶尔会遇到冲突,或者在发生冲突时不会发生冲突,但在实践中,这种简单的逐行规则对于大多数编程语言都非常有效。 (对于研究论文等文本内容,它往往不太有效,除非你习惯于将每个句子或独立子句放在自己的行上。
6这意味着,如果你必须解决冲突,你实际上是在 Git 的索引/暂存区中执行此操作。 你可以使用工作树文件来做到这一点——这是我通常做的——或者你可以使用 Git 在暂存区域中留下的三个输入文件来标记冲突。不过,我们不会在这里详细介绍,因为这只是一个概述。
<小时 />真实合并留下痕迹
现在我们有这个:
I--J <-- develop
/
...--G--H M <-- main (HEAD)
/
K--L <-- topic
我们可以git checkout topic
或git switch topic
并为此做更多工作:
I--J <-- develop
/
...--G--H M <-- main
/
K--L <-- topic (HEAD)
成为:
I--J <-- develop
/
...--G--H M <-- main
/
K--L---N--O <-- topic (HEAD)
例如。 如果我们现在git checkout main
或git switch main
,并再次运行git merge topic
,合并基础提交是什么?
让我们找出答案:从M
,我们回到J
和L
。 从O
,我们回到N
,然后回到L
。啊哈!提交L
位于两个分支上。
提交K
也在两个分支上,提交H
也是如此;但是提交I-J
不是,因为我们必须遵循提交中的"向后箭头",并且没有从L
到M
的链接,只有从M
向后到L
。 所以从L
我们可以到达K
然后H
,但我们不能那样M
,也没有J
或I
的途径。 提交K
明显不如L
,H
不如K
,依此类推,所以提交L
是最好的共享提交。
这意味着我们的下一个git merge topic
运行其两个差异如下:
git diff --find-renames <hash-of-L> <hash-of-M> # what we changed
git diff --find-renames <hash-of-L> <hash-of-O> # what they changed
"我们改变了什么"部分基本上是重新发现我们从I-J
那里带来的东西,而"他们改变了什么"部分,从字面上看,他们改变了什么。 Git 将这两组更改组合在一起,将合并的更改从L
应用于快照,并创建一个新快照:
I--J <-- develop
/
...--G--H M------P <-- main (HEAD)
/ /
K--L---N--O <-- topic
请注意,这次不可能快进,因为main
标识了提交M
(合并),而不是提交L
(合并基础)。
如果我们稍后在topic
上做更多的开发,然后再次合并,未来的合并基础现在将提交O
。 我们不必重复旧的合并工作,除了将差异从L
传播到M
(现在保留为从O
到P
的差异)。
还有更多的合并变体
我们不会涉及git rebase
——因为它是重复的樱桃采摘,所以是一种合并形式(每个樱桃采摘本身就是一个合并)——但让我们简要地看一下git merge --squash
。 让我们从这个开始:
I--J <-- branch1 (HEAD)
/
...--G--H
K--L <-- branch2
这样很明显,合并基础是提交H
,并且我们正在提交J
。 我们现在运行git merge --squash branch2
. 这会像以前一样定位L
,像以前一样执行两个git diff
,并像以前一样组合工作。 但是这一次,它不是进行合并提交M
,而是进行常规提交,我称之为S
(用于 squash),我们像这样绘制:
I--J--S <-- branch1 (HEAD)
/
...--G--H
K--L <-- branch2
请注意,S
根本不会重新连接到提交L
。 Git 从不记得我们是如何S
的。S
只有一个快照,该快照是由同一进程制作的,该快照将使合并提交M
。
如果我们现在向branch2
添加更多提交:
I--J--S <-- branch1
/
...--G--H
K--L-----N--O <-- branch2 (HEAD)
并运行git checkout branch1
或git switch branch1
,然后再次git merge branch2
,合并基将再次提交H
。 当 Git 比较H
与S
时,它会看到我们做了他们在L
中所做的所有相同的更改,加上我们在I-J
中所做的任何更改;当 Git 比较H
与O
时,会看到他们做了整个序列中所做的所有更改K-L-N-O
;Git 现在必须将我们的更改(包含以前的一些更改)与所有更改(同样包含以前的一些更改)结合起来。
这确实有效,但合并冲突的风险会增加。 如果我们继续使用git merge --squash
,在大多数情况下,合并冲突的风险会大大增加。 作为一般规则,像这样的壁球之后唯一要做的就是完全放弃branch2
:
I--J--S <-- branch1 (HEAD)
/
...--G--H
K--L ???
提交S
保存所有与K-L
相同的更改,所以我们放弃branch2
,忘记了如何查找提交K-L
。 我们从不回头寻找它们,最终——很长一段时间后——Git 真的会把它们扔掉,它们将永远消失,只要没有其他人做出任何让 Git 找到它们的名字(分支或标签名称)。 历史似乎总是这样:
...--G--H--I--J--S--... <-- somebranch
总结
- 快进合并不会留下痕迹(也不会进行任何实际合并)。
- 真正的合并会留下痕迹:具有两个父级的合并提交。 合并操作(合并或合并为谓词的操作)使用合并基来找出合并提交中的内容(合并为形容词)。
- 南瓜合并不会留下任何痕迹,通常意味着你应该杀死压扁的树枝。 还原
- 只是正常的日常提交,因此合并还原会合并还原。 您可以在合并之前或之后还原还原以撤消还原。