是的,你可以做到这一点。在某些情况下,这甚至非常容易,因为它只需通过git rebase
自动完成。在某些情况下,这非常困难。让我们来看看这些情况。
首先,在Git中,像几乎所有情况一样,绘制提交图形至关重要。为了达到这个目的,让我们从回顾Git基础知识开始。(这是一个好主意,因为很多Git教程会直接跳过基础知识,因为基础知识枯燥而令人困惑。:-)) 首先,让我们看一下提交是什么以及它对你有什么作用。
提交是什么以及它对你有什么作用
在Git中,提交是一件非常具体的事情。我们可以查看任何实际提交——大多数提交都非常小,不能使用git show
来查看,因为它们会将其装饰得很漂亮,但可以使用git cat-file -p
来查看,该命令显示实际Git对象的即时原始内容(树对象需要进行微调,因此有时是“基本原始”的):
$ git cat-file -p 3bc53220cb2dcf709f7a027a3f526befd021d858
tree 5654dad720d5b0a8177537390575cd6171c5fc50
parent 3e5c63943d35be1804d302c0393affc4916c3dc3
author Junio C Hamano <gitster@pobox.com> 1488233064 -0800
committer Junio C Hamano <gitster@pobox.com> 1488233064 -0800
First batch after 2.12
Signed-off-by: Junio C Hamano <gitster@pobox.com>
这就是一个完整的提交。它的名称——唯一标识该提交,从现在到永远都是如此——是3bc5322...
(一个人类如果可以避免的话,绝不想处理的大而丑陋的哈希ID)。它存储了几个更大更丑陋的哈希ID。其中一个是用于树,有些数字(通常只有一个)是用于父项。它有一个作者(姓名、电子邮件地址和时间戳)和提交者,通常是相同的;还有一个日志消息,可以随意编写。
与提交相关联的树是源代码树快照。它是整个东西,而不是一组更改。(在下面,Git 确实使用压缩技巧,但树的哈希ID会得到文件的哈希ID,而这些文件是完整的文件,而不是某种奇怪的压缩文件。)让Git提取该树,您就可以获取所有文件。
因为每个提交都存储了一个父哈希 ID,所以我们可以从最近的提交开始向后工作。这就是 Git:向后。我们从最近的提交的哈希 ID 开始,Git 会为我们保存在分支名称中。我们说这个分支名称指向该提交:
<--C <--master
master
指向提交
C
。 (我使用单个字母名称而不是复杂的哈希 ID,这限制了提交数量最多为 26 个,但更加方便。)然而,提交
C
中有另一个哈希 ID,因此
C
指向另一个提交。那就是
C
的父提交,也就是
B
。当然,
B
同样指向另一个提交,但假设我们的存储库仅总共有三个提交,因此
B
指回到
A
,但
A
是第一个提交。
由于 A
是第一个提交,所以它不能有父提交。 因此它没有:它不需要再向后指。 我们称 A
为根提交。每个存储库至少有一个(通常只有一个)根提交。1 这就是操作必须停止的地方:我们(或 Git)无法再向后退。
无论如何,一旦提交,就是永久和不可更改的。
2这是因为它们的哈希ID是通过计算提交内部所有位(您使用
git cat-file -p
看到的所有内容)的加密哈希值来生成的。如果您更改任何内容,则会获得新的和不同的哈希ID。每个哈希ID始终是唯一的。
3
因此,让我们画出这个过程,但不要关注内部箭头;让我们只保留分支名称本身的箭头。
A
每次提交都会为您保存一个快照,只有当您将它们与它们的反向箭头一起组装时,才会得到“提交图”。
1除了完全空的存储库(显然没有提交),可以获得根提交。这是通过进行没有父提交的提交来完成的。
2但是,一旦您不再需要提交,它们就可以被垃圾回收。Git通常会以隐形方式执行此操作;我们很快将看到这是如何实现的。
3请勿注意幕后的网站! 但是,SHA-1哈希的最近破坏对Git并非立即问题,但它确实有助于推动Git切换到SHA-256。
添加新的提交
现在我们已经看到了具有三个提交的图形,让我们向master
添加一个新的提交,以了解其工作原理。首先像往常一样执行git checkout master
。这将填充索引和工作树。然后我们会进行工作、git add
、和git commit
操作。
(提醒:工作树是您进行工作的地方。当Git保存文件时,它们会列在难以发音的哈希ID名称下,并将它们压缩,因此仅对Git本身有用。为了使用这些文件,您需要它们处于正常形式,即工作树。同时,索引是您和Git构建下一个提交的地方。您在工作树中处理文件,然后运行git add将它们从工作树复制到索引中。您可以随时git add:这只是再次从工作树更新索引。索引起初与当前提交匹配,然后您修改它,直到准备好进行新提交。)
当您运行git commit时,Git会收集您的日志消息,然后:
- 将索引作为新的
tree
写出:这是您保存的快照,基于您从工作树中替换的内容。新的树有自己的哈希 ID。
- 写出一个新的
commit
对象,带有此新树 ID、当前提交的 ID 作为其 parent
,您作为作者和提交者(现在也是时间戳),以及您的日志信息。
第二步为我们的新提交获取了 Git 新的哈希 ID;让我们称其为 D
。由于新的提交中有 C
的哈希 ID,因此 D
指向 C
:
A
\
D
Git 的最后一步是将
D
的 ID 写入当前的
分支名称。如果当前分支是
master
,则这将使
master
指向
D
。
A
\
D <
如果我们在创建新提交之前,先使用
git checkout -b
命令创建一个
新的分支,那么我们的起始设置如下:
A
两个名字,branch
和master
,都指向C
,但是HEAD
表示我们在分支branch
上,而不是master
上。因此,当我们创建D
并且Git更新当前分支时,我们得到以下结果:
A
\
D <
这是分支增长的方式。一个分支名称只是指向分支的尖端提交;形成图形的是提交本身。
值得停下来思考一下提交A-B-C。它们肯定在master上。但它们也在branch上。在Git中,一个提交可以同时存在于许多分支上。我们经常需要做的是限制Git回溯的范围,告诉它:“从这个分支尖端开始,向后获取所有提交。”
现在是令人兴奋的部分!好吧,可能有点激动人心。:-) 你已经创建了几个带有一堆新提交的分支,让我们画出来:
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q <-- feature2
在这里,
master
在
G
结束,也就是说,提交
G
是 master 的顶端。
feature1
在
L
结束,
feature2
在
Q
结束。提交
E-F-G
在三个分支上都有。提交
P-Q
只在
feature2
上。提交
I-J-K
在
feature1
和
feature2
上都有。提交
L
只在
feature1
上。
再次提醒,这些字母代表着大而丑陋的哈希 ID,实际的哈希 ID 编码了提交中的所有内容:保存的树和父级 ID。因此,例如,L
需要 K
的哈希 ID。这种事情很重要,因为我们打算复制一些提交。
你所描述的想法是以某种方式移植提交记录
P
和
Q
,使它们位于
master
之上。如果有一种复制提交记录的方法会怎样呢?事实证明确实有这样一种方法:它被称为
git cherry-pick
。
樱桃拣选
请记住我们之前提到过的一个提交记录就是快照。它不是一组更改。但现在我们希望提交记录是一组更改,因为提交记录
P
很像其父提交记录
K
,但进行了一些更改。毕竟,您是通过将
K
检出,然后编辑文件并将新版本添加到索引中,最后进行
git commit
来创建
P
的。
幸运的是,有一种简单的方法可以将快照转换为变更集,即通过将其与其父提交进行比较(git diff
)。 git diff
的输出是一组最小指令:“从此文件中删除此行,在该文件中添加这些其他行等”。将这些指令应用于K
中的树将使其变成P
中的树。
但是,如果我们将这些指令应用于其他树会发生什么?事实证明,这通常“只是起作用”。 我们可以git checkout
提交G
——分支master
的末端提交,但让我们使用不同的分支名称:
...
\
I
\
P
然后将差异应用于工作树。我们假设这一步顺利,自动将结果git add
到索引中,并在复制提交P
的日志消息时git commit
。我们将称新提交为P'
,意思是“像P
,但具有不同的哈希ID”(因为它具有不同的树和不同的父级):
P' <-- temp (HEAD)
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q <-- feature2
现在我们用
Q
重复这个过程。我们运行
git diff P Q
将
Q
转换为更改,将这些更改应用于
P'
,并将结果作为新的
Q'
提交:
P'-Q' <
/
...
\
I
\
P
这只是两个“git cherry-pick”步骤,当然还需要创建临时分支。但是现在如果我们删除旧名称“feature2”,并将“temp”更改为“feature2”,会发生什么呢?
P'-Q' <
/
...
\
I
\
P
现在看起来我们是通过执行
git checkout -b feature2 master
,然后从头编写
P'
和
Q'
来创建了
feature2
!这正是你想要的。
4简单,也就是在任何数量的硕士和/或博士论文中研究字符串编辑问题后。
5在某种程度上是“最小的”,并且可以通过不同的差异算法进行微调。 最小化编辑距离对于压缩非常重要,但实际上并不重要正确性。 但是,当我们将编辑说明应用于其他树时,最小化和确切的说明真的开始变得重要。
Git的rebase
是自动化的cherry-pick加上分支标签移动
我们可以使用以下命令一次性完成上述操作:
git checkout feature2
git rebase --onto master feature1
我们在这里使用
feature1
作为告诉 Git 停止复制的方式。回顾一下在原始提交被弃用之前的图表。如果我们告诉 Git 从
feature1
开始向后工作,那么它就会识别出提交
L
、
K
、
J
、
I
、
G
、
F
等等。这些是我们明确地说不要复制的提交:也就是位于分支
feature1
上的提交。
与此同时,我们想要复制的提交是那些位于
feature2
上的提交:
Q
、
P
、
K
、
J
等等。但是,一旦我们遇到任何禁止的提交,我们就会停止复制,因此我们只会复制
P-Q
提交。
我们告诉
git rebase
要复制到的位置是或者说“紧接着”
master
的末端,也就是把提交复制到
G
后面。
我会尽力为您翻译。这段内容是关于Git rebase的,它能够帮助我们完成所有工作,非常容易。但是可能会出现一些问题。解决这些问题可以采取以下措施。假设我们从以下内容开始:
...--E--F--G <-- master (HEAD)
\
I--J--K--L <-- feature1
\
P--Q <-- feature2
我们希望将
feature2
基于
master
进行变基,跳过大部分的
feature1
,但事实证明我们也需要
J
提交中所做的更改。
我们不需要
I
,
K
或
L
,只需要
J
(当然还有
P
和
Q
)。
我们无法仅使用
git rebase
来完成此操作。我们可能需要显式使用
git cherry-pick
复制
J
。但这是Git,所以有很多方法可以做到这一点。
首先,让我们看一下显式cherry-pick方法。我们将继续创建一个新分支并cherry-pick
J
:
git checkout -b temp
git cherry-pick <hash-ID-of-J>
Now we have:
J' <-- temp (HEAD)
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q <-- feature2
现在我们可以像以前一样移植
P-Q
。我们只需要更改
--onto
指令即可。
git checkout feature2
git rebase --onto temp feature1
结果是:
这里是结果。
P'-Q' <-- feature2 (HEAD)
/
J' <-- temp
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q [abandoned]
我们已经不再需要 temp
了,所以我们可以直接使用 git branch -d temp
来整理我们的绘图:
J'-P'-Q' <-- feature2 (HEAD)
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q [abandoned]
另一种获得相同结果的方法
假设我们不仅复制P-Q
,而是让git rebase
复制I-J-K-P-Q
。这可能更容易实现:
git checkout feature2
git rebase master
这次我们不需要使用
--onto
:
master
告诉 Git 要排除哪些提交以及将副本放在哪里。 我们排除提交
G
及之前的提交,并在其后进行复制。 结果如下:
I'-J'-K'-P'-Q' <-- feature2
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q [abandoned]
现在我们有太多的复制提交,但现在我们运行:
git rebase -i master
这给我们每个提交的“pick”行,包括
I'
、
J'
、
K'
、
P'
和
Q'
。我们
删除I'
和
K'
的那些行。现在Git再次复制,得到:
J''-P''-Q'' <-- feature2
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q [abandoned]
这正是我们想要的(原始副本仍然在那里,像原始的-P-Q
一样被抛弃,但它们在那里的时间很短暂,谁会在意呢?:-))。当然,我们可以让第一个git rebase
使用-i
并删除pick
行,只需一步就可以获得J'-P'-Q'
的副本。
消除冗余提交
就目前而言,这还不错,但现在有一个J
和一个J'
。实际上,这并没有什么问题——你可以保持这种情况,并像这样合并,没有真正的伤害。但你可能希望先将J'
作为master
的一部分,然后再分享它。
同样,有多种方法可以做到这一点。我想举一个特定的例子,因为git rebase
中有一些魔法。
假设我们已经完成了
feature2
的变基操作,现在我们将完全删除废弃的提交,就像Git最终进行垃圾回收时一样(注意:默认情况下,您至少有30天的时间可以更改想法)。
J'-P'-Q' <-- feature2
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
现在您可以将
master
快进以包括
J'
:
git checkout master
git merge --ff-only <hash-id-of-J'>
这将移动
标签,而不改变
提交记录图形。为了更容易地在ASCII文本中绘制,我将把
J'
向下移动一行:
P'-Q' <
/
...
\
I
我们也可以通过显式地将 J
cherry-pick 到 master
,然后在没有任何花哨的操作下重新设置 feature2
来到达这里。所以现在我们想要复制 feature1
的提交,在 J'
之后添加它们,并删除 J
。
我们可以使用另一个 git rebase -i
完成此操作,该命令允许我们显式删除原始提交 J
。但我们不一定需要这样做。大多数情况下,我们不需要这样做。相反,我们只需运行:
git checkout feature1
git rebase master
这告诉 Git 应该将
I-J-K-L
视为复制的候选项,并将复制放在
J'
之后(此时
master
指向它)。但是,这里有个神奇的地方——
git rebase
会仔细查看所有不在
feature1
上但在
master
上的提交记录(至少在一些文档中称为“上游”提交记录)。在这种情况下,就是
J'
本身。对于每个这样的提交记录,Git 将提交记录与其父提交记录进行差异比较(类似于
git cherry-pick
),并将结果转换为补丁 ID。对于
每个候选提交记录,也执行相同的操作。如果其中一个候选项(
J
)与其中一个上游提交记录具有
相同的补丁 ID,则 Git 会
从列表中消除该候选项!因此,只要
J
和
J'
具有相同的补丁 ID,Git 就会
自动删除 J
,以便最终结果为:
P'-Q' <
/
...
\
I'-K'-L' <-- feature1
这正是我们想要的。
除了合并之外,rebase可以复制所有内容。但是合并是不可能被复制的,因为新合并有一个不同的父级设置,而cherry picking“撤销”了原始合并。因此,默认情况下,它完全跳过它们。合并不会分配补丁ID,并且不会从集合中取出,因为它们从未在集合中。重新定位包含合并的图形片段通常是不明智的。
Git确实有一种模式来尝试做到这一点。该模式重新执行合并(因为必须这样做:我留给你练习细节)。但是这里存在许多危险,因此最好根本不要这样做。我以前说过,可能默认情况下git rebase应该保留合并,但通过出现错误如果有合并,则需要一个“是,继续尝试重新创建合并”的标志,或者一个“扁平化和删除合并”的标志来进行。
然而,它没有,所以由您来绘制图形并确保您的rebase是有意义的。
当rebase出现问题:合并冲突
每当你使用git rebase
命令来重组一些提交记录时,都有可能会遇到合并冲突。特别是当你从一个长链中拿出一段提交记录时,这种情况就更加普遍了:
o--...--B--1--2--3--4--...--o <-- topic
/
...o--*--o--o--o--T <-- develop
如果我们想要将提交1-4“移动”(复制,然后删除)到
develop
中,很有可能其中一些或全部这四个提交依赖于其他在它们之前的顶行提交(
B
及更早的提交)。当发生这种情况时,我们往往会遇到合并冲突,有时还会有很多。Git最终会将提交
1
的副本视为三方合并操作,将从
B
到
1
的更改与从
B
到
T
的更改合并。从
B
到
T
的变化可能非常复杂,并且在上下文之外可能看起来不合理,因为我们必须“向后”穿越提交
B
之前的提交直到
*
,然后“向前”直到
T
。
你需要自己决定如何以及是否明智地进行此操作。
当rebase出现问题:其他人仍在使用原始提交
由于rebase本质上是一种复制操作,因此您必须考虑谁可能仍然拥有原始提交记录。由于提交记录可以在许多分支上,您可能拥有原始提交记录,就像我们同时拥有J和J'时所看到的那样。
有时候,甚至经常这可能不是一个大问题。但有时候会是。如果所有额外的副本
仅在您自己的存储库中,则可以自行解决所有这些问题。但是,如果您已经发布(推送或让其他人从您那里获取)了其中一些提交记录,则会发生什么情况?特别是,如果其他存储库具有这些原始提交记录及其原始哈希ID,会怎样?如果您已经发布了原始提交记录,则必须告诉所有拥有这些提交记录的其他人:“嘿,我正在放弃原始提交记录,在其他地方拥有全新的副本。” 您必须让他们做同样的事情,否则就要忍受额外的提交副本。
额外的提交有时是无害的。这在合并方面尤其如此,因为git merge
会努力仅获取给定更改的一个副本(尽管Git不能总是自己完全正确地做到这一点,因为每个更改-每个git diff
输出-都取决于上下文和其他更改,最小编辑距离算法有时会出错,选择错误的"最小更改")。即使它们不破坏树,它们也会使提交历史混乱。何时可能成为问题很难预测。
摘要
对于您的目标,git rebase
是一个强大的工具。在使用它时需要注意一些细节,最重要的是要记住,它复制提交,然后放弃-或试图放弃-原始提交。这可能会以几种方式出错,但最糟糕的情况通常发生在其他人已经拥有您的原始提交的副本时,这通常意味着“当您已经发布(推送)它们”。
绘制图表可以帮助理解。每个人都应该养成绘制图表的习惯,或者使用git log --graph
(从“a dog”获取帮助:git log --all --decorate --oneline --graph
,All Decorate Oneline Graph),或者像gitk
这样的图形化浏览器(尽管我个人总体上不喜欢GUI :-))。不幸的是,“真正的”图表很快变得非常杂乱。 Git内置的log --graph
在分离混乱图表方面做得很差。有很多临时工具来处理这个问题,其中一些内置于Git中,但是有很多练习阅读图表的经验肯定会有所帮助。