首页 > 解决方案 > 是否可以只合并主存储库中的一个子存储库(子树)?

问题描述

假设有一个包含子树存储库的 MainRepo:subRepoA、subRepoB、SubRepoC。如果我在所有存储库中进行了更改,但只想合并和推送在 subRepoB 中完成的更改。可能吗?似乎 MainRepo 的行为就像一个大存储库,无法区分其子存储库。

标签: gitgit-subtree

解决方案


这里的答案既不是也不是。也就是说,您可以实现您的要求,但是:

  • 不是一个简单的git merge命令(它将需要额外的命令);和
  • 这通常是个坏主意。当心!你以后可能会后悔。但是,如果您通读以下所有内容,并考虑合并的工作原理,您就可以做到,并且可以在必要时弄清楚以后如何更新。

但是,要做到这一点,请使用:

git merge --no-commit

然后使用git checkoutor (从 Git 2.23 开始)git restore“撤消”一些合并。git merge --continue然后用or完成合并git commit。有关详细信息,请参阅下面的详细信息。

背景

要了解这一切是如何工作的(以及为什么这是一个坏主意),请记住关于 Git 的这一点:Git 是关于提交的。Git 与文件无关,甚至与分支无关。确实,提交包含文件——这就是我们有提交、保存文件的原因——而分支名称找到提交,这就是我们有分支名称的原因。但归根结底,Git 都是关于提交的。

  • 提交已编号。这些不是简单的计数:我们不是从提交 #1 开始,然后是 #2、#3 等等。取而代之的是,每个人都有一个看起来随机(但实际上根本不是随机)的唯一哈希 ID,它显示为一大串丑陋的字母和数字,通常缩写,因为人类通常只会在它们上面闪过(dca3c76df9bb99b0...是同dca3c76dfb9b99b0...?)。

  • 一旦提交,任何提交的任何部分都不能更改。这样做的原因是哈希 ID 实际上是提交的每一位的加密校验和。如果您确实取出一个,进行一些更改,然后将其放回原处,您将得到一个具有新的不同哈希 ID 的提交。具有唯一编号的旧提交仍然存在,任何查找该编号的人都会获得旧提交。

  • 每个提交存储两件事:

    • Git 知道的每个文件都有完整的快照。这些文件以特殊的、只读的、仅限 Git 的、压缩的和去重复的格式存储。(重复数据删除会立即处理大多数提交中的大多数文件与之前提交中相同文件的版本完全相同的事实。)

    • 同时,每个提交都存储一些元数据,即有关提交本身的信息。这包括制作人(姓名和电子邮件地址)以及制作时间,以及他们解释制作原因的日志消息。在这个元数据中,Git 存储了 Git 本身需要的东西:在我们这里查看的提交之前的提交的提交编号。Git 将此称为父提交


    每个提交都存储其父级的编号(哈希 ID)这一事实意味着,如果我们可以在一串提交中找到最后一个提交,Git 可以使用它来向后工作。也就是说,假设我们使用单个大写字母来代表实际的哈希 ID,并绘制以下内容:

     ... <-F <-G <-H
    

    使用 hash ID H,Git 可以检索您所做的实际提交(包括快照),无论何时进行。这样就可以得到文件。它还获取 Git 元数据,包括较早提交的哈希 ID G。这意味着 Git 可以提取两个提交并将文件与 中的文件进行比较G,以向您显示H您在. Git 还可以打印出创建快照的人的姓名和电子邮件地址,并使用其元数据来查找提交。将 中的快照与 中的快照进行比较,Git 可以显示中的更改,Git 可以返回甚至更早的提交,等等。HGGFFGGF

    当然,我们必须以某种方式找到 commit 的哈希 ID H

  • 分支名称类似于master或仅包含一develop(1) 个哈希 ID。但只要我们——或 Git——确保这是链中最后一次提交的哈希 ID,我们就没事了:

     ...--F--G--H   <-- master
    

    进行提交需要 Git 将新提交的哈希 ID 存储到分支名称中:

     ...--F--G--H--I   <-- master
    

    一旦我们进行提交I(当我们使用master它作为名称时),Git 将自动更新master以指向最后一次提交。新提交的父级I将是现有提交H

    由于每个提交中指向其父级的“箭头”是提交的一部分,因此它们无法更改。就像提交中的所有内容一样,这些都是纯只读的。请注意,从分支名称 出来的箭头确实发生了变化。所以这就是为什么我一直画那个箭头,同时把提交到提交的箭头变成更简单的线:我们只需要记住提交指向backs,而 Git 是向后工作

  • 一次提交可以在多个分支上。例如:

     ...--F--G--H   <-- master, develop
    

    在这里,两个名称都将提交标识H为他们的最后一次提交。所以所有的提交都在两个分支上。

    这方面的技术术语是可达性。我们将在下面轻轻地使用它,在合并中,但考虑从提交开始H并向后工作,一次提交一个。无需移动,我们就达到了commit H。我们后退一步,我们已经达到了 commit G。后退两步,我们就进入了 commit F,以此类推。

  • 请注意,Git 可以比较任何两个提交,而不仅仅是父子对。我们将较早的提交放在左侧(好吧,通常无论如何),稍后的提交放在右侧。Git 然后比较两个提交的快照。对于相同的文件,Git 什么也没说。对于不同的文件,Git 计算出我们可以做的一些更改:在第 42 行之后添加这些行,并删除第 86 行 这是一个差异:它显示了如何将左侧文件更改为右侧文件。

    如果我们比较父母和孩子,这个差异列表通常就是我们所做的。但请注意,Git 只会找到组更改。在某些情况下,这并不是我们改变它的方式。Git 找到的差异会起作用,即使我们做的事情有点不同——但有时(见下面的合并),这可能会导致轻微但令人讨厌的合并冲突,如果 Git 在这里做得更好,就不会发生这种情况。

  • 当我们使用git push(或git fetch因此也使用git pull)时,Git 与commits一起使用。推送操作发送整个提交。这包括快照和元数据。两个 Git 通过比较这些哈希 ID 就知道彼此有哪些提交:这就是为什么哈希 ID 是提交的加密校验和的原因。每个 Git 要么有提交,要么没有。无论哪个 Git 发送提交都会向接收 Git 提供哈希 ID,它要么说“是的,我需要那个,发送它”或“不,谢谢,我已经有了那个”。

git merge将合并提交并进行合并提交

git merge命令本身合并提交。我们喜欢将它与分支名称一起使用。也就是说,我们从这样的事情开始:

          I--J   <-- branch1 (HEAD)
         /
...--G--H
         \
          K--L   <-- branch2

因为我们在这个图中有两个名字,所以我们需要记住我们使用的是哪个名字。这就是特殊名称的HEAD来源:我们将它附加到我们告诉 Git 使用的任何分支git checkout或(从 Git 2.23 开始)git switch。这是我们进行新提交时将更新的名称。

所以,现在我们运行git merge branch2. Git 使用名称 branch2来查找一个特定的提交:名称指向的那个。在这种情况下,这就是 commit L。所以两个有趣的提交是 commit J,我们现在正在使用的一个,以及 commit L,我们在命令行中命名的一个。

然而,合并操作实际上需要三个提交。第三个——或者在某种程度上,第一个——是其他两个提交中最好的共同祖先。你可以把它想象成 Git 查看我们已经命名的两个提交——<code>J 和L这里——并向后工作。我们将从两个提交中尽可能地向后移动,直到我们找到可以两个提交中找到的某个提交。

在这种情况下,最好的共享提交是显而易见的:它是 commit H。提交H在两个分支上。提交G也是如此,但它更靠后,所以H最好的。

为了真正完成合并,Git 现在将合并基础——提交H——与我们当前的提交进行比较J,以查看我们更改了什么:

git diff --find-renames <hash-of-H> <hash-of-J>   # what we changed

然后 Git 会将相同的合并基础与我们命名的另一个提交进行比较:

git diff --find-renames <hash-of-H> <hash-of-L>   # what they changed

的核心git merge——我喜欢称之为动词形式,或合并——现在是结合这两个差异的过程。Git 找到了共同的起点,并找到了两组更改:“ours”(来自HEAD/current-branch 提交)和“theirs”(来自我们在命令行中命名的提交)。只要我们和他们在同一个文件中更改不同的文件或不同的行,1 Git 本身就可以自行组合。

Git 将对所有文件重复此操作。Git 会将合并后的更改应用到来自合并基础的快照(此处为 commit H),如果没有冲突,Git 将自行进行新的合并提交。这就是我所说的merge作为名词,因为commit这个词前面的形容词merge经常被用作名词,“a merge”。

为了防止 Git 自行提交,我们将使用--no-commit. 如果我们不这样做,Git 仍然会在合并冲突的情况下停止(然后您必须在提交之前解决冲突)。

在我们继续展示如何撤消部分合并之前,让我们假设我们正常完成了合并,或者遗漏了--no-commit,以便我们获得最终的合并提交。让我们把它画进去:

          I--J
         /    \
...--G--H      M   <-- branch1 (HEAD)
         \    /
          K--L   <-- branch2

请注意,名称 branch1已照常更新。它现在指向新的合并提交 M。合并的原因很简单:它有两个父提交,M而不是通常的单父提交。Git 将提交添加为新提交的第二个父项。JL

这个新的第二个父级的真正意义将在稍后变得更加清晰,但请注意,我们现在可以通过向下和向左 访问从名称K和提交两个提交。因此,现在所有提交都在(可访问)名称上而提交并且不在它们不可访问,因为最后一次提交是 commit ,其(单个)父级是,其(单个)父级是. 从我们只能倒退到,然后到等等。Lbranch1Mbranch1IJbranch2branch2branch2LKHHGF


1如果我们都以不同的方式更改(比如说)第 42 行,Git 将不知道是使用我们的更改,还是他们的更改,或者不同的东西。这里 Git 将声明一个合并冲突,并在合并的中间停止,合并未完成。你的工作变成了告诉 Git 最终结果应该是什么。

即使我们的更改和它们的更改只是邻接(触摸),Git 也会停止:如果我们用新的第 42 行替换旧的第 42 行,并且他们用新的第 43 行替换旧的第 43 行,Git 将在这里声明合并冲突也是。这对文件顶部或末尾的更改特别有用,但也特别烦人,因为 Git 不知道将这些更改放在哪个顺序中。例如,如果有一个 10 行文件并且我们添加了第 11 行,他们添加了第 11 行,哪一行先行?哪一行变成第 12 行?Git 本身不知道,所以它让做的人git merge提供正确的答案。


使用(或滥用?)--no-commit

当 Git 为合并提交创建快照时M,Git 会以与任何提交相同的方式这样做。我们还没有在这里讨论 Git 的索引暂存区的作用——由于篇幅原因,我们不会——但重点是新提交M将有一个快照,就像任何其他提交一样。我们可以使用git checkoutorgit restore或仅通过编辑文件的工作树副本并使用git add更改提交的内容M

所以,如果我们运行:

git checkout branch1
git merge --no-commit branch2

并且 Git 认为这一切都已完成,但尚未进行合并,我们现在可以使特定文件(例如某个目录中的每个文件)与HEAD(ie, current, ie, J) 提交中这些文件的副本匹配:

git checkout HEAD -- subdir2 subdir3

这将在 Git 的索引和您的工作树中,将所有文件副本替换为快照中subdir2/的文件副本。或者:subdir3/HEAD

git restore -iw --source HEAD subdir2 subdir3

它做同样的事情。

如果您现在运行git merge --continuegit commit,Git 现在将根据此步骤更新M的合并文件制作快照。您将获得与以前相同的提交图

          I--J
         /    \
...--G--H      M   <-- branch1 (HEAD)
         \    /
          K--L   <-- branch2

不同之处在于commitM中的快照现在与commit 中的快照匹配J除了没有恢复的文件,这些文件现在包含 Git 自动进行的合并,使用H,JL作为三个输入提交。

请注意,三个现有输入提交中没有任何变化。 没有什么可以改变,所以什么也没有改变。这意味着,如果您愿意,您可以稍后重新执行相同的合并,无论是否使用--no-commit. 因为所有提交哈希 ID 在计算加密校验和时都包含时间戳,所以如果您进行新合并,则新合并将具有与现有合并提交不同的哈希 ID M。您可能希望稍后利用这个事实。

提交存储库中的历史记录

现在该提交M存在:

          I--J
         /    \
...--G--H      M   <-- branch1 (HEAD)
         \    /
          K--L   <-- branch2

本质上,Git 会相信提交M是合并的正确结果。让我们以通常的方式(或,加上通常的工作)向branch1and添加更多提交,然后准备再次合并:branch2git checkoutgit switchbranch2branch1

          I--J
         /    \
...--G--H      M--N   <-- branch1 (HEAD)
         \    /
          K--L--O--P   <-- branch2

如果我们运行git log,我们将看到 commits N、then M、then ——以某种顺序——<code>J 和IandLK——然后H、thenG等等。如果我们运行git log branch2,我们将看到 commit P、then O、then L、then K、then H、then G,依此类推。这是因为这些是来自每个分支提示提交的可达提交。当向后遍历时M,Git 将访问分支的两条腿: 2请注意,当向后查看时,合并实际上是一个分支(和一个分支拆分,其中H拆分为流I,而K, 是一个合并)。

无论如何,如果我们现在运行:

git merge branch2

再次(有或没有--no-commit),Git 将通过通常的过程来定位两个分支提示提交NP然后向后工作以找到最佳共享提交作为合并基础。在这种情况下,最好的共享提交是 commit LN只要我们在分叉处往下走,就向后退两步,然后再向后退两步P3

Git 现在将进行通常的比较,从LtoN查看我们更改了什么,从LtoP查看他们更改了什么。如果我们使用git checkoutgit restore使合并中的文件M与 中的文件相匹配J,“我们更改的内容”是将我们的东西从J后面放回,而“他们更改的内容”通常什么都不是O,因为and P, onbranch2中的快照不会不必进行任何更改以保留其代码。

这意味着通过告诉 Git合并的正确方法是保留文件 Git 将继续相信这是进行合并的正确方法。JLJ

请注意,如果您重新执行Jand的合并L(通过将任一提交检出为历史提交,或创建一个新的分支名称,然后合并另一个提交),Git 仍将重新执行与第一次相同的工作我们合并的时间JL. 也就是说,这一次,Git 会再次组合你手动放回的文件。当我们合并NandP时,它们的历史记录中都有M提交,Git 将“看到”我们之前所做的合并。


2这有助于说明为什么分支一词在 Git 中存在问题。如果我们想要准确,我们应该在谈论诸如、和之类的名称时使用短语分支名称。结构分支——如果你正在向前阅读,叉子,或者当 Git 向后阅读时叉子——没有很好的名字。我喜欢给他们打电话:看看我对“分支”到底是什么意思的回答?masterbranch1branch2HMDAGlets

3两条“腿”每次都后退 2 步,这是一种巧合,我试图画出漂亮的图表。通常每条腿的步数不同,在某些情况下,从一个或两个提交都没有退步。然而,当不退一步时,合并要么是微不足道的(Git 会做其他事情——默认情况下不会真正合并),要么已经完成(Git 只会说你是最新的,什么也不做)。


概括

  • 合并动作——合并部分git merge——merges commits。也就是说,它查看每个提交中的快照。
  • 合并过程使用历史记录来查找合并基础,该历史记录是早期提交(包括合并提交)记录的图表的结果。
  • 您可以在 merge-as-a-verb 部分之后故意暂停 Git 并进行更改。
  • 当你完成这部分,并使用git merge --continueorgit commit来将合并作为一个名词,生成的快照将是你所做的任何事情,而 Git 被暂停了。

这就是你如何实现你想要的。由于您正在处理git-subtree其他地方(我假设),因此在某种意义上这使以后的合并“更难”这一事实可能无关紧要:如果您需要更新的subdir2文件,您可以只是git checkoutgit restore -iw来自适当的提交。


推荐阅读