首页 > 解决方案 > 在这种情况下推送提交后可以重新设置基准吗?

问题描述

我在 master 分支上有一个长期存在的开发分支,所有修改都在此完成,直到该分支合并回 master 分支。然而,有时,一个关键的修复会被挑选出来并应用于主分支,而不是等待完全合并。在开发周期中,对开发分支的修改会多次提交并推送到远程存储库。当最终完成向主服务器的合并时,由于先前的挑选而创建合并提交并不罕见。

我知道,一般来说,您不应该对已推送到其他人正在从中提取的远程存储库的提交的分支进行变基。但是在合并之后,除了头不同之外,开发和主分支基本相同。但是如果我在合并后立即将我的开发分支重新定位到主分支,我相信这两个分支将有一个共同的头部(合并提交)并且开发分支中的任何提交 id 都不会改变。通过这样做,没有人受到伤害,并且我可以进行未来的合并,而不会被自动强制创建合并提交。

这合理吗?

标签: gitrebase

解决方案


TL;博士

没有什么可以变基的,所以你的变基本身就可以了。这不一定是主意,也不一定是主意。如果有一些东西要变基,一切都会变得更加复杂。

(旁注:使用git merge而不是解决修补程序问题有一种不同的,通常更好的方法git cherry-pick,尽管它与您进行这种变基的愿望和能力无关。它也有其自身的缺点。对于有关这些的更多信息,请参阅停止樱桃采摘,开始合并。请务必阅读结尾:如果您需要樱桃采摘,请停止合并。)

长:三个关键要点

我不确定谁会阅读这篇长篇文章,但无论谁阅读,都有三个关键要点。第一个是更复杂的变基规则,就在下面。第二个是快进最终是关于可达性的,它有一个专门介绍这个想法的网站,在Think Like (a) Git。这值得一读。最后一个是 rebase 通过复制提交来工作,然后放弃原件以支持新的和改进的副本。正是这种放弃,以及伴随它的非快进——这是停止使用过时的提交所必需的——带来了所有的麻烦,导致了简单的不要变基共享分支规则,这通常有点简单了。

(还有一些其他的,包括最后的一个,除了存储库历史学家之外,它们通常不太重要。)

重新设置共享分支

我知道,一般来说,您不应该对已推送到其他人正在从中提取的远程存储库的提交的分支进行变基。

这就是简单的规则。有一个更复杂的变体,只要所有使用 / will-be-using / are-using 这个分支的用户都可以接受 rebase 就可以了。

定义术语和gitglossary

但是在合并之后,除了头不同之外,开发和主分支基本相同。但是如果我在合并后立即将我的开发分支重新定位到主分支,我相信这两个分支将有一个共同的头(合并提交)

这可能是真的,但仅限于像您遇到的那种微不足道的情况。此外,这里还有一些术语问题。特别是,我们必须定义“头”。如果按照gitglossary的方式进行操作,我们需要一个不同的术语:我们需要开始使用提示提交来代替。以下是他们对headbranch的定义,以及间接对tip commit 的定义:


分支顶端提交命名引用。Heads 存储在目录中的文件中,除非使用打包的 refs。(参见git-pack-refs[1]。)$GIT_DIR/refs/heads/

分支

“分支”是一条活跃的发展路线。分支上的最新提交称为该分支的尖端。分支的尖端由分支head引用,随着在分支上完成额外的开发,它会向前移动。单个 Git存储库可以跟踪任意数量的分支,但您的工作树仅与其中一个关联(“当前”或“签出”分支),并且HEAD指向该分支。

请注意,顺便说一下,that HEAD(字面量和全部大写)与“head”(全部小写)非常不同。这种区别在 Windows 和 MacOS 等折叠式系统上变得模糊甚至丢失,但在其他方面至关重要:只有一个HEAD,但每个分支名称都是一个“头”。

并且开发分支中的所有提交 id 都不会改变

在大多数情况下,所有development不属于更新后的可访问范围的master提交都将被复制,并且由此复制产生的所有新提交具有不同的哈希 ID。如果这些提交的列表为空,则此复制过程将复制零个提交,并且所有零个都将具有新的哈希 ID,但是由于它们的数量为零,所以没关系。:-)

在屏幕、纸或白板上直观地表示这一点

要理解上面对headbranch的定义是什么意思,首先简单地画出一系列提交(松散地说,“一个分支”)在 Git 中的样子。我们知道:

  • 每次提交都会保存您的代码的完整快照。

  • Git 通过哈希 ID 查找提交(嗯,一般的对象,包括提交)。哈希 ID 是一个大而难看的字符串,例如7ad088c9a811670756a3fb60ac2dab16b520797b.

  • 每个提交都有自己唯一的哈希 ID。1

  • 每个提交都存储其父级(如果提交是普通提交)或父级(至少两个,通常正好两个,如果提交是合并提交)的哈希 ID。

  • 任何提交的内容一旦提交,就永远无法更改。(事实上​​,任何Git 对象一旦生成就无法更改。2

因此,如果我们从最新的提交开始,我们可以让 Git 跟随每个父节点,一次一个,向后

... <-F <-G <-H   <--latest

我们只需要将最新提交的原始哈希 ID 存储在某处,以便 Git 可以查找 hashH并使用它来查找 hashG来查找提交以查找 hash F,依此类推。(最终,Git 将到达第一个提交,它没有父级,因为它没有父级,这让 Git 停止。)

出于绘图的目的,由于提交的内容不能改变,我们可以用线将它们连接起来,只要我们在内部记得,我们只能向后(从较新的提交到较旧的提交)。新提交会记住它们的父提交,但现有提交不能在创建子代时将其子代添加到其中,因为为时已晚:到那时父代被冻结。所以让我们画一个稍微复杂一点的图:

...--G--H   <-- master
         \
          I--J--K   <-- develop

在这里, commit 的父级I是 commit H。该名称 master包含原始哈希 IDH本身;这就是 Git 可以做到的git checkout master。该名称 development包含原始哈希 ID K。这些是最新的提交——“heads”或分支提示,使用 gitglossary 使用的定义。


1 Git 通过在每次提交中添加日期和时间戳来确保这一点,这样即使您强制 Git 重新提交与一分钟前完全相同的内容,重新使用您的姓名和电子邮件地址和日志消息——<em>以及相同的父哈希——时间戳不同。这确实意味着如果你什么都不改变,你实际上不能强迫 Git 每秒提交一次以上,但这是我准备接受的一个限制。:-)

2这是因为 Git 对象的哈希 ID 实际上是该对象数据内容的加密校验和。这有两个目的:在给定摘要校验和的情况下,可以轻松查找实际数据;并且,它使检测数据损坏成为可能,因为仅更改数据的一位会导致新的不同校验和。


词汇表与大多数人的日常用词不符

Gitglossary 尝试使用名称head来表示分支名称本身,单词分支表示分支的提示提交加上该提示提交后面的部分或全部提交,提示提交表示提交HK. 用户通常将这些混为一谈,将所有三个集中在单词分支下。他们甚至可能使用同一个词——“分支”——来指代名称,例如origin/master和/或可以从这样的名称到达的提交。Gitglossary 试图将其称为远程跟踪分支。我发现这个术语会引起混淆,并且一直在使用远程跟踪名称,但不确定这是否有很大的改进。

作为下面的参考,我自己的术语是:名称 like 的分支名称,名称likemaster远程跟踪名称origin/master提示提交的使用方式与词汇表完全相同,DAGlet用于提交及其链接的集合,通常可以找到通过选择最后一次提交并向后工作。

添加提交

最后,我们称之为什么并不重要,只要我们都了解彼此在说什么。不幸的是,在实践中,人们在最后一部分遇到了麻烦。所以让我们来说明添加新提交的过程。

对于 Git,重要的是提交哈希 ID,我在这里将其绘制为单个大写字母。名称——<code>master、developorigin/master等等——只是人类用来跟踪哈希 ID 的东西。Git 使我们能够更新这些名称,以便它们自动保存最新的哈希 ID。我们将从这个开始:

...--G--H   <-- master
         \
          I--J--K   <-- develop

现在我们开始工作,导致 agit commit或 a git cherry-pick。我们从:

git checkout master

选择name mastercommit H,为了实现这一点,Git 将HEAD(全部大写)附加到master

...--G--H   <-- master (HEAD)
         \
          I--J--K   <-- develop

同时将提交提取H到索引和工作树中,以便我们可以处理/使用它。

现在我们做一些工作并运行git commit,或者运行,例如。运行的行为,或其他进行提交的Git 命令,会使 Git 更新名称,以便它现在拥有最新的提交哈希 ID。我们的新提交将采用下一个字母(或者实际上,获取一些大而丑陋的哈希 ID),我们将拥有以下内容:git cherry-pick somethinggit commitL

...--G--H--L   <-- master (HEAD)
         \
          I--J--K   <-- develop

添加合并提交

现在请记住,Git 是反向工作的,一次提交一个。如果我们从 开始K,我们将访问提交K,然后J,然后和I等等,跳过。如果我们从 开始,我们将访问,然后等等,跳过整个链。因此,包含两者的唯一方法是从一些将两者都用作其父级的提交向后工作。这是一个合并提交,我们可以通过运行:HGLLLHGI-J-Kgit merge develop

...--G--H--L------M   <-- master (HEAD)
         \       /
          I--J--K   <-- develop

合并提交M两个父级。最显着的是它的第一个父级,L因为L HEAD我们运行时的提交git merge。这意味着,如果我们使用从 开始获得的 DAGlet M,或者任何让我们到达 M的后续提交,并且向后工作,我们将跳过develop这里进入的提交。这通常正是我们想要的所有直接在.master

快进操作

在 Git 中,我们可以随时将我们喜欢的任何分支名称——新的或现有的——指向当前存在的任何提交。所以现在我们有:

...--G--H--L------M   <-- master (HEAD)
         \       /
          I--J--K   <-- develop

无论出于何种原因,我们都可以创建一个新名称,例如zorg, 来指向 commitLHorJ或任何我们喜欢的东西。J让我们没有特别好的理由选择提交,并HEAD通过git checkout zorg在我们创建期间或之后执行它来实现zorg

...--G--H--L------M   <-- master
         \       /
          I--J--K   <-- develop
              .
               .....<-- zorg (HEAD)

zorg如果我们从头开始并向后工作,我们会得到哪些提交?由于zorgpicks J,它指向I然后H等等,我们得到...--G--H--I--J

现在让我们强制zorg改为指向L,同时更新我们的索引和工作树,使用git reset --hard <hash-of-L>. 现在我们有:

            ..........<-- zorg (HEAD)
           .
...--G--H--L------M   <-- master
         \       /
          I--J--K   <-- develop

zorg如果我们从头开始并向后工作,我们会得到哪些提交?显然,序列...--G--H--L请注意,commitJ不再可以从zorg.

现在让我们zorg指出 commit M,就像这样master做:

...--G--H--L------M   <-- master, zorg (HEAD)
         \       /
          I--J--K   <-- develop

现在可以访问哪些提交?让我们让Git跟随. 因此,对于这个特定的举措,无论我们是从or 还是from,我们仍然能够达到我们之前可以完成的所有提交,以及一些新的提交。M...--G-H-(L and I-J-K)-M L J

快进也适用于推送和获取

在图形方面,提交LJ都是commit的祖先M。这意味着将标签zorg从这些祖先中的任何一个向前移动——在 Git 自己难以实现的方向上——移动到M,这就是 Git 所说的快进操作。词汇表(我认为不正确)定义了这个术语git merge,但它不仅仅适用于git merge. 它根本不是真正的属性git merge,而是标签运动本身的属性。

较早的从Jto移动L不是快进,因为不是. 事实上,两个提交都不是另一个提交的祖先,所以从 J 到 L 的任何移动或反之亦然都是非快进操作。当移动从提交到其后代之一时,会发生快进。(因为这对 Git 来说很难测试,它实际上是反过来检查:你已经给了它后代提交,所以 Git 向后工作以查看它是否从那里找到父提交。)JL

特别是,假设在我们zorg指出之后J,我们运行:

git push origin zorg

这将使我们的 Git 调用另一个 Gitorigin并要求他们创建自己的分支,名为zorg,指向 commit J3 因为这是他们的名字,他们会说OK,然后就去做。

现在我们将在git reset --hard本地强制zorg指向L,然后再试git push一次。这一次,他们确实有一个zorg,并且他们的标识了 commit J。提交L不是的后代,J因此这git push将失败,并出现非快进错误。我们必须用它git push --force来让他们接受我们的请求——现在是一个命令——他们zorg以这种非快进的方式移动他们。

但是,无论我们是否进行第二次推送,如果我们将我们的zorgto 指向M然后运行:

git push origin zorg

再一次,这一次,他们会很高兴地接受这个请求。是因为从JLM的移动一个快进操作。所以他们最终会zorg指向 commit M,与我们自己的情况相匹配。


3如果origin还没有提交J,我们的 Git 会发送它们J以及任何必要的父提交。


樱桃采摘和变基

git cherry-pick命令基本上是关于复制提交。不幸的是,提交是一个快照,当我们复制一个时,我们不只是想拍摄那个快照。一个典型的例子是一些修补程序,它可能就像修复拼写错误或删除顽皮的单词或其他东西一样简单。我们希望将其视为更改,而不是首次进行修复的代码版本。

因此,通过在提交的父级和提交本身之间运行,git cherry-pick基本上将提交变成了一组更改。4 一旦我们有了更改,我们可以将它们应用到其他提交,在完整的提交集合中的其他地方,以进行新的和不同的提交,就像我们上面的提交一样。我们将让 Git 复制精选提交的日志消息,但新提交的哈希 ID 会有所不同。git diffL

假设我们在进行任何合并之前停止,即当我们仍然有这个时:

...--G--H--L   <-- master
         \
          I--J--K   <-- develop (HEAD)

如果我们git rebase master 现在运行,Git 将首先列出可从HEAD-ie, - 到达的提交,然后从 ,...-G-H-I-J-K中减去可到达的集合master...-G-H-L留下集合I-J-K。然后它将继续复制 I到 new-and-improved I',就像 by 一样git cherry-pick,然后I'继续L

             I'  <-- HEAD
            /
...--G--H--L   <-- master
         \
          I--J--K   <-- develop

(这发生在“分离的 HEAD”模式下,这就是为什么HEAD直接指向 new commit I'。)然后它重复 for Jand K

             I'-J'-K'  <-- HEAD
            /
...--G--H--L   <-- master
         \
          I--J--K   <-- develop

作为它的最后一个技巧,git rebase强制名称develop移动,以便它指向最终复制的提交,在这种情况下,K',并重新附加HEAD到移动的develop

             I'-J'-K'  <-- develop (HEAD)
            /
...--G--H--L   <-- master
         \
          I--J--K   [abandoned]

请注意,在这种情况下,运动是非快进的。如果originGit 有一个developthat 指向K,我们现在尝试发送K'(和父母)到origin并要求他们将他们 develop的指向设置为K',他们将拒绝一个非快进错误。


4的实际机制git cherry-pick是使用合并。合并的基本提交是被挑选出来的提交的父提交,所以我们确实得到了这个差异,但我们也得到了第二个差异,反对HEAD,然后是完整的三向合并。此合并通过进行普通的非合并提交来结束:也就是说,cherry-pick 执行git merge, to merge的动词部分,但不是名词部分,因为它只是进行普通(非合并)提交。

但是,除了棘手的情况,您可以将其视为应用 parent-vs-child 差异,就好像它是一个 patch 一样。而事实上,有些git rebase是后者,而另一些是在内部git rebase使用git cherry-pick!没有特别好的理由:只是历史意外,因为git cherry-pick最初是在没有使用适当的三向合并的情况下实现的。当发现这对于棘手的情况来说不够用时,git cherry-pick本身就得到了改进,但老的git rebase还是继续使用旧的方式。所有较新git rebase的 s 都使用新的cherry-pick(因为它几乎总是相同或更好),但为了向后兼容,最古老的 rebase 形式仍然使用旧方式。


如果我们先合并,那就快进了!

但是假设我们等待,让合并提交M先进入,所以我们从这个开始:

...--G--H--L------M   <-- master (HEAD)
         \       /
          I--J--K   <-- develop

然后我们这样做:

git checkout develop
git rebase master

这一次,当 Git 列出无法从 访问的提交时,develop没有。从, git通过它的第二个父级到达,所以所有的提交都已经完成了。因此,rebase 操作从不复制任何提交开始,将所有提交都放在后面:masterMKmasterM

...--G--H--L------M   <-- master, HEAD
         \       /
          I--J--K   <-- develop

最后一个动作git rebase是强制名称develop到最后一个复制的提交,在这种情况下,这实际上意味着M, 并重新附加HEAD

...--G--H--L------M   <-- master, develop (HEAD)
         \       /
          I--J--K

如果我们运行,我们会得到完全相同的效果git checkout develop; git merge master:Git 会在快进操作中将名称 develop 向前移动,因此develop指向 commit M。我们现在可以git push origin develop,因为他们 developK并且移动到M是一个快进,这是允许的。

如果我们现在在 上进行新的提交develop,它们将如下所示:

...--G--H--L------M   <-- master
         \       / \
          I--J--K   N--O   <-- develop (HEAD)

这当然没问题。但是,如果我们进行合并,那也没关系:

...--G--H--L------M   <-- master
         \       /
          I--J--K--N--O   <-- develop (HEAD)

两种方法的区别

这里的关键区别在于,如果我们不快develop,则其父级NK而不是M,这意味着我们可以沿线性developONKJ等的历史。合并到位后,我们需要知道 to ON然后M向下(忽略第一个) to的第二个父级,依此类推。MKJ

如果您要对历史进行大量检查——也许是为了寻找和修复错误,也许只是出于对历史的兴趣——直线的、从不快进的方法为您提供了可以使用的优势--first-parent(表示 *at 合并的 Git 标志,仅跟随第一个父级)以使您未来的工作变得轻松。如果您永远不会这样做,那么这种差异根本没有任何区别。

还有另一种选择,它的用处相对较小,但值得考虑。假设在进行 merge 之后M,您可以像这样进行真正的合并develop

git checkout develop
git merge --no-ff master

你在这里得到的是一个提交N,我们可以像这样绘制:

...--G--H--L------M   <-- master
         \       / \
          I--J--K---N   <-- develop (HEAD)

其中 的第一个父级NK,第二个是M。两次提交的哈希 ID将不同,而每个提交的保存快照应该相同。5 这意味着你可以像以前一样做历史搜索技巧,当你根本没有合并时,但你也表明未来的开发develop从与主线相同的代码开始master

(实际上,几乎不需要这样做——只需选择其他两种方法中的一种——但如果你这样做,你就会得到。)


5我说应该是因为任何操作 Git 的人都可能在这里强加某种差异。不过,这样做通常是个坏主意。如果您正在检查一些您知之甚少的外部 Git 存储库,请记住这一点:如果您看到这种模式,您可以比较合并树MN查看是否有人做了一些奇怪的事情。


推荐阅读