如何“撤销”rebasing提交
这个问题在这里已经有了答案:
这里有一堆有点棘手的概念被卷成一个紧密卷曲的头发。 让我们取笑他们,从一个提交的“真实姓名”开始。 每个提交都只有其中一个,1就是它的哈希ID,它是像238e487ea943f80734cc6dad665e7238b8cbc7ff
这样的大的丑陋的40个字符的东西238e487ea943f80734cc6dad665e7238b8cbc7ff
。
1Git最终从SHA-1过渡到其中含有更多位的东西可能导致无效化:提交将至少暂时具有两个真实姓名,这在不太可能但必然的可能事件中变得尴尬,其中一个新的更大散列提交在其较小的SHA-1散列中发生冲突。 但在这里我们不用担心。 :-)
哈希ID是唯一的
给定一个哈希ID,Git可以找到提交(或其他对象)并提取其内容。 给定一些内容,Git可以计算哈希ID。 所以它们之间有一对一的映射关系:一个散列键只表示一个值,而一个特定的值总是由同一个散列键表示。 这允许Git通过git fetch
和git push
在库之间传输提交(和其他对象)。
提交的哈希ID包括作者和消息以及时间戳
我们来看看其中的一个提交:
$ git cat-file -p HEAD | sed 's/@/ /'
tree e97e9653eed972b4521e7f562e40f61f74eeb76c
parent 6e6ba65a7c8f8f9556ec42678f661794d47f7f98
author Junio C Hamano <gitster pobox.com> 1503813601 -0700
committer Junio C Hamano <gitster pobox.com> 1503813601 -0700
The fifth batch post 2.14
Signed-off-by: Junio C Hamano <gitster pobox.com>
这是提交238e487ea943f80734cc6dad665e7238b8cbc7ff
的全部内容,并且计算commit 293
(293是文本的长度)的SHA-1校验和加上该哈希中的原始文本结果:
$ python
...
>>> import hashlib
>>> import subprocess
>>> p = subprocess.Popen('git cat-file -p HEAD', stdout=subprocess.PIPE, shell=True)
>>> text = p.stdout.read()
>>> len(text)
293
>>> s = 'commit {} '.format(len(text)).encode('utf8')
>>> s += text
>>> hashlib.sha1(s).hexdigest()
'238e487ea943f80734cc6dad665e7238b8cbc7ff'
(上面的应该可以在py2k和py3k上运行,但是在运行时会稍微修补一下,所以可能会出现小故障)。
无论如何,请特别注意parent
行, author
和committer
行。 parent
行提供此提交的父项的哈希ID。 其他两行有一个名字,一个电子邮件地址,一个长十进制数字和一个奇怪的-0700
,实际上是一个时区偏移量(在这种情况下GMT格林威治标准时间西部7小时)。 大小数加上此时区偏移量是提交的时间戳。
tree
行给出了包含与此提交一起提交的源的tree
对象的Git哈希ID。 显然,文本的其余部分只是提交消息本身。 拥有时间戳意味着由同一个人使用同一个源树和同一个提交信息完成的另外两个相同的提交通常会导致两个不同的提交,因为没有人每秒提交一个以上的提交。
2Scripts可以轻易违反此规则,并可能产生惊喜。
分支名称只是指向提交,其他提交也是如此
由于每次提交都有作为其核心数据的一部分的其父提交的哈希ID,因此只需将一个Git哈希ID存储在诸如master
或develop
类的分支名称中即可。 该名称映射到哈希ID,该哈希ID标识或“指向”分支的提示提交。 该特定的提交在其内部具有其父提交的哈希ID:提示提交指向其父代。 该父母提交回到自己的父母。 它是这个向后指针链,从分支名称标识的分支提示提交开始,构成一个Git分支:
A <-B <-C <-- master
在这个微小的3-commit存储库中,名称master
标识提交C
; C
指向B
; B
指向A
由于A
是有史以来第一次提交,所以它没有任何意义。 这个技术术语是一个根提交,当我们(或Git)使用提交时,我们通常会遵循向后指针,直到它们在根上运行。
所有这些意味着没有任何提交(也没有任何Git对象)可以改变
我们声称任何Git对象提交,树,注释标记或“blob”(文件)的哈希ID都是唯一的,并且严格依赖于对象内的数据。 这个说法是真的; Git通过拒绝添加一个新的对象来强制执行它,通过某种机会或邪恶的目的,它具有与某些现有对象相同的散列。 实际上,在提交中更改或添加或删除一个字符会产生一个全新的,不同的散列; 甚至只是复制提交往往会因时间戳而产生新的,不同的哈希。
从某种意义上说,这使得rebase变得不可能。 然而, git rebase
存在,所以它必须以某种方式可能。 诀窍在于如何。
重新分配的目的
有几个原因可以使用git rebase
,但最常见的仅仅是做到这一点:“重新设置”一些提交。 让我们绘制另一个图,如最小的存储库,但添加一个分支:
A--B--C <-- master
D--E <-- develop
这些提交中的箭头都是向后的(按照定义),ASCII使得很难在单个箭头中很好地绘制,所以我把它们留在了这里。 但是,让我们继续强调名称master
指向提交C
,并且名称develop
指向提交E
,因为我们即将对master
进行一次新的提交:
A--B--C--F <-- master
D--E <-- develop
现在我们有一个做git rebase
的情况已经成熟:我们可能想在提交F
之后提交D
和E
不过,我们已经看到,我们无法改变任何关于提交的内容。 如果我们尝试,我们会得到一个新的,不同的提交。 但是我们仍然这样做:让我们将提交D
复制到一个新的,不同的提交D'
,其父提交F
并且其消息与D
相同:
D' <-- [temporary]
/
A--B--C--F <-- master
D--E <-- develop
为了使它真正起作用,我们将从F
的源代码树开始,并且对之前做出的任何更改发送到该树。 我们通过让Git比较提交D
到它的父提交C
:
git diff develop^ develop
然后应用这组更改提交F
,然后使用与原始D
相同的消息使用git commit
创建此新副本D'
。
有一个Git命令可以完成这种复制: git cherry-pick
。 如果我们通过它的哈希ID(作为分离的HEAD)检出提交F
,并选择提交D
,我们就得到提交D'
。 tree
和parent
行有什么变化,几乎可以肯定是时间戳。 但是,提交D'
与提交D
一样“一样好”,或者甚至更好,如果我们也将提交E
复制到E'
:
D'--E' <-- HEAD
/
A--B--C--F <-- master
D--E <-- develop
现在我们已经复制了我们关心的两个提交,我们可以告诉Git将标签develop
从提交E
剥离出来,并将它指向我们的最后一个副本E'
:
D'--E' <-- develop
/
A--B--C--F <-- master
D--E <-- [abandoned]
一般来说,这就是git rebase
作用:它是一个自动化的git cherry-pick
复制操作系列,随后是标签移动。
选择要复制的内容,以及其他细化
这里有一个非常棘手的问题,通过我们绘制这些提交图的方式来伪装。 Git如何知道哪些内容会拷贝以及拷贝到哪里?
在Git中,通常的答案是从git rebase
的(单个)参数中取得的。 如果我们运行git rebase master
,我们告诉Git:
develop
)而不是master
上的提交; master
尖端之后的点。 如果你看图,很明显,正在develop
的提交是DE
。 但这是错误的! 正在开发的提交实际上是ABCDE
。 master
上的提交是ABCF
。 其中三个承诺, ABC
,都在两个分支上。
这就是为什么上面的短语是“提交在当前分支上,而不是另一个上”。 由于ABC
同时存在,将它们从列表中删除,只留下DE
被复制。
请注意,我们的唯一参数master
,既用作“不要复制什么”,又用于“复制到哪里”。 rebase命令有一种方法可以将这些分开 - “不要根据提交S-for-stop进行复制”和“在T-for-target之后放置副本” - 但您仍然只能得到一个“停止”点。 默认的是你用一个名字命名S和T. 该--onto
标志, git rebase --onto TS
,是什么让你分割起来。
除了复制提交之外,还可以使用各种rebase(“交互式”),让您在创建现有提交的新副本之前进行更改。 也就是说,您可以将此视为复制提交D
,就像通过樱桃选择一样,但是在提交新D'
之前让我进行一些小改动。
3实际上,这些更改通常是使用git commit --amend
,这意味着您可以创建两个副本:一个在新位置,然后是修改后的副本,将第一个副本推到一边,以便真正使用。 但是,这一切都发生在幕后,并且比无论如何都更有效率,所以假装它“就在之前”至少用于学习目的并没有真正的伤害。
合并使一切变得更加棘手
现在让我们看看合并。 合并提交 - 这是一个实际的事情,与我们进行合并提交的过程分开,但都称为“合并” - 是至少提交了两个父提交的任何提交。 我们通过将合并“回头”给每个父母来绘制它们:
...--H--I--J---M <-- br1
/
K--L <-- br2
这里合并提交M
有两个父母J
和L
我们可能做了git checkout br1; git merge br2
git checkout br1; git merge br2
。 (这意味着M
的第一个父对象是J
这里没关系,但稍后有用,任何合并的第一个父对象是在你运行git merge
时HEAD
的提交,通常不会得到通常不关心顺序,Git大多不在乎,除了第一件事和第二件事之外,只有当你使用--first-parent
。)
让我们再添加几个M
以外的提交,全部放在br1
(这将是我们当前的分支;我们也通过添加(HEAD)
标记它):
...--H--I--J---M--N--O <-- br1 (HEAD)
/
K--L <-- br2
现在让我们假设我们正在尝试使用git rebase
来复制JMNO
。
我们可以告诉Git停止在(和之前) L
处复制。 但是,这些副本会在错误的地方出现,即在L
之后。
我们可以告诉Git停止在(和之前) I
处复制。 但是Git坚持复制K
和L
换句话说,合并将一个猴子扳手放入只用一个“停止点”的想法,除非我们选择I
; 然后我们复制别人的提交。
它还增加了一个真正大的猴子扳手:Git无法复制合并。 cherry-pick
命令坚持认为你选择了合并的一个“方面”,并将提交复制到一个新的非合并提交中,该提交完成了“一方”所做的事情,而不是真正的合并。 更糟糕的是,默认情况下, rebase
命令完全跳过合并!
这是事情变得特别棘手的地方。 Git有时会重新使用现有的提交,尤其是进行交互式转化; 和git rebase -p
声称试图保留合并 - 实际上,它不会,因为它不能。 但它会重新执行合并,即再次运行git merge
。
因此,鉴于上图,我们可以尝试运行:
git rebase -i -p <hash-of-I>
我们希望Git会重新使用K
和L
,如果我们不打算改变它,甚至可能重用J
当然,我们打算改变J
(通过使用reword
或edit
它)。 所以现在Git会复制J
,让我们调整J'
,然后重新运行合并命令在J'
和L
之间创建一个新的合并, M'
,我们希望它重新使用到位。
Git将不得不继续复制N
和O
新的M'
具有与原始M
不同的哈希ID,因此即使N
本身不需要其他更改,其parent
也必须更改。 由于N
变成了N'
,所以O
同样必须变成O'
指向N'
。
是否所有这些工作取决于Git是否保留了原始的K
和L
提交。 如果Git选择复制它们,您将成为提交者(作者通常保持不变),并且时间戳将会改变,因此您将K
和L
复制到K'
和L'
。 现有分支将继续指向原件,而不是副本。
如果复制对于Git来说太复杂,可以手动完成
假设,无论出于何种原因, git rebase -i -p <hash-of-I>
不会做我们想要的。 我们之后立即使用git reset --hard ORIG_HEAD
或类似方法撤销rebase,以便我们回到这个图表:
...--H--I--J---M--N--O <-- br1 (HEAD)
/
K--L <-- br2
我们现在希望做一个像J
但是不同的新提交J'
,所以我们可以手动完成。 一切都是干净的 - 没有什么变化可以担心在这一点上升级或什么 - 所以我们只是运行:
$ git checkout -b newbr1 <hash-of-I>
$ git cherry-pick -n <hash-of-J>
-n
(或--no-commit
)告诉Git,是的,我们在这里复制J
,但是不要提交拷贝。 现在我们可以尽可能多地提交提交内容(编辑文件和git add
它们),然后运行git commit
来创建新的提交并编辑提交消息。 (如果您不需要更改树,则可以省略-n
并仅编辑该消息。)
现在我们有这个:
J' <-- newbr1 (HEAD)
/
...--H--I--J---M--N--O <-- br1
/
K--L <-- br2
我们现在准备合并提交L
:
$ git merge br2
这产生了提交M'
。 我们现在准备挑选N
:
$ git cherry-pick -n <hash-of-N>
我们可以尽可能多地调整它,并且:
$ git cherry-pick -n br1
复制O
(我们不需要知道或找到它的散列,因为名称br1
指向O
)。
一旦我们完成了,我们只需强制名称br1
指向我们创建的新O'
副本,为此我们可以使用任何一个Git命令,例如:
git branch -f br1 newbr1
只要我们仍然在分支newbr1
。