rub*_*nvb 1 git git-merge git-revert
我们使用具有最新开发的主分支进行开发,并且发布分支经常从该主分支中分离出来并构成一个版本。错误在这些发布分支上得到修复,并且这些错误修复被合并回主分支。我们所有的更改都会通过 PR,您不能手动(强制)推送这些重要分支中的任何一个。
现在,人为错误导致主分支被合并到发布分支(通过 PR)。这是通过包含错误合并提交的恢复提交的 PR 来恢复的。因此,发布分支是“良好的”(除了这两个额外的提交)。随后,这个发布分支被合并到主分支中。接下来发生的事情是出乎意料的:从主分支到发布版本的错误合并以某种方式被忽略(这是合乎逻辑的),但是撤销错误的后续恢复提交被完全合并,有效地删除了自发布分支以来主分支上的所有更改被分裂了。
不幸的是,我不知道这是如何发生的细节,但这可以以某种方式解释为“预期”行为。我计划编写一个 git 命令的小脚本,尽快重复这种序列,并将在这里更新问题。
我的问题是:有没有一种方法(无需强制推送和消除错误提交)能够将发布分支合并到主分支中,而不会使恢复提交对主分支的文件产生影响?现在看来这总是会导致恢复提交改变不应该改变的东西。
是的,这是正常的。TL;DR:您可能想恢复恢复。但您询问的是有关机制的更多信息,而不是快速解决方案,因此:
\n理解Git的合并的方法是理解:
\ngit merge,即两个分支上的最佳共享提交;和快照部分非常简单:每次提交都保存每个文件的完整副本,截至您(或任何人)进行该提交时的状态。1 有一个怪癖,那就是 Git 从其索引(又名临时区域)中的任何内容(而不是某个工作树中的内容)进行提交,但这主要解释了为什么您必须运行git add这么多。
第 2 点和第 3 点相互关联:提交是历史记录,因为每个提交都存储一些早期提交的原始哈希 ID。这些向后指向的链接让 Git 可以随着时间向后移动:从提交到父级,然后从父级到祖父级,等等。类似或 的分支名称只是标识我们想要声明为该分支“上”的最后一次提交。mainmaster
这意味着你需要同时理解第2点和第3点。最初,这并不太难,因为我们可以像这样绘制提交:
\n... <-F <-G <-H\nRun Code Online (Sandbox Code Playgroud)\n这里H代表最后(最新)提交的哈希 ID。我们可以看到H“指向”之前的提交G(提交H实际上包含提交的原始哈希 ID G)。因此G是H\ 的父级。同时 commitG包含更早提交的原始哈希 ID F:F是G其父级,这使其成为H祖父级。
对于该图,我们只需在末尾添加一个分支名称,例如,main指向H:
...--F--G--H <-- main\nRun Code Online (Sandbox Code Playgroud)\n当我们向分支添加新提交时,Git:
\nH(当前提交)等等;I;和\xe2\x80\x94这是棘手的一点\xe2\x80\x94然后I\的哈希ID写入name main。最后一步更新分支,这样我们就有:
\n...--F--G--H--I <-- main\nRun Code Online (Sandbox Code Playgroud)\n现在的名字main选择I,不是H;我们用它I来查找H,我们用它来查找G,我们用它来查找F,等等。
Git 知道更新名称,main因为(或者更确切地说,如果)这是我们进行新提交时“所在”的分支I。如果我们有多个分支名称,它们可能都指向同一个提交:
...--G--H <-- develop, main, topic\nRun Code Online (Sandbox Code Playgroud)\n这里所有三个分支名称都选择 commit H。这意味着就我们检查的内容而言,我们git checkout或去哪一个并不重要:在任何情况下我们都会检查提交。但是如果我们选择这里使用的名称,这也会告诉 Git 这也是当前的名称:git switchHdevelopdevelop
...--G--H <-- develop (HEAD), main, topic\nRun Code Online (Sandbox Code Playgroud)\n请注意,所有提交(包括提交)都H位于所有三个分支上。
现在,当我们进行新的提交时I, Git 更新的名称将是:即附加develop特殊名称的名称。HEAD所以一旦我们做出来,I我们就有了:
I <-- develop (HEAD)\n /\n...--G--H <-- main, topic\nRun Code Online (Sandbox Code Playgroud)\n如果我们再进行一次提交,我们会得到:
\n I--J <-- develop (HEAD)\n /\n...--G--H <-- main, topic\nRun Code Online (Sandbox Code Playgroud)\n向上的提交仍然H在所有三个分支上。提交并且至少目前\xe2\x80\x94仅在.IJdevelop
如果我们现在git switch topic或git checkout topic,我们将返回提交H,同时将特殊名称附加到新选择的分支名称:
I--J <-- develop\n /\n...--G--H <-- main, topic (HEAD)\nRun Code Online (Sandbox Code Playgroud)\ntopic如果我们现在再进行两次提交,那么这次移动的就是名称:
I--J <-- develop\n /\n...--G--H <-- main\n \\\n K--L <-- topic (HEAD)\nRun Code Online (Sandbox Code Playgroud)\n从这里开始,事情变得有点复杂和混乱,但我们现在准备研究合并基础的概念。
\n1这些完整副本经过重复数据删除,因此,如果连续 3 次提交每次重复使用数百个文件,并且只有一个文件在新提交中一遍又一遍地更改,则数百个文件中的每一个都只有一个副本文件数量,在所有 3 次提交之间共享;这是改变的那个文件,具有三个副本,三个提交中每个副本都有一个。重用始终有效:今天进行的新提交,将所有文件设置回去年的状态,重新使用去年的文件。(Git还进行了增量压缩,但它是在稍后以不可见的方式进行的,其方式与大多数 VCS 不同,但旧文件的即时重用意味着这并不像看起来那么重要。)
\n运行git merge总是会影响当前分支,因此第一步通常是选择正确的分支。(如果我们已经在正确的分支上,我们只能跳过这一步。)假设我们想要签出main并合并develop,所以我们运行git checkout main或git switch main:
I--J <-- develop\n /\n...--G--H <-- main (HEAD)\n \\\n K--L <-- topic\nRun Code Online (Sandbox Code Playgroud)\n接下来,我们将运行git merge develop. Git 将找到合并基础:两个分支上的最佳提交。正在进行的提交main是直到 \xe2\x80\x94 为止的所有提交(包括以 \xe2\x80\x94commit 结尾)H。那些打开的develop都是J沿着中线和顶线向上提交的。Git 实际上是通过向后而不是向前工作来找到这些的,但重要的是它发现向上提交H是共享的。
提交H是最好的共享提交,因为从某种意义上说,它是最新的。2 通过观察图表,这一点也非常明显。但是:请注意,H合并基础commit与我们现在正在进行的提交是相同的提交。我们正在 on main,它选择 commit H。在 中git merge,这是一种特殊情况,Git 将其称为快进合并。3
在快进合并中,不需要实际合并。在这种情况下,Git 将跳过 merge,除非你告诉它不要这样做。相反,Git 将仅检查由其他分支名称选择的提交,然后拖动当前分支名称以满足该提交并保持HEAD附加,如下所示:
I--J <-- develop, main (HEAD)\n /\n...--G--H\n \\\n K--L <-- topic\nRun Code Online (Sandbox Code Playgroud)\n请注意如何没有发生新的提交。Git 只是将名称main“向前”移动(到顶行的末尾),与 Git 通常移动的方向(从提交向后到父级)相反。这就是快进的作用。
您可以强制 Git 对这种特殊情况进行真正的合并,但出于说明目的,我们不会这样做(它对您自己的情况没有任何帮助)。相反,我们现在将继续进行另一次合并,而 Git无法进行快进。我们现在要运行git merge topic。
2这里的最新不是由日期定义的,而是由图表中的位置定义的:例如,比现在H“更接近” 。从技术上讲,合并基础是通过解决有向无环图扩展的最低公共祖先问题来定义的,并且在某些情况下,可以有多个合并基础提交。我们会小心地忽略这个案例,希望它永远不会出现,因为它相当复杂。查找我的其他一些答案,看看 Git 出现时会做什么。JG
3快进实际上是标签运动(分支名称或远程跟踪名称)的属性,而不是合并,但是当您使用 实现此目的时, git merge Git 将其称为快进合并。当你用git fetch或得到它时git push,Git 将其称为快进,但通常什么也不说;当获取或推送无法发生这种情况时,在某些情况下您会收到非快进错误。不过,我将把这些排除在这个答案之外。
如果我们现在运行git merge topic,Git 必须再次找到合并基础,即最佳共享提交。请记住,我们现在处于这种情况:
I--J <-- develop, main (HEAD)\n /\n...--G--H\n \\\n K--L <-- topic\nRun Code Online (Sandbox Code Playgroud)\n向上的提交J是在main我们当前的分支上。向上提交H,加上K-L,正在进行topic。那么哪个提交是最好的共享提交呢?好吧,从 向后工作J:从 开始J,然后点击 commit I,然后H,然后G,依此类推。L现在从到K向后工作H:提交H是共享的,并且它是“最右边”/最新可能的共享提交,因为它G出现在 之前 H。所以合并基础再次 commit 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\nRun Code Online (Sandbox Code Playgroud)\n弄清楚自共同起点以来您更改了什么之后,Git 现在需要弄清楚它们更改了什么,这当然只是另一个git diff:
git diff --find-renames <hash-of-H> <hash-of-L> # what they changed\nRun Code Online (Sandbox Code Playgroud)\n现在的工作git merge是将这两组变化结合起来。如果您更改了文件的第 17 行README,Git 会将您的更新更新到README. 如果他们在 的第 40 行之后添加了一行main.py,Git 会将其添加到main.py.
Git 获取这些更改中的每一个\xe2\x80\x94 你的和他们的\xe2\x80\x94 并将这些更改应用到H合并基础 commit 中的快照。这样,Git 会保留您的工作并添加他们的\xe2\x80\x94,或者,通过相同的论点,Git 保留他们的工作并添加您的工作。
请注意,如果您在commit之后在某处进行了恢复H,而他们没有,那么您的恢复是自合并基础以来的更改,并且自合并基础以来他们没有更改任何内容。所以 Git 也会进行恢复。
在某些情况下,您和他们可能更改了同一文件的相同行,但方式不同。换句话说,您的更改可能会发生冲突。5 对于这些情况,Git 会声明合并冲突,并给您留下必须自行清理的混乱局面。但在数量惊人的情况下,Git 的合并可以自行发挥作用。
\n如果 Git能够自己成功地合并所有内容\xe2\x80\x94,或者即使不能,但只要它认为它做到了\xe2\x80\x94,Git 通常会继续进行自己的新提交。这个新的提交在一个方面是特别的,但让我们先画一下它:
\n I--J <-- develop\n / \\\n...--G--H M <-- main (HEAD)\n \\ /\n K--L <-- topic\nRun Code Online (Sandbox Code Playgroud)\n请注意名称如何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 也会声明冲突。在某些情况下,可能存在订购限制;一般来说,从事合并软件工作的人们发现这给出了最好的整体结果,在适当的时候产生冲突。当实际上不需要某个规则时,您可能偶尔会遇到冲突,或者当存在冲突时不会出现冲突,但实际上,这种简单的逐行规则对于大多数编程语言都适用。(对于像研究论文这样的文本内容,它的效果往往不太好,除非你养成了将每个句子或独立子句放在自己的行上的习惯。)
\n6这意味着如果您必须解决冲突,您实际上是在 Git 的索引/暂存区域中执行此操作。您可以使用工作树文件来执行此操作\xe2\x80\x94,这就是我通常所做的\xe2\x80\x94,或者您可以使用三个输入文件,Git在暂存区域中留下这些文件来标记冲突。不过,我们不会在这里详细讨论其中的任何细节,因为这只是一个概述。
\n现在我们有了这个:
\n I--J <-- develop\n / \\\n...--G--H M <-- main (HEAD)\n \\ /\n K--L <-- topic\nRun Code Online (Sandbox Code Playgroud)\n我们可以git checkout topic或者git switch topic并且做更多的工作:
I--J <-- develop\n / \\\n...--G--H M <-- main\n \\ /\n K--L <-- topic (HEAD)\nRun Code Online (Sandbox Code Playgroud)\n变成:
\n I--J <-- develop\n / \\\n...--G--H M <-- main\n \\ /\n K--L---N--O <-- topic (HEAD)\nRun Code Online (Sandbox Code Playgroud)\n例如。如果我们现在git checkout main或git switch main,然后再次运行,合并基础git merge topic提交是什么?
让我们找出答案:从M,我们回到J和L。从O,我们回到N,然后到L。 啊哈! 提交L是在两个分支上。
CommitK也在两个分支上,commit 也是如此H;但提交I-J不是,因为我们必须遵循提交中的“向后箭头”,并且没有从L到 的链接M,只有从M向后到 的链接L。因此,从L可以到达K,然后H,但我们无法到达M那条路,并且没有通往J或 的路径I。CommitK显然不如L、 、H等K,所以 commitL是最好的共享提交。
这意味着我们的下一个git merge topic运行它的两个差异:
git diff --find-renames <hash-of-L> <hash-of-M> # what we changed\ngit diff --find-renames <hash-of-L> <hash-of-O> # what they changed\nRun Code Online (Sandbox Code Playgroud)\n“我们改变了什么”部分基本上是重新发现我们从中引入了什么I-J,而“他们改变了什么”部分从字面上弄清楚了他们改变了什么。Git 组合这两组更改,将组合的更改应用到快照L,并创建一个新快照:
I--J <-- develop\n / \\\n...--G--H M------P <-- main (HEAD)\n \\ / /\n K--L---N--O <-- topic\nRun Code Online (Sandbox Code Playgroud)\n请注意,这次快进是不可能的,因为main已确定提交M(合并),而不是提交L(合并基础)。
如果我们稍后进行更多开发topic并再次合并,未来的合并基础现在将是 commit O。L除了传播to的差异(现在保留为toM的差异)之外,我们不必重复旧的合并工作。OP
我们不会触及git rebase\xe2\x80\x94,因为它是重复的择优挑选,是一种合并形式(每个择优挑选本身就是一个合并)\xe2\x80\x94,但让我们简要地看一下git merge --squash。让我们从这个开始:
I--J <-- branch1 (HEAD)\n /\n...--G--H\n \\\n K--L <-- branch2\nRun Code Online (Sandbox Code Playgroud)\n这样很明显合并基础是 commitH并且我们正在 commit J。我们现在跑git merge --squash branch2。它像以前一样定位L,git diff像以前一样执行两次操作,并像以前一样组合工作。但这一次,M它没有进行合并提交,而是进行了常规提交,我将其称为S(用于挤压),我们绘制如下:
I--J--S <-- branch1 (HEAD)\n /\n...--G--H\n \\\n K--L <-- branch2\nRun Code Online (Sandbox Code Playgroud)\n请注意如何根本S 不连接回提交。LGit 从来不记得我们是如何得到的S。 S只有一个快照,该快照是由进行合并提交的同一进程创建的M。
如果我们现在添加更多提交branch2:
I--J--S <-- branch1\n /\n...--G--H\n \\\n K--L-----N--O <-- branch2 (HEAD)\nRun Code Online (Sandbox Code Playgroud)\n并再次运行git checkout branch1或,合并基础将再次提交。当 Git 比较vs时,它会看到我们在 中进行了所有相同的更改,加上我们在 中所做的任何更改;当 Git 比较vs时,它会看到它们在整个序列中进行了所有更改;Git 现在必须将我们的更改(包含之前的一些更改)与他们的所有更改(同样包含之前的一些更改)结合起来。git switch branch1git merge branch2H HSLI-JHOK-L-N-O
这确实有效,但合并冲突的风险会增加。如果我们继续使用git merge --squash,在大多数情况下,合并冲突的风险会大大增加。一般来说,在这样的挤压之后唯一要做的就是完全放下 branch2:
I--J--S <-- branch1 (HEAD)\n /\n...--G--H\n \\\n K--L ???\nRun Code Online (Sandbox Code Playgroud)\n提交S保留了所有相同的更改,因此K-L我们放弃branch2,忘记了如何查找提交K-L。我们永远不会回头寻找它们,最终 \xe2\x80\x94 经过很长一段时间 \xe2\x80\x94Git 真的会把它们扔掉,它们将永远消失,只要没有其他人命名(分支或标签名称)让 Git 找到它们。历史似乎总是这样:
...--G--H--I--J--S--... <-- somebranch\nRun Code Online (Sandbox Code Playgroud)\n