是否可以从主存储库中只合并一个子存储库(子树)?

Is it possible to merge only one sub-repository (subtree) from main repository?

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

这里的答案既不是也不是。也就是你能达到你要求的,但是:

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

不过,要做到这一点,请使用:

git merge --no-commit

然后使用 git checkout 或(自 Git 2.23)git restore 来“撤消”一些合并。然后用 git merge --continuegit commit 完成合并。请参阅下面的详细信息以获取更多信息。

背景

要了解这一切是如何工作的(以及为什么这是个坏主意),请记住关于 Git 的这一点:Git 是关于提交的。 Git 与 文件 无关,甚至与 分支 无关。的确,commits contain files—that's why we have commits, to hold files—and branch names find commits, 这就是为什么我们有分支名.但最终,Git 就是关于 提交

  • 提交已编号。这些不是简单的计数:我们不是从提交 #1 开始,然后是 #2、#3 等等。相反,每个人都有一个 random-looking(但实际上根本不是随机的),唯一的 哈希 ID,它显示为一大串丑陋的字母和数字,通常缩写为人类通常会略过它们(dca3c76df9bb99b0...dca3c76dfb9b99b0... 一样吗?)。

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

  • 每个提交存储两件事:

    • Git 知道的每个文件都有完整的快照。这些文件以特殊的 read-only、Git-only、压缩和 de-duplicated 格式存储。 (de-duplication 立即处理了这样一个事实,即大多数提交中的大多数文件与之前提交中的那些相同文件的版本完全相同。)

    • 同时,每个提交存储一些元数据,即关于提交本身的信息。这包括是谁制作的——姓名和电子邮件地址——以及制作时间,以及解释为什么他们制作的日志消息。在这个元数据中,Git 存储了 Git 本身需要的东西: 我们在这里看到的提交之前的提交的提交编号。 Git 称其为 parent 提交.


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

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

    使用哈希 ID H,Git 可以检索您所做的实际提交(包括快照),无论您何时提交。这样就可以获取文件。它还获取 Git 元数据,包括早期提交的哈希 ID G。这意味着 Git 可以提取 both 提交并将 G 中的文件与 H 中的文件进行比较,以向您展示您 H 中更改了 。 Git 还可以打印出制作快照 G 的人的姓名和电子邮件地址,并使用 G 的元数据来查找提交 F。将 F 中的快照与 G 中的快照进行比较,Git 可以向您显示 G 和 [=596= 中的 更改 的内容] 可以返回 even-earlier 提交 F,等等。

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

  • 一个 分支名称 masterdevelop 只包含一 (1) 个哈希 ID。但只要我们——或 Git——确保这是链中 last 提交的哈希 ID,我们就没问题了:

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

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

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

    一旦我们提交I(当我们使用master作为名称时),Git将自动更新master 以便它指向 last 提交。新提交 I 的 parent 将是现有提交 H.

    由于每个提交的“箭头”指向其 parent,是提交的一部分,因此无法更改。就像提交中的所有内容一样,这些纯粹是 read-only。请注意 分支名称 出来的箭头 确实 改变了尽管。所以这就是为什么我一直画那个箭头,同时将 commit-to-commit 箭头变成更简单的线条:我们只需要记住提交点 backwards,并且 Git 有效 向后.

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

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

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

    这个的技术术语是可达性。我们将在下面的合并中稍微使用它,但请考虑从提交 H 开始并向后工作,一次提交一个。在不动的情况下,我们已经 达到 提交 H。我们后退一步,我们已经提交 G。后退两步,我们在提交 F,依此类推。

  • 请注意 Git 可以比较 任何两个 提交,而不仅仅是 parent 和 child 对。我们将较早的提交放在左侧(嗯,通常情况下),而较晚的提交在右侧。 Git 然后比较两个提交的快照。对于相同的文件,Git 什么也没说。对于不同的文件,Git 找出我们可以做的一些更改:在第 42 行之后添加这些行,并删除第 86 行 这是 diff: 它显示了如何将 left-side 文件更改为右侧文件。

    如果我们比较 parent 和 child,这个差异列表 通常 我们所做的。但请注意 Git 只会找到 a 组更改。在某些情况下,我们如何 改变了它。 diff Git 发现会起作用,即使我们做的事情有点不同——但有时(见下面的合并),这可能会导致轻微但恼人的合并冲突,如果 Git 做了在这里干得更好。

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

git merge 将合并 commits 并进行 merge commit

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

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

因为我们在这个图中有两个名字,我们需要记住我们使用的是哪个名字。这就是特殊名称 HEAD 的由来:我们将它附加到我们告诉 Git 与 git checkout 或(自 Git 2.23 起)[=65= 一起使用的任何分支].这是我们进行新提交时将更新的名称。

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

然而,合并操作实际上需要三次 次提交。第三个——或者在某种程度上,第一个——是其他两个提交中最佳共同祖先的提交。您可以将此视为 Git 查看我们已经命名的两个提交 - JL 此处 - 并向后工作。我们将从两个提交中根据需要向后移动,直到我们找到可以从 两个提交中找到的提交。

在这种情况下,最佳共享提交显而易见:它是提交 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 找到了共同的起点,并找到了两组更改:“我们的”(来自 HEAD / current-branch 提交)和“他们的”(来自我们命名的提交命令行)。只要我们和他们改变不同的文件同一文件中的不同行,1Git本身会b能够自己进行组合。

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

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

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

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

请注意,名称 branch1 已照常更新。它现在指向新的 merge commit MM 合并的原因很简单:它有,而不是通常的单个 parent 提交 J两个 parents。 Git 添加提交 L 作为新提交的 秒 parent

这个新秒 parent 的真正意义稍后会变得更加清晰,但请注意,我们现在能够从名称中获得提交 KL branch1 并通过 down-and-left 提交 M所以现在 所有 提交都在(可从)名称 branch1 上,而提交 IJnot on branch2: 它们无法从 branch2 访问,因为 branch2 上的最后一次提交是提交 L,谁的(单)parent是K,谁的(单)parent是H。从H我们只能向后G,然后到F等等。


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

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


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

当 Git 为合并提交 M 制作快照时,Git 以与任何提交相同的方式进行快照。我们还没有在这里讨论 Git 的 indexstaging area 的作用——由于篇幅原因,我们不会' t——但重点是新提交 M 将有一个快照,就像任何其他提交一样。我们可以使用 git checkoutgit restore,或者仅通过编辑文件的工作树副本并使用 git add,更改提交 M.

的内容

所以,如果我们 运行:

git checkout branch1
git merge --no-commit branch2

和Git认为这一切都完成了但是还没有进行合并,我们现在可以使特定文件——比如某个目录中的每个文件——匹配HEAD(即当前,即 J)提交中那些文件的副本:

git checkout HEAD -- subdir2 subdir3

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

git restore -iw --source HEAD subdir2 subdir3

做同样的事情。

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

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

不同的是,提交 M 中的 快照现在与提交 J 中的快照匹配,except 对于您 没有 还原的文件,这些文件现在包含 Git 使用 HJL 作为三个输入提交。

请注意,现有的三个输入提交中没有任何更改。没有任何内容可以更改,因此没有任何更改。这意味着,如果您愿意,您可以稍后 re-do 相同的合并,有或没有 --no-commit。因为在计算加密校验和时所有提交哈希 ID 都包含 time-stamp,所以如果进行新合并,则新合并将具有与现有合并提交 M 不同的哈希 ID。你可以希望以后利用这个事实。

提交存储库中的历史

现在提交 M 存在:

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

Git 本质上会相信提交 M 是合并的 正确结果 。让我们以通常的方式(git checkoutgit switch 加上通常的工作)向 branch1branch2 添加更多提交,然后准备合并 branch2再次进入branch1

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

如果我们 运行 git log 我们将看到提交 N,然后是 M,然后——按某种顺序——JILK——然后是 H,然后是 G,依此类推。如果我们 运行 git log branch2,我们将看到提交 P,然后是 O,然后是 L,然后是 K,然后是 H,然后是 G,依此类推。这是因为这些是来自每个 branch-tip 提交的 可达提交 。通过M向后遍历时,Git会访问分支的两条腿2注意,向后看时,合并实际上是一个分支(而分支拆分,其中 H 拆分为流 IK,是合并)。

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

git merge branch2

再次(有或没有 --no-commit),Git 将通过通常的过程找到两个 branch-tip 提交 NP 和然后向后工作以找到最好的 shared 提交作为合并基础。在这种情况下,最好的共享提交是提交 L:从 N 后退两步,只要我们在分叉处下车,也从 P 后退两步。3

Git 现在将进行通常的比较,从 LN 看看我们改变了什么,从 LP 看看我们改变了什么他们改变了什么。如果我们使用 git checkoutgit restore 使合并 M 中的文件与 J 中的文件相匹配,“我们改变的”是将我们的东西从 J 回来,“他们改变了什么”通常 什么都没有 ,因为 OP 中的快照在 branch2 上不会无需进行任何 更改 即可保留其代码。

这意味着告诉Git正确合并JL的方法是保留来自 J 的文件,Git 将继续相信这是进行合并的正确方法。

请注意,如果您 re-perform 合并 JL(通过将提交签出为历史提交,或创建新的分支名称,然后合并其他提交),Git 仍将 re-do 与我们第一次合并 JL 时所做的工作相同。即这次、Git会合并你手动放回去的文件。当我们合并 NP 时,它们的历史记录中都有 M 提交,Git 将“看到”我们之前所做的合并。


2这有助于说明为什么单词 branch 在 Git 中有问题。如果我们想要准确,我们应该在谈论名称时使用短语分支名称,如masterbranch1 , 和 branch2。结构分支——如果你向前阅读,H 分支,或者当 Git 向后阅读时,M 分支——没有很好的名字。我喜欢称他们为 DAGlets:请参阅我对 What exactly do we mean by "branch"?

的回答

3事实上,它在两条“腿”上每次都向后退了 2 步,这是我试图绘制漂亮图表所迫使的一种巧合。通常每条腿的步数不同,在某些情况下,no 从一个或两个提交后退。然而,当不退一步时,合并要么是微不足道的(并且 Git 会做其他事情——默认情况下不是真正的合并),要么已经完成(并且 Git 只会说你已经起来了到目前为止,什么都不做)。


总结

  • 合并操作——git merge合并部分——合并提交。也就是说,它会查看每次提交中的快照。
  • 合并过程使用历史记录来查找合并基础。图形 由早期提交(包括合并提交)记录的结果。
  • 您可以在 merge-as-a-verb 部分之后故意暂停 Git 并进行更改。
  • 当你完成这部分,并使用 git merge --continuegit commit 制作 merge-as-a-noun 时,生成的快照将是你制作的任何内容,而 Git 已暂停。

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