你能否删除一个分支上与另一个分支存在“交集”的 Git 提交记录?

15
有时候,我会处于一种简单的情境中,即我正在进行一些更改并创建一个分支。随着我的更改不断进行,我开始发现需要进行清理或其他部分相关的更改,我想开始处理这些更改,因此我希望保持分支的特定性,所以我很快就另起一个分支开始处理这组可能与先前分支不同依赖项的更改。最后,我有了两个分支,试图将更改隔离开来,但是这第二个分支起源于第一个分支,而第一个分支则来自“主”分支。
我可以(也已经)通过将“主”合并到每个分支中来单独更新每个分支,并希望将第二个分支准备好合并到“主”分支中,因为它具有较少的依赖项,比第一个分支创建的变化更小。但是,由于它是从第一个分支中分离出来的,因此该分支还包含在第一个分支中进行的更改。
因此,我想知道,是否有一种方法可以告诉Git类似于:“删除所有存在于此其他分支中的提交”,从而使我仅得到我的第二个分支,而没有第一个分支中所做的所有更改,使我能够将此第二个分支合并到“主”中,并让我回到我创建的第一个分支上工作。
可能我只是没有在Git中找到正确的术语,以查看它如何已经实现。但也可能它无法。尽管如此,它似乎应该是很容易做到的,因为Git擅长仅显示分支1和2之间适当的差异,即使在我单独从“主”更新两个分支之后。
而“删除”分支并不是必要的... 即使想法是创建另一个分支,但仍然排除了第一个分支中进行的更改,那也足够了。
2个回答

25

是的,你可以做到这一点。在某些情况下,这甚至非常容易,因为它只需通过git rebase自动完成。在某些情况下,这非常困难。让我们来看看这些情况。

首先,在Git中,像几乎所有情况一样,绘制提交图形至关重要。为了达到这个目的,让我们从回顾Git基础知识开始。(这是一个好主意,因为很多Git教程会直接跳过基础知识,因为基础知识枯燥而令人困惑。:-)) 首先,让我们看一下提交是什么以及它对你有什么作用。

提交是什么以及它对你有什么作用

在Git中,提交是一件非常具体的事情。我们可以查看任何实际提交——大多数提交都非常小,不能使用git show来查看,因为它们会将其装饰得很漂亮,但可以使用git cat-file -p来查看,该命令显示实际Git对象的即时原始内容(树对象需要进行微调,因此有时是“基本原始”的):

$ git cat-file -p 3bc53220cb2dcf709f7a027a3f526befd021d858
tree 5654dad720d5b0a8177537390575cd6171c5fc50
parent 3e5c63943d35be1804d302c0393affc4916c3dc3
author Junio C Hamano <gitster@pobox.com> 1488233064 -0800
committer Junio C Hamano <gitster@pobox.com> 1488233064 -0800

First batch after 2.12

Signed-off-by: Junio C Hamano <gitster@pobox.com>

这就是一个完整的提交。它的名称——唯一标识该提交,从现在到永远都是如此——是3bc5322...(一个人类如果可以避免的话,绝不想处理的大而丑陋的哈希ID)。它存储了几个更大更丑陋的哈希ID。其中一个是用于,有些数字(通常只有一个)是用于父项。它有一个作者(姓名、电子邮件地址和时间戳)和提交者,通常是相同的;还有一个日志消息,可以随意编写。

与提交相关联的是源代码树快照。它是整个东西,而不是一组更改。(在下面,Git 确实使用压缩技巧,但树的哈希ID会得到文件的哈希ID,而这些文件是完整的文件,而不是某种奇怪的压缩文件。)让Git提取该树,您就可以获取所有文件。

因为每个提交都存储了一个父哈希 ID,所以我们可以从最近的提交开始向后工作。这就是 Git:向后。我们从最近的提交的哈希 ID 开始,Git 会为我们保存在分支名称中。我们说这个分支名称指向该提交:
<--C   <--master
master 指向提交 C。 (我使用单个字母名称而不是复杂的哈希 ID,这限制了提交数量最多为 26 个,但更加方便。)然而,提交 C 中有另一个哈希 ID,因此 C 指向另一个提交。那就是 C 的父提交,也就是 B。当然,B 同样指向另一个提交,但假设我们的存储库仅总共有三个提交,因此 B 指回到 A,但 A 是第一个提交。

由于 A 是第一个提交,所以它不能有父提交。 因此它没有:它不需要再向后指。 我们称 A 为根提交。每个存储库至少有一个(通常只有一个)根提交。1 这就是操作必须停止的地方:我们(或 Git)无法再向后退。

无论如何,一旦提交,就是永久和不可更改的。2这是因为它们的哈希ID是通过计算提交内部所有位(您使用git cat-file -p看到的所有内容)的加密哈希值来生成的。如果您更改任何内容,则会获得新的和不同的哈希ID。每个哈希ID始终是唯一的。3 因此,让我们画出这个过程,但不要关注内部箭头;让我们只保留分支名称本身的箭头。
A--B--C  <-- master

每次提交都会为您保存一个快照,只有当您将它们与它们的反向箭头一起组装时,才会得到“提交图”。

1除了完全空的存储库(显然没有提交),可以获得根提交。这是通过进行没有父提交的提交来完成的。

2但是,一旦您不再需要提交,它们就可以被垃圾回收。Git通常会以隐形方式执行此操作;我们很快将看到这是如何实现的。

3请勿注意幕后的网站! 但是,SHA-1哈希的最近破坏对Git并非立即问题,但它确实有助于推动Git切换到SHA-256。


添加新的提交

现在我们已经看到了具有三个提交的图形,让我们向master添加一个新的提交,以了解其工作原理。首先像往常一样执行git checkout master。这将填充索引工作树。然后我们会进行工作、git add、和git commit操作。

(提醒:工作树是您进行工作的地方。当Git保存文件时,它们会列在难以发音的哈希ID名称下,并将它们压缩,因此仅对Git本身有用。为了使用这些文件,您需要它们处于正常形式,即工作树。同时,索引是您和Git构建下一个提交的地方。您在工作树中处理文件,然后运行git add将它们从工作树复制到索引中。您可以随时git add:这只是再次从工作树更新索引。索引起初与当前提交匹配,然后您修改它,直到准备好进行新提交。)
当您运行git commit时,Git会收集您的日志消息,然后:
  1. 将索引作为新的 tree 写出:这是您保存的快照,基于您从工作树中替换的内容。新的树有自己的哈希 ID。
  2. 写出一个新的 commit 对象,带有此新树 ID、当前提交的 ID 作为其 parent,您作为作者和提交者(现在也是时间戳),以及您的日志信息。

第二步为我们的新提交获取了 Git 新的哈希 ID;让我们称其为 D。由于新的提交中有 C 的哈希 ID,因此 D 指向 C

A--B--C     <-- master (HEAD)
       \
        D

Git 的最后一步是将 D 的 ID 写入当前的 分支名称。如果当前分支是 master,则这将使 master 指向 D
A--B--C
       \
        D   <-- master (HEAD)

如果我们在创建新提交之前,先使用git checkout -b命令创建一个新的分支,那么我们的起始设置如下:
A--B--C     <-- branch (HEAD), master

两个名字,branchmaster,都指向C,但是HEAD表示我们在分支branch上,而不是master上。因此,当我们创建D并且Git更新当前分支时,我们得到以下结果:

A--B--C     <-- master
       \
        D   <-- branch (HEAD)

这是分支增长的方式。一个分支名称只是指向分支的尖端提交;形成图形的是提交本身。
值得停下来思考一下提交A-B-C。它们肯定在master上。但它们也在branch上。在Git中,一个提交可以同时存在于许多分支上。我们经常需要做的是限制Git回溯的范围,告诉它:“从这个分支尖端开始,向后获取所有提交。”
现在是令人兴奋的部分!好吧,可能有点激动人心。:-) 你已经创建了几个带有一堆新提交的分支,让我们画出来:
...--E--F--G                <-- master
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   <-- feature2

在这里,masterG 结束,也就是说,提交 G 是 master 的顶端。feature1L 结束,feature2Q 结束。提交 E-F-G 在三个分支上都有。提交 P-Q 只在 feature2 上。提交 I-J-Kfeature1feature2 上都有。提交 L 只在 feature1 上。

再次提醒,这些字母代表着大而丑陋的哈希 ID,实际的哈希 ID 编码了提交中的所有内容:保存的树和父级 ID。因此,例如,L 需要 K 的哈希 ID。这种事情很重要,因为我们打算复制一些提交。

你所描述的想法是以某种方式移植提交记录 PQ,使它们位于 master 之上。如果有一种复制提交记录的方法会怎样呢?事实证明确实有这样一种方法:它被称为 git cherry-pick
樱桃拣选
请记住我们之前提到过的一个提交记录就是快照。它不是一组更改。但现在我们希望提交记录是一组更改,因为提交记录 P 很像其父提交记录 K,但进行了一些更改。毕竟,您是通过将 K 检出,然后编辑文件并将新版本添加到索引中,最后进行 git commit 来创建 P 的。

幸运的是,有一种简单的方法可以将快照转换为变更集,即通过将其与其父提交进行比较(git diff)。 git diff的输出是一组最小指令:“从此文件中删除此行,在该文件中添加这些其他行等”。将这些指令应用于K中的树将使其变成P中的树。

但是,如果我们将这些指令应用于其他树会发生什么?事实证明,这通常“只是起作用”。 我们可以git checkout提交G——分支master的末端提交,但让我们使用不同的分支名称:

...--E--F--G                <-- master, temp (HEAD)
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   <-- feature2

然后将差异应用于工作树。我们假设这一步顺利,自动将结果git add到索引中,并在复制提交P的日志消息时git commit。我们将称新提交为P',意思是“像P,但具有不同的哈希ID”(因为它具有不同的树和不同的父级):

             P'             <-- temp (HEAD)
            /
...--E--F--G                <-- master
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   <-- feature2

现在我们用Q重复这个过程。我们运行git diff P QQ转换为更改,将这些更改应用于P',并将结果作为新的Q'提交:
             P'-Q'          <-- temp (HEAD)
            /
...--E--F--G                <-- master
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   <-- feature2

这只是两个“git cherry-pick”步骤,当然还需要创建临时分支。但是现在如果我们删除旧名称“feature2”,并将“temp”更改为“feature2”,会发生什么呢?
             P'-Q'          <-- feature2 (HEAD)
            /
...--E--F--G                <-- master
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   [abandoned]

现在看起来我们是通过执行git checkout -b feature2 master,然后从头编写P'Q'来创建了feature2!这正是你想要的。

4简单,也就是在任何数量的硕士和/或博士论文中研究字符串编辑问题后。

5在某种程度上是“最小的”,并且可以通过不同的差异算法进行微调。 最小化编辑距离对于压缩非常重要,但实际上并不重要正确性。 但是,当我们将编辑说明应用于其他树时,最小化和确切的说明真的开始变得重要。


Git的rebase是自动化的cherry-pick加上分支标签移动

我们可以使用以下命令一次性完成上述操作:

git checkout feature2
git rebase --onto master feature1

我们在这里使用 feature1 作为告诉 Git 停止复制的方式。回顾一下在原始提交被弃用之前的图表。如果我们告诉 Git 从 feature1 开始向后工作,那么它就会识别出提交 LKJIGF 等等。这些是我们明确地说不要复制的提交:也就是位于分支 feature1 上的提交。
与此同时,我们想要复制的提交是那些位于 feature2 上的提交: QPKJ 等等。但是,一旦我们遇到任何禁止的提交,我们就会停止复制,因此我们只会复制 P-Q 提交。
我们告诉 git rebase 要复制到的位置是或者说“紧接着” master 的末端,也就是把提交复制到 G 后面。
我会尽力为您翻译。这段内容是关于Git rebase的,它能够帮助我们完成所有工作,非常容易。但是可能会出现一些问题。解决这些问题可以采取以下措施。假设我们从以下内容开始:
...--E--F--G                <-- master (HEAD)
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   <-- feature2

我们希望将feature2基于master进行变基,跳过大部分的feature1,但事实证明我们也需要J提交中所做的更改。
我们不需要IKL,只需要J(当然还有PQ)。
我们无法仅使用git rebase来完成此操作。我们可能需要显式使用git cherry-pick复制J。但这是Git,所以有很多方法可以做到这一点。
首先,让我们看一下显式cherry-pick方法。我们将继续创建一个新分支并cherry-pickJ
git checkout -b temp
git cherry-pick <hash-ID-of-J>

Now we have:

             J'             <-- temp (HEAD)
            /
...--E--F--G                <-- master
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   <-- feature2

现在我们可以像以前一样移植 P-Q。我们只需要更改 --onto 指令即可。
git checkout feature2
git rebase --onto temp feature1

结果是:

这里是结果。

               P'-Q'        <-- feature2 (HEAD)
              /
             J'             <-- temp
            /
...--E--F--G                <-- master
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   [abandoned]

我们已经不再需要 temp 了,所以我们可以直接使用 git branch -d temp 来整理我们的绘图:

             J'-P'-Q'       <-- feature2 (HEAD)
            /
...--E--F--G                <-- master
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   [abandoned]

另一种获得相同结果的方法

假设我们不仅复制P-Q,而是让git rebase复制I-J-K-P-Q。这可能更容易实现:

git checkout feature2
git rebase master

这次我们不需要使用--ontomaster 告诉 Git 要排除哪些提交以及将副本放在哪里。 我们排除提交 G 及之前的提交,并在其后进行复制。 结果如下:
             I'-J'-K'-P'-Q'  <-- feature2
            /
...--E--F--G                 <-- master
            \
             I--J--K--L      <-- feature1
                    \
                     P--Q    [abandoned]

现在我们有太多的复制提交,但现在我们运行:
git rebase -i master

这给我们每个提交的“pick”行,包括I'J'K'P'Q'。我们删除I'K'的那些行。现在Git再次复制,得到:
             J''-P''-Q''    <-- feature2
            /
...--E--F--G                <-- master
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   [abandoned]

这正是我们想要的(原始副本仍然在那里,像原始的-P-Q一样被抛弃,但它们在那里的时间很短暂,谁会在意呢?:-))。当然,我们可以让第一个git rebase使用-i并删除pick行,只需一步就可以获得J'-P'-Q'的副本。

消除冗余提交

就目前而言,这还不错,但现在有一个J和一个J'。实际上,这并没有什么问题——你可以保持这种情况,并像这样合并,没有真正的伤害。但你可能希望先将J'作为master的一部分,然后再分享它。

同样,有多种方法可以做到这一点。我想举一个特定的例子,因为git rebase中有一些魔法。

假设我们已经完成了feature2的变基操作,现在我们将完全删除废弃的提交,就像Git最终进行垃圾回收时一样(注意:默认情况下,您至少有30天的时间可以更改想法)。
             J'-P'-Q'     <-- feature2
            /
...--E--F--G              <-- master
            \
             I--J--K--L   <-- feature1

现在您可以将 master 快进以包括 J'
git checkout master
git merge --ff-only <hash-id-of-J'>

这将移动 标签,而不改变 提交记录图形。为了更容易地在ASCII文本中绘制,我将把J'向下移动一行:
                P'-Q'     <-- feature2
               /
...--E--F--G--J'          <-- master
            \
             I--J--K--L   <-- feature1

我们也可以通过显式地将 J cherry-pick 到 master,然后在没有任何花哨的操作下重新设置 feature2 来到达这里。所以现在我们想要复制 feature1 的提交,在 J' 之后添加它们,并删除 J

我们可以使用另一个 git rebase -i 完成此操作,该命令允许我们显式删除原始提交 J。但我们不一定需要这样做。大多数情况下,我们不需要这样做。相反,我们只需运行:

git checkout feature1
git rebase master

这告诉 Git 应该将 I-J-K-L 视为复制的候选项,并将复制放在 J' 之后(此时 master 指向它)。但是,这里有个神奇的地方——git rebase 会仔细查看所有不在 feature1 上但在 master 上的提交记录(至少在一些文档中称为“上游”提交记录)。在这种情况下,就是 J' 本身。对于每个这样的提交记录,Git 将提交记录与其父提交记录进行差异比较(类似于 git cherry-pick),并将结果转换为补丁 ID。对于 每个候选提交记录,也执行相同的操作。如果其中一个候选项(J)与其中一个上游提交记录具有相同的补丁 ID,则 Git 会从列表中消除该候选项!因此,只要 JJ' 具有相同的补丁 ID,Git 就会自动删除 J,以便最终结果为:
                P'-Q'     <-- feature2
               /
...--E--F--G--J'          <-- master
               \
                I'-K'-L'  <-- feature1

这正是我们想要的。
除了合并之外,rebase可以复制所有内容。但是合并是不可能被复制的,因为新合并有一个不同的父级设置,而cherry picking“撤销”了原始合并。因此,默认情况下,它完全跳过它们。合并不会分配补丁ID,并且不会从集合中取出,因为它们从未在集合中。重新定位包含合并的图形片段通常是不明智的。
Git确实有一种模式来尝试做到这一点。该模式重新执行合并(因为必须这样做:我留给你练习细节)。但是这里存在许多危险,因此最好根本不要这样做。我以前说过,可能默认情况下git rebase应该保留合并,但通过出现错误如果有合并,则需要一个“是,继续尝试重新创建合并”的标志,或者一个“扁平化和删除合并”的标志来进行。
然而,它没有,所以由您来绘制图形并确保您的rebase是有意义的。

当rebase出现问题:合并冲突

每当你使用git rebase命令来重组一些提交记录时,都有可能会遇到合并冲突。特别是当你从一个长链中拿出一段提交记录时,这种情况就更加普遍了:

        o--...--B--1--2--3--4--...--o   <-- topic
       /
...o--*--o--o--o--T                     <-- develop

如果我们想要将提交1-4“移动”(复制,然后删除)到develop中,很有可能其中一些或全部这四个提交依赖于其他在它们之前的顶行提交(B及更早的提交)。当发生这种情况时,我们往往会遇到合并冲突,有时还会有很多。Git最终会将提交1的副本视为三方合并操作,将从B1的更改与从BT的更改合并。从BT的变化可能非常复杂,并且在上下文之外可能看起来不合理,因为我们必须“向后”穿越提交B之前的提交直到*,然后“向前”直到T
你需要自己决定如何以及是否明智地进行此操作。
当rebase出现问题:其他人仍在使用原始提交
由于rebase本质上是一种复制操作,因此您必须考虑谁可能仍然拥有原始提交记录。由于提交记录可以在许多分支上,您可能拥有原始提交记录,就像我们同时拥有J和J'时所看到的那样。
有时候,甚至经常这可能不是一个大问题。但有时候会是。如果所有额外的副本在您自己的存储库中,则可以自行解决所有这些问题。但是,如果您已经发布(推送或让其他人从您那里获取)了其中一些提交记录,则会发生什么情况?特别是,如果其他存储库具有这些原始提交记录及其原始哈希ID,会怎样?如果您已经发布了原始提交记录,则必须告诉所有拥有这些提交记录的其他人:“嘿,我正在放弃原始提交记录,在其他地方拥有全新的副本。” 您必须让他们做同样的事情,否则就要忍受额外的提交副本。

额外的提交有时是无害的。这在合并方面尤其如此,因为git merge会努力仅获取给定更改的一个副本(尽管Git不能总是自己完全正确地做到这一点,因为每个更改-每个git diff输出-都取决于上下文和其他更改,最小编辑距离算法有时会出错,选择错误的"最小更改")。即使它们不破坏,它们也会使提交历史混乱。何时可能成为问题很难预测。

摘要

对于您的目标,git rebase是一个强大的工具。在使用它时需要注意一些细节,最重要的是要记住,它复制提交,然后放弃-或试图放弃-原始提交。这可能会以几种方式出错,但最糟糕的情况通常发生在其他人已经拥有您的原始提交的副本时,这通常意味着“当您已经发布(推送)它们”。

绘制图表可以帮助理解。每个人都应该养成绘制图表的习惯,或者使用git log --graph(从“a dog”获取帮助:git log --all --decorate --oneline --graphAll Decorate Oneline Graph),或者像gitk这样的图形化浏览器(尽管我个人总体上不喜欢GUI :-))。不幸的是,“真正的”图表很快变得非常杂乱。 Git内置的log --graph在分离混乱图表方面做得很差。有很多临时工具来处理这个问题,其中一些内置于Git中,但是有很多练习阅读图表的经验肯定会有所帮助。


谢谢您的讲座。 :) 我还没有回复,因为我还没有完全消化这个内容。 :) 但是一旦我理解了,我会回复的。 - user2415376
4
有时你会看到一个像书一样的答案,教给了你很多东西。但是...你发现它却得到了很少的赞赏。所以在这里再点赞一次!写得好,谢谢! - Luceos

6
如果您的历史记录如下:
...--E--F--G                <-- master
            \
             I--J--K--L     <-- feature1
                    \
                     P--Q   <-- feature2

在简单的情况下,如果要删除feature1,则执行以下操作:
git checkout feature2
git rebase --onto master feature1

这是@torek的答案的缩写,答案非常优秀,但很难找到问题的实际答案。阅读@torek的答案以获取更多详细信息,并了解在非简单情况下要做什么。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接