如何在 2 个功能分支之间共享代码
How to share code between 2 feature branches
假设我在 feature1 branch
中写了一个 method
,一段时间后我意识到我在另一个 feature2 branch
中也需要这段代码。
所以我只是 copy/paste 从 feature1
到 feature2
的代码,并且工作同时在两个分支上继续进行。我无法将 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 “对象”的大数据库中。内部有四种对象:blob、tree、commit、准确地说是带注释的标签。但在大多数情况下,人类只处理提交对象。这些存储我们的提交,并且由于提交是 Git 存储库中的“工作单元”,可以说——因为 Git 存储 提交——这就是我们处理 Git.
的级别
不幸的是,对于我们人类来说,Git 的提交是有编号的,大而难看的 random-looking 数字没有韵律或原因;1 他们看起来例如 1bcf4f6271ad8c952739164d160e97efd579424f
。人类无法处理这些,所以我们就是不处理。 Git 通过添加一个较小的 names 数据库,与提交和其他对象的大数据库分开,包括分支和标签名称。像 refs/heads/main
或 refs/heads/master
这样的名称是 分支 名称,并且会变成丑陋的大哈希 ID for 我们。所以我们可以给 Git 一个分支名称,然后 Git 会找出正确的哈希 ID,并用它来找出正确的提交。
这就是我们可以使用 feature1
和 feature2
这样的名称的方式和原因。这些名称对 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
.
好吧,我们刚才说过 分支名称 如 main
或 feature1
存储哈希 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 feature1
或 git 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 main
或 git 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
;或
- 提交
J
,feature1
的提示提交;或
- 提交
I-J
,目前仅 feature1
;或
- 提交到
J
,包括 H
和其他 main
; 的提交
或者其他一些东西。 branch 这个词在 Git 中被严重滥用了,更具体一点通常是个好主意(你可以说“分支名称”或“提示提交”或例如,“提交集”。
正在合并
当我们有 发散 像上面那样的分支时——feature1
和 feature2
从提交 H
发散并在提交 [=80 处结束=] 和 L
——我们以后经常想 合并工作 。即,给定:
I--J <-- feature1
/
...--G--H
\
K--L <-- feature2
我们希望得到一个单一的提交 M
,它有一组文件作为它的快照:
- 与
H
中的相似,除了 ...
- 他们有我们在
I
和 J
和 中所做的更改
- 他们也有我们在
K
和 L
中所做的更改。
我们经常在 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 更改的更改集。完全没有改变的文件——从 H
到 J
保持不变的文件——没有被提及。如果您 运行 git diff
提交 H
和 J
,这就是您将看到的内容,这就是 git merge
将看到的内容。
从 H
到 J
、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
的特别之处在于它有一个 second 父 L
而不是只有一个父 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 比较 I
和 J
中的快照,以查看哪些文件进行了哪些更改。
鉴于 Git 可以轻松做到这一点,我们让 Git 做到了。然后我们 Git 在提交 L
中对这些文件的当前版本应用相同的更改。我们 可以 Git 只是字面意思他对相同的行进行相同的更改,但是如果 thing.py
的修复在 他们的 版本的第 45 行,但是我们在顶部附近添加了一个新函数,会发生什么修复在 our 版本的 thing.py
?
的第 70 行
好吧,我们可以 Git 更巧妙地应用修复程序。如果我们有 Git 差异提交 I
的 thing.py
版本与提交 L
的 thing.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 编辑代码时必须修改代码,那么以后合并 N
和 L
时很可能会遇到合并冲突,因为实例。那是因为我们进行了更改(对某些行集)并且 修改了更改 ,所以稍后,当 Git 去合并更改时,它会看到影响的略有不同的更改可以说是“同一条线”。稍后我们必须解决另一个合并冲突。但是,即使 不会 发生,也不能保证我们以后不必解决合并冲突。大多数情况下,我们只是让合并冲突按原样发生,然后手动修复它们。这是程序员工作的一部分。
假设我在 feature1 branch
中写了一个 method
,一段时间后我意识到我在另一个 feature2 branch
中也需要这段代码。
所以我只是 copy/paste 从 feature1
到 feature2
的代码,并且工作同时在两个分支上继续进行。我无法将 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 “对象”的大数据库中。内部有四种对象:blob、tree、commit、准确地说是带注释的标签。但在大多数情况下,人类只处理提交对象。这些存储我们的提交,并且由于提交是 Git 存储库中的“工作单元”,可以说——因为 Git 存储 提交——这就是我们处理 Git.
的级别不幸的是,对于我们人类来说,Git 的提交是有编号的,大而难看的 random-looking 数字没有韵律或原因;1 他们看起来例如 1bcf4f6271ad8c952739164d160e97efd579424f
。人类无法处理这些,所以我们就是不处理。 Git 通过添加一个较小的 names 数据库,与提交和其他对象的大数据库分开,包括分支和标签名称。像 refs/heads/main
或 refs/heads/master
这样的名称是 分支 名称,并且会变成丑陋的大哈希 ID for 我们。所以我们可以给 Git 一个分支名称,然后 Git 会找出正确的哈希 ID,并用它来找出正确的提交。
这就是我们可以使用 feature1
和 feature2
这样的名称的方式和原因。这些名称对 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
.
好吧,我们刚才说过 分支名称 如 main
或 feature1
存储哈希 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 feature1
或 git 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 main
或 git 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
;或 - 提交
J
,feature1
的提示提交;或 - 提交
I-J
,目前仅feature1
;或 - 提交到
J
,包括H
和其他main
; 的提交
或者其他一些东西。 branch 这个词在 Git 中被严重滥用了,更具体一点通常是个好主意(你可以说“分支名称”或“提示提交”或例如,“提交集”。
正在合并
当我们有 发散 像上面那样的分支时——feature1
和 feature2
从提交 H
发散并在提交 [=80 处结束=] 和 L
——我们以后经常想 合并工作 。即,给定:
I--J <-- feature1
/
...--G--H
\
K--L <-- feature2
我们希望得到一个单一的提交 M
,它有一组文件作为它的快照:
- 与
H
中的相似,除了 ... - 他们有我们在
I
和J
和 中所做的更改
- 他们也有我们在
K
和L
中所做的更改。
我们经常在 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 更改的更改集。完全没有改变的文件——从 H
到 J
保持不变的文件——没有被提及。如果您 运行 git diff
提交 H
和 J
,这就是您将看到的内容,这就是 git merge
将看到的内容。
从 H
到 J
、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
的特别之处在于它有一个 second 父 L
而不是只有一个父 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 比较 I
和 J
中的快照,以查看哪些文件进行了哪些更改。
鉴于 Git 可以轻松做到这一点,我们让 Git 做到了。然后我们 Git 在提交 L
中对这些文件的当前版本应用相同的更改。我们 可以 Git 只是字面意思他对相同的行进行相同的更改,但是如果 thing.py
的修复在 他们的 版本的第 45 行,但是我们在顶部附近添加了一个新函数,会发生什么修复在 our 版本的 thing.py
?
好吧,我们可以 Git 更巧妙地应用修复程序。如果我们有 Git 差异提交 I
的 thing.py
版本与提交 L
的 thing.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 编辑代码时必须修改代码,那么以后合并 N
和 L
时很可能会遇到合并冲突,因为实例。那是因为我们进行了更改(对某些行集)并且 修改了更改 ,所以稍后,当 Git 去合并更改时,它会看到影响的略有不同的更改可以说是“同一条线”。稍后我们必须解决另一个合并冲突。但是,即使 不会 发生,也不能保证我们以后不必解决合并冲突。大多数情况下,我们只是让合并冲突按原样发生,然后手动修复它们。这是程序员工作的一部分。