是的,您可以这样做。在某些情况下,它甚至非常简单,因为它只是由git rebase 自动完成的。在某些情况下,这非常困难。一起来看看案例吧。
首先,绘制提交图至关重要,因为它几乎总是在 Git 中。为了达到这个目的,让我们从回顾 Git 基础开始。 (这是一个好主意,因为很多 Git 教程都跳过了基础知识,因为基础知识很无聊且令人困惑。:-))首先,让我们看看提交 是什么 并为你做了什么.
提交是什么,对你有什么作用
commit,在 Git 中,是一个完全具体的东西。我们可以查看任何实际的提交——它们中的大多数都很小——不是git show,它很喜欢它们,而是git cat-file -p,它显示了直接的原始内容(嗯,tree 对象需要较小调整,所以有时“大部分是原始的”)实际的 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 称为 root 提交。每个存储库都至少有一个(通常只有一个)根提交。1这就是必须停止操作的地方:我们(或 Git)不能再回头了。
在任何情况下,提交一旦做出,就永久不变。2这是因为它们的哈希 ID 是通过计算所有位的加密哈希而产生的在提交中(您使用git cat-file -p 看到的所有内容)。如果您更改任何内容,您将获得一个新的和不同的哈希 ID。每个哈希 ID 始终是唯一的。3
所以,让我们把它画出来,但不要打扰内部箭头;让我们只保留一个作为分支名称本身。
A--B--C <-- master
每次提交都会为您保存一个快照。当您将它们与它们的向后箭头组合在一起时,您会得到提交图。
1除外,即一个完全空的存储库,显然有no次提交。这就是你首先获得根提交的方式,通过在没有父节点的情况下进行提交。
2但是,一旦您对提交没有用处,它们可能会被垃圾收集。 Git 通常会无形地做到这一点。我们很快就会看到结果如何。
3不理会that web site behind the curtain! 严重的是,最近SHA-1 散列is not an immediate problem for Git 被破坏,但它确实有助于推动Git 切换到SHA-256。
添加一个新的提交
现在我们看到了图表在三个提交中的样子,让我们向master 添加一个新提交,看看它是如何工作的。首先我们会像往常一样git checkout master。这填充了 index 和 work-tree。然后我们将工作,git add 的东西,和 git commit。
(提醒:work-tree 是你工作的地方。当 Git 保存文件时,它会将它们列在不可发音的 hash-ID 名称下,并将它们压缩存储,从而保持它们以仅对 Git 本身有用的形式。要使用这些文件,你需要它们的正常形式,这就是工作树。同时 index 是你的位置和 Git 构建 next 提交。您处理工作树中的文件,然后运行 git add 将它们从工作树复制到索引中。您可以随时 git add时间:这只是再次从工作树更新索引。索引开始匹配当前提交,然后你修改它,直到你准备好进行新的提交。)
当你运行git commit 时,Git 会收集你的日志信息,然后:
- 将索引写为新的
tree:这是您保存的快照,基于您在工作树索引中替换的内容。新树拥有自己的哈希 ID。
- 写一个新的
commit对象,用这个新的树ID,当前提交的ID作为它的parent,你作为作者和提交者(现在作为时间戳),和您的日志消息。
第 2 步为我们的新提交获取 Git 一个新的哈希 ID;我们称之为D。由于新提交包含C 的哈希ID,D 指向C:
A--B--C <-- master (HEAD)
\
D
不过,Git 做的最后一件事是将 D 的 ID 写入当前 分支名称。如果当前分支是master,这使得master指向D:
A--B--C
\
D <-- master (HEAD)
如果我们 git checkout -b 一些 new 分支 first,但是——就在我们进行新的提交之前,也就是——然后看看我们新的开始设置: p>
A--B--C <-- branch (HEAD), master
branch 和 master 这两个名称都指向 C,但 HEAD 表示我们在分支 branch 上,而不是在 master 上。所以当我们创建D 并且Git 更新当前分支时,我们得到这个:
A--B--C <-- master
\
D <-- branch (HEAD)
这就是分支的增长方式。一个分支name只是指向一个分支的tip commit;构成图表的是提交本身。
此时值得停下来思考一下提交A-B-C。他们肯定在master 上。但他们也在分支branch。 在 Git 中,一次提交可能同时在多个分支上。 我们需要做的,通常是限制当我们让 Git 离开多远时告诉它:“让我从这个分支提示开始所有提交并向后工作。”
现在是激动人心的部分!
嗯,也许令人兴奋。 :-) 你已经用一堆新的提交创建了几个分支,所以让我们画一下:
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q <-- feature2
这里master结束于G,即commit G是master的tip。 feature1 以 L 结束,feature2 以 Q 结束。提交E-F-G 在所有三个 分支上。 P-Q 的提交仅在 feature2 上。提交 I-J-K 在 feature1 和 feature2 上。提交L 仅在feature1 上。
再次记住,这些字母代表大而丑陋的哈希 ID,其中实际的哈希 ID 编码提交中的所有内容:保存的树 和parent身份证。例如,L 需要 K 的哈希 ID。这种事情很重要,因为我们打算复制一些提交。
您描述想要做的是以某种方式移植提交 P 和 Q 以便它们位于 master 之上。如果有办法复制提交怎么办?原来有:叫git cherry-pick。
樱桃采摘
请记住,我们之前提到过提交是快照。这不是一组变化。但是现在我们希望提交是一组更改,因为提交P很像它的父提交K,但做了一些更改。毕竟,您是通过签出K,然后编辑文件并将新版本git adding 到索引中然后git committing 来创建P。
幸运的是,有一种简单的4 方法可以将快照转换为变更集,方法是比较 (git diff) 与其父提交。 git diff 的输出是最小的5 组指令:“从该文件中删除这一行,将这些其他行添加到该文件,等等。”将这些指令应用于K 中的树将把它变成P 中的树。
但是如果我们将这些指令应用到某个其他树会发生什么?事实证明,这通常“有效”。我们可以git checkoutcommit G——分支master的提示提交,但是我们使用不同的分支名称:
...--E--F--G <-- master, temp (HEAD)
\
I--J--K--L <-- feature1
\
P--Q <-- feature2
然后将差异应用到工作树。我们假设一切顺利,自动将结果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' <-- temp (HEAD)
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q <-- feature2
这只是两个git cherry-pick 步骤,当然还有创建临时分支。但是看看现在如果我们删除旧名称 feature2 并将 temp 更改为 feature2 会发生什么:
P'-Q' <-- feature2 (HEAD)
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q [abandoned]
现在看起来我们通过 git checkout -b feature2 master 制作了 feature2,然后从头开始编写 P' 和 Q'!这正是你想要的。
4简单,也就是说,在string-to-string edit problems 上完成任意数量的硕士和/或博士论文之后。
5在某种意义上“最小”,并且可以通过不同的差异算法进行一些调整。最小化编辑距离对于压缩很重要,但实际上对于正确性并不重要。但是,当我们将编辑指令应用到某个 other 树时,最小性和确切的指令就变得很重要了。
Git 的 rebase 是自动樱桃挑选加上分支标签移动
我们可以使用以下方法一次性完成以上所有操作:
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)。
我们不能用 just git rebase 做到这一点。我们可能需要一个显式的git cherry-pick 来复制J。但这是 Git,所以有很多方法可以做到这一点。
首先,让我们看一下显式樱桃挑选方法。我们将继续创建一个新分支并挑选J:
git checkout -b temp
git cherry-pick <hash-ID-of-J>
现在我们有了:
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]
获得相同结果的另一种方法
假设我们让git rebase 复制I-J-K-P-Q,而不是仅仅复制P-Q。这实际上可能更容易:
git checkout feature2
git rebase master
这一次我们不需要--onto:master 告诉 Git both 哪个承诺省略和 将副本放在哪里。我们省略了提交G 和更早的版本,我们在G 之后复制。结果是:
I'-J'-K'-P'-Q' <-- feature2
/
...--E--F--G <-- master
\
I--J--K--L <-- feature1
\
P--Q [abandoned]
现在我们复制了太多个提交,但现在我们运行:
git rebase -i master
这为每个提交 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' <-- feature2
/
...--E--F--G--J' <-- master
\
I--J--K--L <-- feature1
(我们也可以通过将 J 明确地 J 转换为 master 来到达这里,然后在没有任何花哨的步法的情况下重新定位 feature2。)所以现在我们想复制feature1 的提交,在J' 之后添加它们,并删除 J。
我们可以使用另一个git rebase -i 来执行此操作,这让我们可以显式删除原始提交J。但我们不必必须。好吧,我们不必,大部分时间。相反,我们只是运行:
git checkout feature1
git rebase master
这告诉 Git 它应该将 I-J-K-L 视为副本的候选对象,并将副本放在 J' 之后(master 现在指向的位置)。但是——这就是神奇之处——git rebase 仔细观察master 上的所有提交6 不在feature1 上的提交(这些被称为 upstream 提交,至少在一些文档中)。在这种情况下,这就是J' 本身。对于每个这样的提交,Git 都会将提交与其父级(例如 git cherry-pick)进行比较,并将结果转换为 patch ID。 每个候选提交也是如此。如果其中一个候选者 (J) 的补丁 ID 与上游提交之一相同,Git 从列表中删除候选者!
因此,只要J 和J' 具有相同的补丁ID,Git 自动 丢弃J,因此最终结果是:
P'-Q' <-- feature2
/
...--E--F--G--J' <-- master
\
I'-K'-L' <-- feature1
这正是我们想要的。
6全部,即合并除外。从字面上看,Rebase 不能 复制合并——一个新的合并与原来的合并有不同的父集,并且樱桃选择首先“撤消”了一个合并——所以默认情况下它会完全跳过它们。合并不会获得分配的补丁 ID,也不会被从集合中剔除,因为它们从未在集合中。对包含合并的图片段进行变基通常是个坏主意。
Git 确实有一个尝试这样做的模式。这种模式重新执行合并(因为它必须:我把处理细节作为练习)。但是这里有很多危险,所以通常最好不要这样做。我之前说过,git rebase 应该默认为“保留”合并,但如果存在 合并,则通过错误输出,需要“是的,继续尝试重新创建合并”标志,或“展平并移除合并”标志以继续。
不过,它没有,所以由你来绘制图表并确保你的 rebases 有意义。
当变基出错时:合并冲突
任何时候你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 本质上是一个 copy 操作,所以您必须考虑谁可能仍然拥有 original 提交。由于提交可以在 许多 分支上,因此可能是 您 拥有原件。例如,当我们同时拥有 J 和 J' 时,我们看到了这种情况。
有时——甚至是经常——这可能没什么大不了的。有时是的。 如果所有额外的副本仅在您自己的存储库中,您可以自行解决所有这些问题。但是,如果您发布了(推送或让其他人从您那里获取)您的一些提交,会发生什么?特别是,如果其他存储库有这些原始提交,以及它们的原始哈希 ID,该怎么办?如果您已经发布了原始提交,则必须告诉拥有它们的其他所有人:“嘿,我要放弃原始提交,我在其他地方有闪亮的新副本。”你必须让他们做同样的事情,否则就忍受额外的提交副本。
额外的提交有时是无害的。对于合并来说尤其如此,因为git merge 努力只获取任何给定更改的一个副本(尽管 Git 不能总是靠自己完全正确,因为每个更改 - 每个 git diff输出——取决于上下文和其他变化,最小编辑距离算法有时会自己出错,选择错误的“最小变化”)。但是,即使它们没有破坏 tree,它们也会弄乱提交历史。很难预测这是否以及何时会成为问题。
总结
对于您的目标,git rebase 是一个强大的工具。使用它时需要小心,最重要的是要记住它复制提交,然后放弃——或试图放弃——原件。这可能会以多种方式出错,但最糟糕的情况往往发生在其他人已经拥有您原始提交的副本时,这通常意味着“当您发布(推送)它们时”。
绘制图表会有所帮助。每个人都应该养成画图的习惯,和/或使用git log --graph(从“狗”那里获得帮助:git log --all --decorate --oneline --graph,All Decorate Oneline Graph)和/或图形浏览器,如 gitk(尽管我个人一般讨厌 GUI :-))。不幸的是,“真实”的图表很快就会变得非常混乱。 Git 的内置 log --graph 在分离鼠巢图方面做得很差。有很多专门的工具可以解决这个问题,其中一些内置在 Git 中,但通过大量练习阅读图表肯定会有所帮助。