是的,这很正常。 TL;DR:您可能想要还原还原。但是您询问的是有关机制的更多信息,而不是快速解决方案,所以:
长
理解Git的merge的方法是理解:
- Git 使用(存储)快照;
- 提交是历史:它们链接回旧提交;
- 首先提交“在一个分支上”意味着什么,并且提交通常在多个分支上;
-
git merge 定位 合并基础,即 两个分支上的最佳共享提交;和
- 合并的工作原理,使用合并基础和两个提示提交。
快照部分非常简单:每个提交都包含每个文件的完整副本,以您(或任何人)进行该提交时的状态为准。1 有一个怪癖,也就是说,Git 从其 index AKA staging area 中的任何内容进行提交,而不是某些工作树中的内容,但这主要解释了为什么您必须运行 @987654323 @这么多。
第 2 点和第 3 点相互关联:提交是历史记录,因为每个提交都存储了一些较早提交的原始哈希 ID。这些向后指向的链接让 Git 在时间上向后移动:从提交到父级,然后从父级到祖父级,等等。像main 或master 这样的分支名称 仅标识我们想要声明的任何提交是最后 提交“在”分支上。
这意味着您需要同时理解第 2 点和第 3 点。最初,这并不太难,因为我们可以像这样绘制提交:
... <-F <-G <-H
这里H 代表last(最新)提交的哈希ID。我们可以看到H“指向”之前的提交G(提交H字面上包含提交G的原始哈希ID)。因此G 是H 的父级。同时提交G 包含仍然较早提交F 的原始哈希ID:F 是G 的父级,这使其成为H 的祖父级。
对于这张图,我们只是在末尾添加一个分支名称,例如,main 指向H:
...--F--G--H <-- main
当我们向分支添加 new 提交时,Git:
- 使用索引/暂存区域中的快照进行新提交;
- 用元数据包装它,说明谁提交了提交,他们现在提交了,父提交是
H(当前提交)等等;
- 写出所有这些以获得一个新的随机哈希 ID,我们将其称为
I;并且——这是棘手的一点——那么
- 将
I 的哈希ID 写入名称 main。
最后一步更新分支,这样我们就有了:
...--F--G--H--I <-- main
名称main 现在选择I,而不是H;我们使用I 来查找H,我们使用它来查找G,我们使用它来查找F,等等。
Git 知道要更新名称 main,因为(或者更确切地说,if)这是我们在进行新提交 I 时“打开”的分支。如果我们有多个分支名称,它们可能都指向同一个提交:
...--G--H <-- develop, main, topic
这里所有三个分支名称都选择提交H。这意味着我们 git checkout 或 git switch 到哪一个并不重要,就我们得到 签出的内容而言: 我们得到提交 H 在任何情况下签出。但是,如果我们选择 develop 作为我们在这里使用的名称,这会告诉 Git develop 也是 当前名称:
...--G--H <-- develop (HEAD), main, topic
请注意,通过并包括提交H 的所有提交都在所有三个分支上。
现在,当我们进行新的提交 I 时,Git 更新的 name 将是 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 个提交中共享;它是一个 changed 文件,具有三个副本,三个提交中的每个副本。重用在所有时间都有效:今天进行的新提交,将所有文件设置回去年的方式,重用去年的文件。 (Git 也 进行增量压缩,后来和不可见的方式与大多数 VCS 不同,但即时重用旧文件意味着这并不像看起来那么重要。)
合并有多种形式:现在让我们看看快进合并
运行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 是最好的 共享提交,因为它在某种意义上是最新的。2 仅通过观察图表也很明显。但是:请注意,commit H,合并基础,与我们现在所坐的提交相同的提交。我们在main,它选择提交H。在git merge 中,这是一种特殊情况,Git 将其称为快进合并。3
在快进合并中,不需要实际的合并。在这种情况下,Git 会跳过合并,除非你告诉它不要这样做。相反,Git 只会签出另一个分支名称选择的提交,然后拖动当前分支名称来满足它并保持HEAD 附加,如下所示:
I--J <-- develop, main (HEAD)
/
...--G--H
\
K--L <-- topic
注意没有 新的提交 发生。 Git 只是将名称 main "forward" 移动到了顶行的末尾,这与 Git 通常移动的方向相反(从提交向后退到父级)。这就是 快进 的作用。
您可以强制 Git 对这种特殊情况进行真正的合并,但为了便于说明,我们不会这样做(这对您自己的情况没有任何帮助)。相反,我们现在将继续进行另一个合并,其中 Git 无法 进行快进。我们现在将运行git merge topic。
2最新 这里不是由 dates 定义的,而是由图中的位置定义的:H 是“更接近”@例如,987654403@ 比 G 是。从技术上讲,合并基础是通过解决Lowest Common Ancestor problem as extended for a Directed Acyclic Graph 来定义的,在某些情况下,可以有多个合并基础提交。我们会小心地忽略这个案例,希望它永远不会出现,因为它相当复杂。查找我的其他一些答案,看看 Git 在 确实 出现时会做什么。
3快进实际上是标签运动的一个属性(分支名称或远程跟踪名称),而不是合并,但是当你实现这个时使用 @987654405 @,Git 称之为快进合并。当您使用git fetch 或git push 获得它时,Git 将其称为快进,但通常什么都不说;当 fetch 或 push 无法发生时,在某些情况下会出现 non-fast-forward 错误。不过,我会将这些排除在此答案之外。
真正的合并更难
如果我们现在运行git merge topic,Git 必须再次找到合并基础,即最佳共享提交。请记住,我们现在处于这种情况:
I--J <-- develop, main (HEAD)
/
...--G--H
\
K--L <-- topic
通过J 提交的内容位于我们当前的分支main。通过H 和K-L 向上提交,在topic 上。那么哪个提交是最好的 shared 提交?好吧,从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 已更新,因为它是当前分支。而且,由于通过从 last 提交向后工作可以找到“在”分支上的一组提交,所以现在 所有提交都在 main 上。我们从M 开始,然后我们返回一跳到both 提交J 和L。从这里,我们向后移动一跳到 both 提交 I 和 K。从那里,我们向后移动一跳以提交H:向后移动一跳在分支较早分叉的点解决了这个“多路径”问题。
4--find-renames 部分处理您使用git mv 或等效项的情况。合并自动打开重命名查找; git diff 在 最近 版本的 Git 中默认自动打开它,但在旧版本中,您需要明确的 --find-renames。
5如果您更改的区域刚刚接触(邻接)他们更改的区域,Git 也会声明冲突。在某些情况下,可能存在排序限制;一般来说,从事合并软件工作的人发现这会产生最好的整体结果,并在适当的时候产生冲突。您可能偶尔会在不需要冲突时遇到冲突,或者在存在冲突时不会遇到冲突,但在实践中,这种简单的逐行规则对大多数 编程语言。 (对于像研究论文这样的文本内容,它往往效果不佳,除非您习惯将每个句子或独立从句放在单独的一行中。)
6这意味着如果您必须解决冲突,您实际上是在 Git 的索引/暂存区中执行此操作。您可以使用工作树文件来执行此操作(我通常这样做),或者您可以使用三个输入文件,Git 将它们留在 in 暂存区域以标记冲突。不过,我们不会在这里详细介绍这些内容,因为这只是一个概述。
真正的合并留下痕迹
现在我们有了这个:
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。 啊哈! Commit L 在两个分支上。
commit K 也在两个分支上,commit 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 diffs,并像以前一样组合工作。但是这一次,它不是进行合并提交M,而是进行常规提交,我将其称为S(用于壁球),我们绘制如下:
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 ???
Commit S 包含与K-L 相同的所有更改,因此我们删除了branch2,忘记了如何找到提交K-L。我们从不回头寻找它们,最终——在很长一段时间后——Git 真的会把它们真正扔掉,它们将永远消失,前提是没有其他人提出任何让 Git 找到它们的名称(分支或标签名称)。历史似乎总是这样:
...--G--H--I--J--S--... <-- somebranch
总结
- 快进合并不会留下痕迹(也不会进行任何实际合并)。
- 真正的合并留下痕迹:与两个父母的合并提交。合并操作——合并的动作,或 作为动词的合并——使用 merge base 来确定 merge commit 中的内容(作为形容词合并)。
- Squash 合并不会留下任何痕迹,通常意味着您应该关闭被压扁的分支。
- revert 只是普通的日常提交,因此合并revert 会合并reversion。您可以在合并之前或之后还原还原以撤消它。