如何在 2 个功能分支之间共享代码

How to share code between 2 feature branches

假设我在 feature1 branch 中写了一个 method,一段时间后我意识到我在另一个 feature2 branch 中也需要这段代码。

所以我只是 copy/paste 从 feature1feature2 的代码,并且工作同时在两个分支上继续进行。我无法将 feature1 合并到 feature2,因为 feature2 的审阅者也必须检查 feature1 的更改。 然后我请审稿人审阅这两个功能。

假设 feature1 合并到 master 中,然后我想将 feature2 合并到 master 中。但是因为 copy/paste 我得到了合并冲突,所以我不得不再次要求评论。 这本身不是问题。但是有没有办法避免这种冲突呢?

您的问题始于一些不正确的假设:

  • 可以cherry-pick,如果你喜欢。
  • Cherry-picking本身并不代表你有冲突。这并不意味着您不会有冲突。
  • 你确实可以 copy-paste 一些代码;和以前一样,这本身既不会导致也不会避免冲突。
  • 矛盾不是罪恶!好吧,您没有说它们是 ,但让我们澄清一下:“冲突”的全部含义是 Git 在尝试合并工作时无法自行完成此合并。这不是悲剧,人类通常很容易解决。

让我们看看合并在Git中是什么意思,以及一些常见的错误。

Git 是关于提交

Git 的新手通常认为 Git 是关于文件或分支的,但事实并非如此。 Git 大约 提交 。一个提交 包含 个文件——每个提交都有 每个 个文件的完整快照,事实上——我们组织并找到我们的提交 using 分支,所以文件和分支都有一部分。但是 Git 的核心和灵魂是提交。

Git 将这些提交存储在一个充满 Git “对象”的大数据库中。内部有四种对象:blobtreecommit准确地说是带注释的标签。但在大多数情况下,人类只处理提交对象。这些存储我们的提交,并且由于提交是 Git 存储库中的“工作单元”,可以说——因为 Git 存储 提交——这就是我们处理 Git.

的级别

不幸的是,对于我们人类来说,Git 的提交是有编号的,大而难看的 random-looking 数字没有韵律或原因;1 他们看起来例如 1bcf4f6271ad8c952739164d160e97efd579424f。人类无法处理这些,所以我们就是不处理。 Git 通过添加一个较小的 names 数据库,与提交和其他对象的大数据库分开,包括分支和标签名称。像 refs/heads/mainrefs/heads/master 这样的名称是 分支 名称,并且会变成丑陋的大哈希 ID for 我们。所以我们可以给 Git 一个分支名称,然后 Git 会找出正确的哈希 ID,并用它来找出正确的提交。

这就是我们可以使用 feature1feature2 这样的名称的方式和原因。这些名称对 Git 来说几乎毫无意义。 Git 并不真正需要它们,也不关心我们如何拼写它们或我们用它们做什么——例如,我们可以随时重命名它们——只是为 us 以便 我们 不必记住哈希 ID。 Git 将名称转换为哈希 ID,并通过哈希 ID 找到提交并开始工作。所以 Git 此时没有使用您的分支名称:它仅使用提交本身,Git 通过其哈希 ID 找到。

这就是 Git 与 提交 有关的方式和原因。我们使用分支名称,但 Git 大多不使用。我说 mostly 因为我们即将达到 Git do 使用分支名称的地步,以使它们保持最新我们.


1从技术上讲,它们只是一些加密散列的输出。传统上,Git 使用 SHA-1,但 Git 现在支持 SHA-256。正在努力使其更有用:目前,如果您是 Git 用户而不是 Git 开发人员,您将只使用 SHA-1。


提交内容

请记住,Git 中的每个提交都有一个丑陋的大哈希 ID。这些是 独特的 那个特定的提交: 没有其他 Git 提交,任何地方,是 曾经允许使用那个再次哈希 ID.2 因此,如果我们将任何两个 Git 存储库拴在 Git-repository-park 上(就像把狗带到狗身上一样park),他们可以互相嗅探并决定哪个提交有另一个没有,只需查看哈希 ID。然后一个 Git 存储库可以获得另一个的提交,知道第一个存储库仅根据哈希 ID 缺少那些提交。

我们不会在这里担心这个 exchange-of-commits 东西——那是 Git 作为分布式版本控制系统的 distributed 部分——但它很重要当我们查看任何给定提交中的内容时,请记住这个“唯一哈希 ID”:

  • 每个提交都有一个每个源文件的完整快照,一直冻结。如果你有那个提交,你就有了那个快照。 (如果你没有那个提交,你可能仍然有那个快照——两个或多个提交可能有 相同的 快照——但你不能确定:你必须有提交。这些快照中的文件通过压缩和 de-duplication 巧妙地存储,因此存储库不会随着我们添加新提交而变得非常庞大:大多数文件要么完全重复, 或 near-duplicates 的一些早期文件,并且它被压缩到根本没有(重复)或非常小的存储空间。我们将跳过所有细节,即使它们令人着迷。

  • 同时,每个提交存储一些元数据,或者关于提交本身的信息。例如,这包括提交作者的姓名和电子邮件地址。

任何一次提交中的元数据不一定很大(或很小——您的提交日志消息会放在这里,所以如果您写一个非常大的元数据,它会在这里,占据 space)。但是除了你的姓名和电子邮件地址之外,Git 向每个提交添加了一个 父提交哈希 ID 列表 。此列表通常恰好是一个条目。

这意味着给定 最新的 提交,我们可以让 Git 向后工作以找到 second-latest 自行提交。让我们画这个。假设最新的提交有一些丑陋的大哈希 ID,我们不会尝试猜测它,而只会调用 H,表示“哈希”。我们会这样画:

            <-H

提交 H 在我们的绘图中有一个小箭头伸出来。实际上,提交 H 在其元数据中有一个 哈希 ID ,并且该哈希 ID 是 之前 提交的哈希 ID ] H。让我们调用该提交 G 并将其绘制在:

        <-G <-H

当然,提交G是一个提交,所以它有一个像H一样的散列ID“箭头”。提交 G 的箭头 指向 G 之前的提交,我们称之为 F:

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

提交 F 有一个指向 父级的箭头,依此类推。所以我们要做的就是让 Git 找到 所有 提交,就是让 Git 找到 最新的 提交 H.

好吧,我们刚才说过 分支名称 mainfeature1 存储哈希 ID。所以这个名字指向H,就像H指向G,以此类推:

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

Git 必须使用的技巧之一,以保持哈希 ID 的工作,是 any 提交的所有部分都被永久冻结。这包括向后指向先前提交的哈希 ID。所以 H 总是 指向 G,它总是指向 F,等等。因此,我有点懒得画连接提交彼此的箭头。

不是分支名称的情况。分支名称中的箭头 move.


2在两个从未相遇的 Git 存储库中,此限制有所放松。只要它们不相遇,两个独立的 Git 存储库就可以意外地 re-use 一个哈希 ID。无论如何,这在实践中并没有真正发生,尤其是因为控制最终会见哪些存储库的是人类。 Git 不知道那些疯狂的人类将来会做什么,所以 Git 只是试图确保每个提交都有一个完全唯一的哈希 ID。


进行新的提交

要进行新的提交,我们检查一些带有git checkout的分支,或者使用git switch“切换到”分支,因此“检查出来”,达到同样的效果。 Git 通过将特殊名称 HEAD 附加到存储库中的分支名称之一,来记住我们使用的 分支名称 。在这一点上,我们只有一个名字,main,所以不需要它,但我们有这个:

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

让我们现在创建一个新分支名称。让我们创建名称 feature1。此名称 必须指向某个现有的提交 。我们可以选择存储库中的任何提交,但通常我们会选择最新的 main-branch 提交(或者可能是最新的 develop-branch 提交或其他东西,但现在我们只有 main 无论如何)。所以新名称 feature1 也将指向提交 H:

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

请注意 所有 提交是如何在 两个分支 上进行的。两个名字 select 现在都提交 H。不过,这种情况即将改变。

我们现在使用 git switch feature1git checkout feature1 来 select name feature1 with which to select提交 H。这改变了我们的图片:

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

我们没有更改 commits,所以我们正在使用相同的文件,但是我们更改了我们正在使用的 分支名称找到提交 H.

现在我们做修改和 git add-ing 和 git commit-ing 的常规操作。 Git 完成新提交后,新提交会保存所有文件(冻结、压缩、de-duplicated 和 read-only)的新快照,一个新提交——我们称之为提交 I——将提交 H 作为其父提交:

             I
            /
...--F--G--H

但是——这里是Git的小魔术——Git已经在当前分支中存储了I的哈希ID, HEAD 附加的名称。所以如果我们在图片中包括分支名称,我们现在有这个:

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

新提交 I 目前仅在 feature1 上。通过 H 提交继续在两个分支上。如果我们再次提交 J,我们得到:

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

如果我们现在 git switch maingit checkout main,我们得到:

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

Git 将从我们的工作区中删除来自提交 J 的文件,并将来自提交 H 的文件放在适当的位置。 (我们没有在这里介绍工作树和 Git 的索引,并且出于 space 的原因,我们不会。)

现在让我们创建第二个分支名称,feature2 指向提交 H,然后切换到 feature2

             I--J   <-- feature1
            /
...--F--G--H   <-- feature2 (HEAD), main

当我们在 feature2 上进行新提交时,它们会导致 feature2 增长,就像 feature 发生的那样:

             I--J   <-- feature1
            /
...--F--G--H   <-- main
            \
             K--L   <-- feature2 (HEAD)

所以这是一个分支

这就是 Git 中的分支。我们称 latest 提交,由某个分支 name 找到,该分支的 tip commit。 (这是一个官方的 Git 术语。)我们称该提交加上一些早期提交的字符串为“分支”,我们也称名称为“分支”。所以当有人说“branch feature1”时,他们的意思可能是:

  • 姓名feature1;或
  • 提交Jfeature1的提示提交;或
  • 提交 I-J,目前仅 feature1;或
  • 提交到 J,包括 H 和其他 main;
  • 的提交

或者其他一些东西。 branch 这个词在 Git 中被严重滥用了,更具体一点通常是个好主意(你可以说“分支名称”或“提示提交”或例如,“提交集”。

正在合并

当我们有 发散 像上面那样的分支时——feature1feature2 从提交 H 发散并在提交 [=80 处结束=] 和 L——我们以后经常想 合并工作 。即,给定:

          I--J   <-- feature1
         /
...--G--H
         \
          K--L   <-- feature2

我们希望得到一个单一的提交 M,它有一组文件作为它的快照:

  • H 中的相似,除了 ...
  • 他们有我们在 IJ
  • 中所做的更改
  • 他们也有我们在 KL 中所做的更改。

我们经常在 Git 中使用 git merge 实现这一点。

到运行git merge,我们:

  • 选择两个 提示提交之一,通常按分支名称,通过检出/切换到该分支名称;
  • 运行 git merge 并给它另一个提交的哈希 ID,通常是分支名称。

所以我们 运行 git switch feature1 && git merge feature2,或者 git switch feature2 && git merge feature1.

当我们这样做时,Git 将:

  • 找到当前提交(使用HEAD和分支名称);
  • 找到另一个提交(使用我们给它的分支名称);和
  • 找到最佳共享提交:两个分支上的提交,其快照Git可以用作共同的起点。

请记住,我们的目标是合并工作。但是,提交不包含 work。它们包含 快照: 整个来源的完整档案。

因此,通过找到“最佳”共同起点——在本例中显然是提交H——Git可以简单地比较文件in commit H with those in commit J,看看改变了什么feature1.

此比较的输出是 line-by-line 对两次提交中任何 更改文件 的 file-by-file 更改的更改集。完全没有改变的文件——从 HJ 保持不变的文件——没有被提及。如果您 运行 git diff 提交 HJ,这就是您将看到的内容,这就是 git merge 将看到的内容。

HJ、Git 现在 运行 和 已经弄清楚哪些文件发生了变化,其中发生了什么变化比较,从提交 H 到提交 L。和以前一样,这会找出更改了哪些文件以及这些文件中更改了什么,line-by-line.

git merge 命令现在 合并了更改 。如果“我们”(H-vs-J,如果我们现在在 feature1)触及了一些文件,他们(H-vs-L ) 没有,Git 保留我们的更改。如果我们没有触及文件但他们触及了,Git 会保留他们的更改。如果我们 both touched the file, Git 尝试 combin我们的改变。

如果我们和他们对相同的源行进行了不同的更改,您会遇到合并冲突 =513=]。如果我们和他们触及“触及边缘”的两个线范围 (abut),您也会遇到合并冲突。 这意味着 Git 不确定如何组合这些更改。 作为程序员,您的工作是提供 正确的 组合.

这就是合并冲突的原因:Git 不确定进行更改 line-by-line 是否正确。如果您 没有 遇到合并冲突,Git 确保进行更改 line-by-line 是正确的,即使实际上不正确。 Git 在这里并不聪明:Git 遵循关于文本行的可笑的简单规则。

一旦您解决了合并冲突,或者如果 Git 没有合并冲突,Git 将像往常一样进行新的提交。这个新提交 M 的特别之处在于它有一个 secondL 而不是只有一个父 J :我们说 Git 应该合并的提交。 Git 像往常一样将新合并提交的哈希 ID 存储到当前分支名称中,所以我们得到:

          I--J
         /    \
...--G--H      M   <-- feature1 (HEAD)
         \    /
          K--L   <-- feature2

因为提交 M 向后连接到 both 提交 J L,提交K-L,以前只在feature2,现在在两个分支。提交 I-J-M 仍然只在 feature1 上,因为 L 仍然是 feature2 提示 提交,并且 Git只能向后工作,不能向前工作。所以从 L 我们倒退到 K,然后是 H,然后是 G,从未见过提交 I-J-M.

琐碎的合并

有时我们为 Git:

进行 非常容易 的合并
          H--I   <-- feature
         /
...--F--G   <-- main

我们 运行 git switch main 然后 git merge --no-ff feature (需要 --no-ff 才能使此行为像 GitHub 的“合并”按钮;它失败了Git 通常在此处使用的 short-cut)。 Git 找到共同的起始提交,但那是提交 G,这也是 main 的提示提交。所以一个完整的合并包括:

  • G 中的快照与 G 中的快照进行比较,看看有什么变化(无);
  • G 中的快照与 I 中的快照进行比较,看看发生了什么变化;
  • 不向 what-changed 添加任何内容,得到 what-changed;
  • 将这些更改应用到 G,获得 I 中相同的快照;
  • 进行新的提交并更新当前分支名称。

结果如下所示:

          H--I   <-- feature
         /    \
...--F--G------M   <-- main (HEAD)

(我再次调用了合并提交 M,用于 Merge;实际上它获得了一个唯一的哈希 ID,就像每次提交一样。)保证 M 中的快照与中的快照匹配I,因为 G-vs-G 从来没有要添加的任何更改,而 G-vs-I 总是有要添加的更改,导致I 快照。

如果我们不阻止 Git 这样做,Git 会将这个简单的合并变成 fast-forward 操作,这根本不是真正的合并。而不是新的提交,我们只是得到这个:

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

也就是说,Git 只是将名称 main 向前移动了两跳,就像 fast-forwarding a tape recorder。从字面上看,它只是一个将名称(在本例中为 main)向前拖动的结帐。 Git 将工作树中的 commit-G 文件换成 commit-I 文件。不需要合并,所以不会发生合并;不会发生合并冲突,因此不需要合并。

强制与 --no-ff 合并(没有快进)并且合并发生并且你得到一个新的合并提交。有时您想要这个(例如为了发布标记目的),有时您不在乎。要知道你是否想要它,你需要知道 Git 就是关于 提交 。新的提交会得到一个新的、唯一的哈希 ID,我们可以将其与其他所有提交区分开来。 “Re-use”像 fast-forward 这样的提交会,而我们 不会 得到新的提交,因此它与以前的旧提交相同。

Cherry-picking

假设我们有:

          I--J--K   <-- br1
         /
...--G--H
         \
          L   <-- br2 (HEAD)

我们突然意识到提交 J,比如说,修复了一个我们 需要在 br2 中修复的严重错误 。我们可以复制并粘贴该提交中的代码更改,但是如果该提交 完全修复了 错误,那么如果我们可以让 Git *比较之前的提交就更好了该提交-提交 I- 到该提交以查看发生了什么变化。也就是说,我们希望 Git 比较 IJ 中的快照,以查看哪些文件进行了哪些更改。

鉴于 Git 可以轻松做到这一点,我们让 Git 做到了。然后我们 Git 在提交 L 中对这些文件的当前版本应用相同的更改。我们 可以 Git 只是字面意思他对相同的行进行相同的更改,但是如果 thing.py 的修复在 他们的 版本的第 45 行,但是我们在顶部附近添加了一个新函数,会发生什么修复在 our 版本的 thing.py?

的第 70 行

好吧,我们可以 Git 更巧妙地应用修复程序。如果我们有 Git 差异提交 Ithing.py 版本与提交 Lthing.py 版本,那将显示我们添加的功能,并且第 45 行现在是第 70 行。因此 Git 将能够应用 他们的 更改到第 70 行,这是正确的行。

但请稍等。我们正在 Git 将快照 I 中的文件与 快照 J 中的文件进行比较,并且还 快照 L 中的文件。刚才我们在用 git merge 做什么? 我们对 git merge 做了完全相同的事情。 合并比较快照并合并更改

这正是 cherry-pick 的工作原理:它实际上是一个合并操作,合并基础被强制执行。我们正在 cherry-pick 从提交 J 开始。提交 J 的父项是提交 I。所以 Git 使用提交 I 作为 合并基础 ,提交 J 作为“他们的” branch-tip 提交,以及我们当前的提交 L 作为“我们的”提交。 Git 进行通常的比较,然后像往常一样合并工作。 不像 的合并是,一旦 Git 完成了 combining-work 部分,Git 就会生成一个 普通的 (non-merge) 提交:

          I--J--K   <-- br1
         /
...--G--H
         \
          L--N   <-- br2 (HEAD)

新提交 N 将对 L 进行与 J-vs-I 相同的 更改 I I,根据需要进行调整。 cherry-pick 代码使用 合并引擎 来实现“根据需要调整”部分。

Cherry-picking 因此在 cherry-pick 操作期间会发生合并冲突!这很正常,与 git merge 一样,没有什么可怕的:作为程序员,您只需要提供 正确的结果 。无论你告诉 Git 是正确的结果,Git 都会相信你:这就是进入新提交快照的内容。

如果您在 cherry-pick 编辑代码时必须修改代码,那么以后合并 NL 时很可能会遇到合并冲突,因为实例。那是因为我们进行了更改(对某些行集)并且 修改了更改 ,所以稍后,当 Git 去合并更改时,它会看到影响的略有不同的更改可以说是“同一条线”。稍后我们必须解决另一个合并冲突。但是,即使 不会 发生,也不能保证我们以后不必解决合并冲突。大多数情况下,我们只是让合并冲突按原样发生,然后手动修复它们。这是程序员工作的一部分。