关于git:如何“撤消”rebasing提交


How to “undo” rebasing commits

本问题已经有最佳答案,请猛点这里访问。

DR:

我进行了一系列提交,包括中间的一个合并提交;现在我想编辑合并之前提交的文本,但是要保留提交的代码和提交图。

长篇小说:

我想重新命名我在本地做的承诺,所以我使用了git rebase -i

我在我想重命名的承诺上使用了r,在我想"保留"的承诺上使用了p。问题是,我想要"保留"的提交是其他人的提交(在尝试重命名提交消息之前,我以前做过从主分支到我的分支的合并)。

所以基本上我有这样的想法:

1
2
3
4
aaaa My Commit
bbbb My commit
cccc Someone's else commit
dddd My commit

我所做的是运行git rebase -i,然后这样做:

1
2
3
4
r aaaa My Commit 1
r bbbb My Commit 2
p cccc Somene's else commit
r dddd My Commit 3

现在我认为发生了什么,那些不是我的提交被重写了,现在它们看起来像新的提交,它们似乎有一个不同于主分支的ID。因此,对于main分支,4rd commit没有ccccid:

xxxx Somene's else commit

所以我的问题是:

  • 我的理解正确吗?现在是新的承诺了吗?或者我完全误解了。
  • 修改提交消息时是否出错?正确的方法是什么?
  • 现在怎么办?我怎样才能解决这个烂摊子?
  • 我可以自己搜索如何尝试撤销这个,但我想了解发生了什么。


    这里有一堆有些棘手的概念,它们都卷成了一团紧紧盘绕的头发。让我们把它们分开,从提交的"真名"开始。每一个提交都只有一个,1,这就是它的散列ID,它是像238e487ea943f80734cc6dad665e7238b8cbc7ff这样丑陋的40个字符中的一个。好的。

    1它最终会从sha-1转换到包含更多位的对象,这可能会导致无效:提交将至少暂时具有两个真名称,这在这些新的更大的哈希提交中的一个在较小的sha-1哈希中发生冲突的不太可能但必然可能的情况下变得很尴尬。但我们别担心。-)好的。哈希ID是唯一的

    给定散列ID,Git可以找到提交(或其他对象)并提取其内容。给定一些内容,Git可以计算散列ID。因此在这些内容之间有一对一的映射:散列键正好代表一个值,并且一个特定的值总是由同一个散列键表示。这使得Git可以通过git fetchgit push在存储库之间传输提交(和其他对象)。好的。提交的哈希ID包括作者、消息和时间戳。

    让我们看看其中一个承诺:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    $ 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>

    这是commit 238e487ea943f80734cc6dad665e7238b8cbc7ff的全部内容,计算commit 293\0的sha-1校验和(293是文本的长度),再加上原始文本会导致该哈希:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    $ 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 {}\0'.format(len(text)).encode('utf8')
    >>> s += text
    >>> hashlib.sha1(s).hexdigest()
    '238e487ea943f80734cc6dad665e7238b8cbc7ff'

    (上面的代码在PY2K和PY3K中应该可以使用,但在飞行中会稍微修补一下,所以可能会出现故障)。好的。

    无论如何,特别要注意parent线和authorcommitter线。parent行提供了此提交的父级的哈希ID。其他两行有一个名称、一个电子邮件地址、一个长十进制数字和一个奇怪的-0700,实际上是时区偏移(在本例中是格林威治标准时间/祖鲁时间以西7小时)。大十进制数加上这个时区偏移量就是提交的时间戳。好的。

    tree行给出了tree对象的git散列ID,该对象包含与此提交一起使用的源。显然,文本的其余部分只是提交消息本身。具有时间戳意味着由同一个人使用相同的源树和相同的提交消息进行的两次相同的提交通常会导致两次不同的提交,因为没有人每秒进行一次以上的提交。2好的。

    2脚本很容易违反这一规则,并会产生惊喜。好的。分支名称只指向提交,其他提交也是如此

    由于每个提交都有其父提交的哈希ID作为其核心数据的一部分,因此它足以在分支名称中存储单个git哈希ID,如masterdevelop。这个名称映射到散列ID,它标识或"指向"分支的提示提交。然后,这个特定的提交在其内部具有其父提交的哈希ID:提示提交指向其父提交。该父提交指向它自己的父提交。正是由分支名称标识的分支提示提交开始的向后指针链组成了Git分支:好的。

    1
    A <-B <-C   <-- master

    在这个小小的3提交存储库中,名称master标识commit CC指向BB指向A。由于A是有史以来第一次作出承诺,它根本没有指出任何地方。这是一个根提交的技术术语,当我们(或Git)处理提交时,我们通常遵循向后的指针,直到它们在根上用完为止。好的。所有这一切意味着,任何提交(或任何git对象)都不能更改

    我们得到的声明是,任何git对象commit、tree、带注释的标记或"blob"(文件)的散列ID都是唯一的,并且它严格依赖于对象内部的数据。这个声明是正确的;Git通过拒绝添加一个新对象来强制执行它,这个新对象由于某种偶然或邪恶的目的,与某个现有对象具有相同的散列值。在实践中,更改、添加或删除一个提交中的一个字符会产生一个全新的、不同的哈希;甚至仅仅复制一个提交也会由于时间戳而产生一个新的、不同的哈希。好的。

    从某种意义上说,这使得重新平衡变得不可能。然而,git rebase是存在的,所以它一定是可能的。诀窍在于如何。好的。再平衡的目的

    有几个原因可以使用git rebase,但最常见的只是这样做:"重新建立"一些承诺。让我们绘制另一个类似最小存储库的图形,但添加一个分支:好的。

    1
    2
    3
    A--B--C   <-- master
           \
            D--E   <-- develop

    这些命令中的箭头都是向后的(根据定义),而ASCII使得很难画出单独的箭头,所以我把它们放在这里了。但我们要继续强调的是,master的名称表示承诺Cdevelop的名称表示承诺E,因为我们将对master作出新的承诺:好的。

    1
    2
    3
    A--B--C--F   <-- master
           \
            D--E   <-- develop

    现在,我们已经有了一个时机来做git rebase:我们可能希望在DE之后再做F。好的。

    但是,我们已经看到,对于提交,我们不能改变任何东西。如果我们尝试,我们会得到一个新的,不同的承诺。但是我们还是要这样做:让我们把commit D复制到一个新的不同commit D',它的父代是commit F,其消息与D的相同:好的。

    1
    2
    3
    4
    5
               D'  <-- [temporary]
              /
    A--B--C--F   <-- master
           \
            D--E   <-- develop

    为了使这一点真正起作用,我们也将从F的源树开始,并对该树进行之前所做的任何更改。我们将通过让git比较commit D和其父commit C来实现这一点:好的。

    1
    git diff develop^ develop

    然后将该组更改应用于提交F,然后使用git commit,使用与原始D相同的消息制作新的D'。好的。

    有一个git命令可以进行这种复制:git cherry-pick。如果我们通过其散列ID(作为分离的头)签出commit F,而cherry pick commit D,我们将得到commit D'treeparent行有什么变化,几乎可以肯定的是时间戳。但是commit D'和commit D一样好,或者甚至更好,如果我们也把commit E复制到E'中:好的。

    1
    2
    3
    4
    5
               D'--E'  <-- HEAD
              /
    A--B--C--F   <-- master
           \
            D--E   <-- develop

    既然我们已经复制了我们关心的两个承诺,我们可以告诉Git撕掉承诺E的标签develop,并指出我们最后一个副本E':好的。

    1
    2
    3
    4
    5
               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上的承诺是D-E。但这是错误的!正在开发的承诺实际上是A-B-C-D-Emaster上的承诺是A-B-C-F。其中三项承诺,即A-B-C,在这两个分支机构。好的。

    这就是为什么上面的短语是"当前分支上的提交,而不是另一个分支上的提交"。因为A-B-C在这两个分支上,所以将它们从列表中删除,只留下D-E要复制。好的。

    请注意,我们的单参数master既用作"不复制的内容"也用作"复制到哪里"。REBASE命令有一种方法可以将它们分开——"不要基于commit s-for-stop进行复制"和"将副本放在t-for-target之后"——但您仍然只能得到一个"stop"点。默认情况下,使用一个名称同时命名S和T。--onto旗,git rebase --onto T S,就是让你把它们分开的。好的。

    除了复制提交之外,您还可以使用一种特殊的"交互"方式,让您在3之前进行更改,它将生成现有提交的新副本。也就是说,您可以将其视为复制提交D,就像通过cherry pick一样,但在提交新D'之前,让我做一些小的更改。好的。

    3实际上,这些更改通常是使用git commit --amend进行的,这意味着您最终会制作两份副本:一份在新的地方,然后是修改后的副本,将第一份拷贝推到一边,以便真正使用。但这一切都发生在幕后,而且比听起来更有效率,因此,至少出于学习的目的,假装它是"刚刚开始"并没有真正的伤害。好的。合并使一切变得更加棘手

    现在我们来看看合并。合并提交这是一个实际的事情,与我们进行合并提交的过程是分开的,但是这两者都被称为"合并"——任何具有至少两个父提交的提交。我们通过让合并"指向"它的每个父对象来绘制它们:好的。

    1
    2
    3
    ...--H--I--J---M   <-- br1
             \    /
              K--L   <-- br2

    这里合并承诺M有两个父母,JL。我们可能是通过做git checkout br1; git merge br2来实现的。(这意味着M的第一个父母是J。这在这里并不重要,但在以后会有用。任何合并的第一个父级是运行git merge时的HEAD提交。这通常不会绘制成图形,而图形通常不关心顺序。Git也不在乎,除了第一个和第二个,然后只有在你使用--first-parent的情况下。)好的。

    让我们在M之后再增加一些承诺,所有承诺都在br1上(这将是我们当前的分支;我们也可以通过添加(HEAD)来标记这一点):好的。

    1
    2
    3
    ...--H--I--J---M--N--O   <-- br1 (HEAD)
             \    /
              K--L   <-- br2

    现在让我们设想一下,我们正试图使用git rebase来复制,比如说,J-M-N-O。好的。

    我们可以告诉Git停止在L之前复制。但是,这些副本放错了地方,也就是说,就在L之后。好的。

    我们可以告诉Git停止在I之前复制。但后来吉特坚持复制KL。好的。

    换言之,合并使我们产生了一种只使用一个"停止点"的想法,除非我们选择I;然后我们复制别人的承诺。好的。

    它还增加了一个非常大的活动扳手:git不能复制合并。cherry-pick命令坚持您选择合并的一个"边",并将提交复制到一个新的非合并提交中,该提交执行"边"所做的操作,而不是实际合并。更糟糕的是,默认情况下,rebase命令只会跳过完全合并!好的。

    这就是事情变得特别棘手的地方。Git有时会重新使用现有的提交,尤其是进行交互式的重新平衡;并且git rebase -p声称试图保留合并,但实际上,它不能,因为它不能。但是它将重新执行合并,即再次运行git merge。好的。

    因此,根据上图,我们可以尝试运行:好的。

    1
    git rebase -i -p <hash-of-I>

    我们希望,Git将重新使用KL,如果我们不打算改变它,甚至可能重新使用J。当然,我们确实打算更改J(在上面使用rewordedit)。所以现在Git会复制J,让我们调整J',然后重新运行merge命令,在J'L之间进行一个新的合并,M',我们希望它能被使用。好的。

    然后,Git将不得不继续复制NO。新的M'与原来的M具有不同的哈希ID,因此即使N本身不需要其他更改,其parent行也必须更改。由于N改为N'O也必须改为O'指向N'。好的。

    所有这些工作是否都取决于Git是否保留原来的KL承诺。如果Git选择复制它们,您将成为提交者(作者通常保持不变),时间戳将改变,因此您将复制KLK'L'。现有分支机构将继续指向原件,而不是副本。好的。如果复制对Git来说太复杂,可以手动进行。

    假设,无论出于什么原因,git rebase -i -p 都不做我们想要的。我们随后立即使用git reset --hard ORIG_HEAD或类似工具撤消钢筋,以便返回此图:好的。

    1
    2
    3
    ...--H--I--J---M--N--O   <-- br1 (HEAD)
             \    /
              K--L   <-- br2

    我们现在希望进行一个新的提交J',类似于J,但不同,所以我们可以手动执行。所有东西都是干净的,在这一点上没有需要担心的变化,所以我们只运行:好的。

    1
    2
    $ git checkout -b newbr1 <hash-of-I>
    $ git cherry-pick -n <hash-of-J>

    -n号(或--no-commit号)告诉Git,是的,我们在这里复制J号,但暂时不要提交副本。现在我们可以随心所欲地修改提交内容(编辑文件和git add它们),然后运行git commit来进行新的提交和编辑提交消息。(如果您不需要更改任何树,可以省略-n,只需编辑消息。)好的。

    现在我们有了:好的。

    1
    2
    3
    4
    5
              J'   <-- newbr1 (HEAD)
             /
    ...--H--I--J---M--N--O   <-- br1
             \    /
              K--L   <-- br2

    我们现在准备合并commit L:好的。

    1
    $ git merge br2

    这将产生commit M'。我们现在准备好挑选N:好的。

    1
    $ git cherry-pick -n <hash-of-N>

    我们可以根据自己的喜好进行调整,并且:好的。

    1
    $ git cherry-pick -n br1

    复制O(我们不需要知道或找到它的散列值,因为名称br1指向O。好的。

    完成后,我们只需强制名称br1指向我们制作的新O'副本,我们可以使用几个git命令中的任何一个,例如:好的。

    1
    git branch -f br1 newbr1

    只要我们还在EDOCX1支〔46〕上。好的。好啊。