将一个分支与另一个首先提交的分支合并

Merge a branch with another branch which first committed

我有以下问题。我在一个分支(我们称之为 A)上工作,在那里我实现了一个新功能。我只提交了更改,但没有推送它们。现在我后来意识到我在错误的分支上。所以我换到了右边的分支(B)。如何将更改从分支 A 转移到分支 B?

所以在 B 中,到目前为止的所有内容都保留下来,而 A 中的所有新内容都存放在 B 中。

如果:

  • 喜欢一些提交,但是
  • 关于相同的提交,还有一些您喜欢的东西

那么通常解决这个问题的正确方法是使用 git rebase。关于 git rebase 总是有一个警告,我稍后会描述,但由于您还没有 发送 这些提交给一些 other Git 存储库 - 您想要以某种方式更改的提交完全属于您,仅存在于您自己的 Git 存储库中 - 此警告不适用于您的情况。

不过,在您的特定情况下,您根本不需要使用变基。您将改为使用 git cherry-pick,然后使用 git resetgit branch -f。或者,您甚至可能不需要执行 cherry-pick.

关于提交(和 Git 的一般知识)

Git 实际上就是 提交 。它与文件无关,尽管提交会 hold 文件。它也与分支无关,尽管分支名称可以帮助我们(和 Git)找到 提交。不过,最后,重要的只是提交。这意味着您需要了解有关提交的所有信息。

在Git中:

  • 每个提交都是编号,有一个独特的,但又大又丑的random-looking,哈希IDobject ID。这些实际上根本不是随机的:数字是加密哈希函数的输出。每个 Git 使用相同的计算,因此宇宙中每个地方的每个 Git 都会同意某个特定的提交得到 那个数字 。没有其他提交可以有 那个数字,不管它是什么:那个数字现在被那个特定的提交用完了。由于数字必须是普遍唯一的,因此它们必须很大(因此很难看,人类无法使用)。

    Git 将这些提交和其他支持提交的内部 object 存储在一个大数据库中 - key-value store - 其中哈希 ID 是键和提交(或其他 object)是值。你给 Git 密钥,例如,通过从 git log 输出剪切和粘贴,Git 可以找到提交并因此使用它。这通常不是我们实际使用 Git 的方式,但重要的是要知道:Git 需要密钥,即哈希 ID。

  • 每个提交存储两件事:

    • 每次提交都会存储一个每个文件的完整快照,截至您提交时。它们以特殊的 read-only、Git-only、压缩和 de-duplicated 格式存储,而不是您计算机上的普通文件。根据您的 OS,Git 可能能够存储您的计算机实际上无法使用或提取的文件(例如,Windows 上名为 aux.h 的文件),这有时是个问题。 (你必须制作这些文件在OS上可以命名它们,当然,比如Linux。不过,所有这一切的目的只是为了表明这些文件 不是 常规文件。)

    • 每个提交还存储一些 元数据,或有关提交本身的信息:例如,谁创建的,何时创建的。元数据包括 git log 显示的日志消息。对于 Git 至关重要的是,每个提交的元数据都包含一个列表——通常只有一个条目长——包含 先前的提交哈希 ID.

  • 由于 Git 使用的散列技巧,没有提交——没有任何类型的内部 object——一旦被存储就永远无法更改。 (这也是文件存储的工作方式,也是 Git de-duplicates 文件的方式,并且可以存储您的计算机无法存储的文件。它们都只是那个大数据库中的数据。)

同样,提交的元数据存储一些先前提交的哈希 ID。大多数提交在此列表中只有一个条目,并且该条目是此提交的 parent。这意味着 child 提交记住他们的 parents' 的名字,但是 parents 不记得他们的 children:parents 被及时冻结从他们制作的那一刻起,他们 children 的最终存在就无法添加到他们的记录中。但是当child人出生时,parent人就存在了,所以child可以保存它的parent提交号。

这一切意味着提交形式向后看链,其中最新提交指向next-to-latest,并且该提交指向另一个跃点,依此类推。也就是说,如果我们绘制一个小的提交链,其 last 提交具有散列 H,我们得到:

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

哈希为H的提交保存所有文件的快照,加上元数据; H 的元数据让 Git 找到提交 G,因为 H 指向它的 parent G。 Commit G 依次保存所有文件和元数据的快照,并且G 的元数据指向 F。这一直重复到第一次提交,这是第一次提交 - 不能向后指向。它有一个空的 parent 列表。

git log程序因此只需要知道一个提交哈希ID,即H。从那里,git log 可以显示 H,然后向后移动一跳到 G 并显示 G。从那里,它可以向后移动另一跳到 F,依此类推。当您厌倦阅读 git log 输出并退出程序时,或者当它一路返回到第一次提交时,该操作将停止。

分支名称帮助我们找到提交

这里的问题是我们仍然需要以某种方式记住链中最后一个提交 H 的哈希 ID。我们可以把它记在白板上、纸上或其他东西上——但我们有一台 计算机 。为什么不让 computer 为我们保存哈希 ID?这就是 分支名称 的意义所在。

每个分支名称,在Git中,只保存一个哈希ID。无论分支名称中的哈希 ID 是什么,我们都说该名称 指向 该提交,并且该提交是该分支的 尖端提交 .所以:

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

这里我们有分支名称 main 指向提交 H。我们不再需要记住哈希 ID H:我们只需输入 main 即可。 Git 将使用名称 main 查找 H,然后使用 H 查找 G,然后使用 G 查找 F,等等。

一旦我们这样做了,我们就有了一个简单的方法来添加新的提交:我们只需做一个新的提交,比如I,这样它就指向后面到 H,然后 I 的哈希 ID 写入名称 main,如下所示:

...--F--G--H--I   <-- main

或者,如果我们不想更改我们的名字 main,我们可以创建一个 新名字,例如 developbr1:

...--F--G--H   <-- br1, main

现在我们有多个 name,我们需要知道我们使用哪一个来查找提交 H,所以我们将绘制特殊名称 HEAD,附加到分支名称之一,以表明:

...--F--G--H   <-- br1, main (HEAD)

这里我们通过名称 main 使用提交 H。如果我们 运行:

git switch br1

我们得到:

...--F--G--H   <-- br1 (HEAD), main

没有其他变化——Git 注意到我们正在“从 H 移动到 H”,可以说是——所以 Git 需要一些 short-cuts 并且不会为这种情况做任何其他工作。但现在我们是 on branch br1,正如 git status 所说。现在,当我们进行新提交时 I,我们将得到:

             I   <-- br1 (HEAD)
            /
...--F--G--H   <-- main

名称 main 留在原地,而名称 br1 移至指向新提交 I

您描述的情况

I was working on a branch (let's call it A) where I implemented a new function. I have only committed the changes, but I did not push them. Now I realized later that I am on the wrong branch. So I changed to the right branch (B). How can I transfer the changes from branch A to branch B?

让我们画这个:

...--G--H   <-- br-A (HEAD), main
      \
       I--J   <-- br-B

你是 on branch br-A 并且做了一个新的提交,我们称之为 K:

          K   <-- br-A (HEAD)
         /
...--G--H   <-- main
      \
       I--J   <-- br-B

关于提交 K,您 有一些事情喜欢:例如,它的快照与提交 H 中的快照不同,无论您如何更改制作。它的日志消息也说明了您希望日志消息说明的内容。

但是有一件事你喜欢提交K:它发生在提交H之后,当你想要它在提交 J.

之后出现

您不能更改提交

我们在靠近顶部的位置注意到,一旦提交,就无法更改。您现有的提交 K 是一成不变的:没有人,没有任何东西,甚至 Git 本身也不能更改关于提交 K任何东西。它在 H 之后,它有快照和日志消息,并且永远如此。

但是...如果我们可以复制 K 到一个新的改进的提交呢?我们称此 new-and-improved 提交 K',表明它是 K 副本 ,但有一些不同之处。

应该有什么不同?好吧,一方面,我们希望它在 J 之后出现。然后我们希望它对 KH 所做的 更改 J 相同。也就是说,如果我们问 H-vs-K 快照有什么不同,然后问 J-vs-K' 快照有什么不同制作,我们希望获得 相同的更改

有一个相当低级别的 Git 命令可以像这样精确地复制一个提交,称为 git cherry-pick。这实际上就是我们最终要使用的。

不过,这里还是要说一下git rebase。如果我们有十几个或一百个要复制的提交,cherry-pick 对每个进行复制可能会很乏味; git rebase 也会自动执行重复的 cherry-picking。所以 rebase 是 usual 使用的命令。

rebase 的工作原理如下:

  • 首先,我们Git列出了它需要复制的所有提交。在这种情况下,只需提交 K.
  • 然后,我们 Git 签出 (切换到)我们所在的提交希望副本 go。在这种情况下,提交 J.
  • 接下来,我们Git从它创建的列表中一次复制每个提交。
  • 然后我们 Git 获取 分支名称 找到要复制的 last 提交,然后移动它指向 last-copied 提交的名称。

所有这一切的最终结果,在这种情况下,是:

          K   ???
         /
...--G--H   <-- main
      \
       I--J   <-- br-B
           \
            K'  <-- br-A (HEAD)

请注意提交 K 仍然存在。只是再也没有人能找到它了。名称 br-A 现在找到 copy,提交 K'.

Cherry-picking

这不是我们想要的,所以我们不使用 git rebase,而是使用 git cherry-pick。我们先运行:

git switch br-B

得到:

          K   <-- br-A
         /
...--G--H   <-- main
      \
       I--J   <-- br-B (HEAD)

现在我们将 运行:

git cherry-pick br-A

这个用名字br-A找到commitK,然后复制到我们现在所在的地方。也就是说,我们得到了一个新的提交,它进行了 与提交 K 相同的更改 ,并且具有 相同的日志消息 。这个提交在我们现在所在的分支上进行,所以 br-B 被更新为指向副本:

          K   <-- br-A
         /
...--G--H   <-- main
      \
       I--J--K'  <-- br-B (HEAD)

我们现在应该检查和测试新的提交,以确保我们真的喜欢结果(因为如果我们不喜欢,您可以在这里做很多事情)。但假设一切顺利,现在我们想 discardbr-A.

末尾提交 K

我们实际上无法删除 提交K。但是分支名称只是保存了我们想说的“在分支上”的最后一次提交的哈希 ID,我们可以更改存储在分支名称中的哈希 ID.

这里事情变得有点复杂,因为 Git 有两种不同的方法来做到这一点。使用哪一个取决于我们是否检查了那个特定的分支。

git reset

如果我们现在运行:

git switch br-A

得到:

          K   <-- br-A (HEAD)
         /
...--G--H   <-- main
      \
       I--J--K'  <-- br-B

我们可以使用 git reset --hard 将提交 K 从当前分支的末尾删除。我们只需找到 previous 提交的哈希 ID,即哈希 ID H。我们可以使用 git log,然后是 cut-and-paste 哈希 ID,或者我们可以使用 Git 内置的一些特殊语法:

git reset --hard HEAD~

语法 HEAD~ 的意思是:找到由 HEAD 命名的提交,然后返回到它的(首先也是唯一在这种情况下)parent。在此特定绘图中定位提交 H

重置命令然后将分支名称移动到指向此提交,并且——因为 --hard——更新我们的工作树和 Git 的 index aka 暂存区匹配:

          K   ???
         /
...--G--H   <-- br-A (HEAD), main
      \
       I--J--K'  <-- br-B

Commit K 不再有办法找到它,所以除非你告诉他们,否则没人会知道它在那里。

请注意,鉴于此特定绘图,我们也可以完成 git reset --hard mainHEAD~1 样式语法甚至在其他情况下也有效。

git branch -f

如果我们不先检查 br-A,我们可以使用git branch -f强制它后退一步。这与 git reset 具有相同的效果,但是因为我们没有按名称检查分支,所以我们不必担心我们的工作树和 Git 的 index/staging-area:

git branch -f br-A br-A~

在这里,我们使用名称 br-A 的波浪号后缀让 Git 后退一个 first-parent 跃点。效果是完全一样的,但是只有在还没有检出分支br-A.

的情况下才能这样做

特例

假设我们上面的图纸不太正确。也就是说,假设分支 br-Abr-B 在我们提交 K 之前指向 不同的提交 ,它们都指向 相同的提交。例如,我们可能有:

...--G--H   <-- main
         \
          I--J   <-- br-A (HEAD), br-B

如果我们处于这种情况然后提交 K,我们将得到:

...--G--H   <-- main
         \
          I--J   <-- br-B
              \
               K   <-- br-A (HEAD)

请注意,在这种情况下,没有什么我们不喜欢提交K:它有正确的快照 它有正确的元数据。 唯一的问题是名称br-A指向Kbr-B指向J。我们希望 br-B 指向 K 并且 br-A 指向 J.

我们可以通过以下方式得到我们想要的:

  • 移动两个分支名称,或
  • 交换分支名称

我们可以用 git resetgit branch -f 的组合来做第一个。我们只需要注意不要丢失提交 K 的哈希 ID。

我们可以运行git log剪切粘贴K的hash ID,这样就不会丢了,然后运行:

git reset --hard HEAD~

得到:

...--G--H   <-- main
         \
          I--J   <-- br-A (HEAD), br-B
              \
               K   ???

那么我们可以运行:

git branch -f br-B <hash-of-K>

粘贴正确的散列,得到:

...--G--H   <-- main
         \
          I--J   <-- br-A (HEAD)
              \
               K   <-- br-B

例如。或者,与其采用那种稍微冒险的方法(如果我们不小心剪切了一些其他文本并丢失了哈希 ID 会怎样?),我们可以更新br-B 第一个,其中:

git branch -f br-B br-A

或:

git checkout br-B; git merge --ff-only br-A

(里面引入了--ff-only合并的概念,这里不做解释)得到:

...--G--H   <-- main
         \
          I--J
              \
               K   <-- br-A, br-B

其中之一是当前分支。然后我们可以修复 br-A 使其向后移动一跳。

最后,我们可以使用“重命名两个分支”技巧。这需要临时取第三个名字:

git branch -m temp        # rename br-A to temp
git branch -m br-B br-A   # rename br-B to br-A
git branch -m br-B        # rename temp to br-B

在所有这些情况下,无需复制任何提交,因为K 已经是正确的形式。我们只需要将 names 稍微打乱一下。

关键通常是画图

如果您对这些事情不确定,画图

您可以让 Git 或其他程序为您绘制图形:请参阅 Pretty Git branch graphs。请注意,绘制和阅读图表需要一些练习,但这是一项重要技能,在 Git.

绘制图表后,您可以判断是否需要 新的和改进的提交——您可以使用 git cherry-pick 获得,也许 git rebase—and/or你需要分支名称re-point.

这也让您深入了解我提到的警告。 当您将提交复制到 new-and-improved 时,任何已经拥有 old-and-lousy 的 Git 存储库 1 也需要更新。 因此,如果您使用 git push 发送 old-and-lousy 提交到其他 Git 存储库,请确保他们——无论谁”他们”——也愿意更新。如果你不能让它们切换,进行new-and-improved提交只会造成大量重复提交,因为他们会继续把旧的和糟糕的提交即使你一直把它们拿出来,也要回来。因此,如果您 发布了 一些提交,请确保他们——无论他们是谁——在你进行变基或其他任何事情之前同意切换到改进的提交。


1如果某些东西是 new-and-improved,这告诉您关于旧版本的什么信息?或许这里“烂”太过强烈,但至少让人回味无穷。