在这种情况下推送提交后可以变基吗?

Is it okay to rebase after having pushed commits in this instance?

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

我知道一般情况下,如果一个分支的提交已被推送到其他人正在从中拉取的远程存储库,那么您不应该对其进行变基。但是在合并之后,开发分支和主分支本质上是相同的,只是头部不同。但是如果我在合并后立即将我的开发分支重新定位到主分支,我相信这两个分支将有一个共同的头(合并提交)并且开发分支中的提交 ID 的 none 将会改变。通过这样做,没有人受到伤害,我可以在以后进行合并而不会自动被迫创建合并提交。

这样合理吗?

如果在 master 发布时您没有对 development 的任何新提交,您所描述的相当于删除 development 分支,并从 master 重新分支。

通过变基,您将孤立被精心挑选到 master 上的原始提交,因此如果 development 下游的任何人已固定到提交哈希,可能会产生一些后果。它还会 make life difficult for anybody downstream that had also checkout out the pre-rebased development 分支并尝试拉动。

我认为这是合理的,但它应该是您开发过程中的一个沟通部分,因此上述结果不会让任何人感到惊讶。

TL;DR

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

(旁注:使用 git merge 而不是 git cherry-pick 有一种不同的、通常更好的方法来解决修补程序问题,尽管它与您的意愿和能力无关这种变基。它也有自己的缺点。有关这些的更多信息,请参阅 Stop cherry-picking, start merging. Be sure to read the coda: Stop merging if you need to cherry-pick。)

Long:三个要点

我不确定谁会阅读这个较长的部分,但无论谁阅读,都有三个关键要点。第一个是更复杂的 rebase 规则,就在下面。第二个是 fast-forward 最终是关于 可达性,整个 web-site 致力于这个想法,在 Think Like (a) Git。这值得一读。最后一点是 rebase 通过 copying 提交来工作,然后放弃原件以支持 new-and-improved 副本。正是这种放弃,以及伴随它的非 fast-forwarding——这是停止使用过时提交所必需的——带来了导致简单 don't rebase shared b 的所有问题运行ches 规则通常有点 简单。

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

重新设置共享 b运行ches

I know in general you should not rebase a branch that has commits that have been pushed to a remote repository that others are pulling from.

这是简单的规则。有一个更复杂的变体说 rebase 是可以的,只要所有使用 / will-be-using / are-using 这个 b运行ch 的用户都可以。

定义术语和 the gitglossary

But following the merge, the development and master branches are essentially identical except for having different heads. But if I rebase my development branch onto the master branch immediately following the merge, I believe the two branches will have a common head (the merge commit)

这可能是真的,但仅限于像您遇到的那种微不足道的情况。此外,这里还有一些术语问题。特别是,我们必须定义“头”。如果按照 the gitglossary 的方式去做,我们需要一个不同的 tern:我们需要开始使用 tip commit 来代替。这是他们对 headb运行ch[ 的定义=551=],间接地,提示提交:

head

A named reference to the commit at the tip of a branch. Heads are stored in a file in $GIT_DIR/refs/heads/ directory, except when using packed refs. (See git-pack-refs[1].)

branch

A "branch" is an active line of development. The most recent commit on a branch is referred to as the tip of that branch. The tip of the branch is referenced by a branch head, which moves forward as additional development is done on the branch. A single Git repository can track an arbitrary number of branches, but your working tree is associated with just one of them (the "current" or "checked out" branch), and HEAD points to that branch.

请注意,顺便说一句,HEAD(字面全大写)与“head”(全小写)非常不同。这种区别在 case-folding 系统(如 Windows 和 MacOS)上变得模糊甚至消失,但在其他方面很重要:只有一个 HEAD,但每个 b运行ch 名称都是一个“头”。

and none of the commit ids in the development branch will change

在大多数情况下,所有 development 独有的提交——尚未在 运行ge 中可从更新后的 master 访问的所有提交—— 被复制,所有由此复制产生的新提交 具有不同的哈希 ID。如果这些提交的列表为空,则此复制过程将复制零个提交,并且所有这些零都将具有新的哈希 ID,但由于它们为零,所以这无关紧要。 :-)

在屏幕、纸张或白板上以视觉方式表示此内容

要理解上面headb运行ch的定义是什么意思,先简单画一下什么一系列提交——松散地,“a b运行ch”——看起来像 Git。我们知道:

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

  • Git 通过哈希 ID 查找提交(好吧,通常 objects,包括提交)。哈希 ID 是一个丑陋的大字符串,例如 7ad088c9a811670756a3fb60ac2dab16b520797b.

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

  • 每个提交存储其 parent(如果提交是普通提交)或 parents(至少两个,通常恰好两个,如果提交是合并提交)。

  • 任何提交的内容一旦提交,就永远无法更改。 (事实上​​ ,没有 Git object一旦做出就可以改变。2

因此,如果我们从 最新的 提交开始,我们可以让 Git 跟在每个 parent 之后,一次一个,向后:

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

我们只需要将 最新 提交的原始哈希 ID 存储在某处,以便 Git 可以查找哈希 H 并将其用于find hash G 以查找提交以查找 hash F,依此类推。 (最终,Git 将到达第一个提交,其中 没有 parent,因为它不能有,这让 Git停止。)

为了画图,由于commits的内容不能改变,所以我们可以用线连接起来,只要我们内部记得,我们只能向后(从较新的提交到较旧的提交)。新提交会记住它们的 parent,但是当 children get crea 时,现有提交不能将它们的 children 添加到它们中ed,因为为时已晚:到那时 parent 将永远冻结。那么我们画一个稍微复杂一点的图:

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

在这里,commit I 的 parent 是 commit Hname master 包含原始哈希 ID H 本身;这就是 Git 可以 git checkout master 的方式。 名称 development 包含原始哈希 ID K。这些是 latest 提交——“heads”或 b运行ch tips,使用 gitglossary 使用的定义.


1Git 通过向每个提交添加 date-and-time-stamp 来确保这一点,这样即使您强制 Git re-commit 与您一分钟前完全相同的内容,re-using 您的姓名和电子邮件地址以及日志消息 - 相同的 parent 哈希—时间戳不同。这确实意味着如果你什么都不改变,你实际上 不能 强制 Git 每秒进行一次以上的提交,但这是一个限制,我准备生活。 :-)

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


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

Gitglossary 尝试使用名称 head 作为 b运行ch 名称本身,单词 b运行ch 表示 b运行ch 的 tip 提交加上部分或全部提交 behind 该 tip 提交,以及 tip commit 用于提交 HK。用户通常将它们混为一谈,将所有三个都归为 b运行ch。他们甚至可以使用同一个词——“b运行ch”——来指代诸如 origin/master and/or 这样的名称的提交。 Gitglossary 试图将其称为 remote-tracking branch。我发现这个术语会引起混淆,并且一直在使用 remote-tracking name,但我不确定它是否有很大的改进。

下面供参考,我自己的条件是:b运行ch name for a name like master, remote-tracking nameorigin/master 这样的名称,tip commit 的使用方式与词汇表完全相同,而 DAGlet 用于提交及其链接的集合,通常通过选择最后一次提交并向后工作来找到。

添加提交

到头来,我们call these, as long as we all understand what each other is talking about是什么并不重要。不幸的是,在实践中,人们在最后一部分遇到了麻烦。那么我们来说明一下添加新commit的过程。

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

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

现在我们开始工作,导致 git commitgit cherry-pick。我们开始于:

git checkout master

到selectnamemastercommitH,为了实现这个, Git 将 HEAD(全部大写)附加到 master:

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

并同时将提交 H 提取到索引和 work-tree 以便我们可以处理/使用它。

现在我们做一些工作运行git commit,或者运行gitcherry-pick<em>一些事情</em>,例如。 运行ning git commit 或其他使 new 提交的 Git 命令的行为,使 Git 更新 name 以便它现在拥有 latest 提交哈希 ID。我们的新提交将采用下一个字母 L(或者实际上,获取一些丑陋的大哈希 ID),我们将拥有:

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

添加合并提交

现在请记住 Git 工作 向后 ,一次一个提交。如果我们从 K 开始,我们将访问提交 K,然后是 J,然后是 IH 以及 G 等等,跳过 L。如果我们从 L 开始,我们将访问 L,然后是 HG 等等,跳过整个 I-J-K 链。因此,包含 both 的唯一方法是从一些 new 提交向后工作,将两者都用作其 parents。这是一个 merge commit,我们可以通过 运行ning git merge develop:

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

合并提交 M 两个 parent。 most-distinguished一个是它的第一个parent,也就是L,因为L HEAD 在我们 运行 git merge 时提交。这意味着如果我们使用 DAGlet,我们从 M 开始,或者任何后来的提交让我们得到 t M,并向后工作,我们将 跳过 来自 develop 的提交。这通常正是我们想要的:所做的所有提交直接master.

上工作

Fast-forward 操作

在 Git 中,我们可以随时将我们喜欢的任何 b运行ch 名称(新的或现有的)指向当前存在的任何提交。所以现在我们有:

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

我们可以创建一个新名称,例如 zorg,以指向提交 LHJ 或任何我们喜欢的名称,无论出于何种原因。让我们无缘无故地选择提交 J,并通过在我们创建 zorg:

期间或之后执行 git checkout zorg 来使其成为 HEAD
...--G--H--L------M   <-- master
         \       /
          I--J--K   <-- develop
              .
               .....<-- zorg (HEAD)

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

现在让我们强制移动 zorg 指向 L,同时更新我们的索引和 work-tree,使用 git reset --hard <hash-of-L>。现在我们有:

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

如果我们从 zorg 开始并向后工作,我们会得到哪些提交?很明显,序列...--G--H--L请注意,无法再从 zorg 访问提交 J

现在让 zorg 指向提交 M,就像 master 所做的那样:

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

现在可以访问哪些提交?我们让 Git 跟在 parent 个 M 之后,这样我们就得到 ...--G-H-(L and I-J-K)-M。因此,对于这个特定的移动,无论我们是从 L 还是 J 开始,我们仍然是能够达到 所有 我们之前可以达到的提交,加上一些新的提交。

Fast-forward也适用于推送和获取

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

较早的从 JL 的移动是 而不是 和 fast-forward,因为 J 不是L。事实上,两个提交都不是另一个的祖先,因此从 J 到 L 或 vice-versa 的任何移动都是非 fast-forward 操作。 Fast-forwards 发生在从提交到其后代之一的移动时。 (因为 Git 很难测试,它实际上会以相反的方式检查:你已经给了它后代提交,所以 Git 向后工作以查看它是否从那里找到 parent .)

特别地,假设,在我们让 zorg 指向 J 之后,我们 运行:

git push origin zorg

这会让我们的 Git 在 origin 呼叫另一个 Git 并要求他们创建 他们自己的 b运行ch 命名为 zorg,指向提交 J.3 由于这是他们的 new 名称,他们会说好就去做。

现在我们将在本地执行 git reset --hard 以强制 zorg 指向 L,然后再次尝试 git push。这一次,他们 有一个 zorg,他们的标识提交 J。提交 L 不是 J 的后代,所以这个 git push 会失败,出现 non-fast-forward 错误。我们必须使用 git push --force 让他们接受我们的请求——现在是一个命令——他们以这种非 fast-forward 的方式移动他们的 zorg

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

git push origin zorg

同样,这一次,他们会很高兴地接受请求。那是因为 thisJL 移动到 Mis a fast-forward 操作。所以他们最终会用他们的 zorg 指向提交 M,匹配我们自己的情况。


3如果 origin 还没有提交 J,我们的 Git 会向他们发送 J 和任何必要的parent-commits 还有。


Cherry-pick 和变基

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

所以 git cherry-pick 本质上将提交 变成了 一组更改,通过 运行ning git diff 在 parent 之间该提交,以及该提交本身。4 一旦我们有了更改,我们就可以将它们应用于其他提交,在完整提交集合中的其他地方,以创建一个新的和不同的承诺,就像我们的承诺L 以上。我们将 Git 复制 cherry-picked 提交的日志消息,但新提交的哈希 ID 将不同。

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

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

如果我们 运行 git rebase master 现在 ,Git 将首先列出可从 HEAD 访问的提交——即, ...-G-H-I-J-K——然后从master...-G-H-L中减去可达集合,留下集合I-J-K。然后它将继续 copy I 到 new-and-improved I',就好像 git cherry-pickI' 去在 L:

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

(这发生在“分离的 HEAD”模式下,这就是 HEAD 直接指向新提交 I' 的原因。)然后重复 JK :

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

作为它的最后一个技巧,git rebase 强制名称 develop 移动,以便它指向最终复制的提交,在这种情况下,K' 和 re-attaches HEAD 搬到 develop:

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

请注意,在这种情况下,运动是非fast-forward。如果 origin Git 有一个指向 Kdevelop,我们现在尝试将 K'(和 parents)发送到 origin 并要求他们将 他们的 develop 设置为指向 K',他们将以非 fast-forward 错误拒绝。


4git cherry-pick 的实际机制是使用合并。合并的基本提交是提交 cherry-picked 的 parent,所以我们确实得到了这个差异,但我们也得到了第二个差异,反对 HEAD,然后是完整的 three-way合并。这个合并通过一个普通的 non-merge commit: 来结束,也就是说,cherry-pick 做 git merge, 的动词部分合并,但不是名词部分,因为它只是进行普通的(non-merge)提交。

不过,除了棘手的情况外,您可以将其视为应用 parent-vs-child 差异,就好像它是一个补丁。事实上,某些类型的 git rebase 执行后者,而其他类型的 git rebase 在内部使用 git cherry-pick!这没有特别充分的理由:只是历史事故,因为 git cherry-pick 最初是在 的情况下 使用适当的 three-way 合并实现的。当发现这不适用于棘手的情况时, git cherry-pick 本身得到了改进,但旧的 git rebase 继续使用旧方法。所有较新的 git rebase 都使用新的 cherry-pick(因为它几乎总是 the-same-or-better),但是为了向后兼容,最旧的 rebase 形式仍然使用旧的方式。


如果我们先合并,那就是fast-forward!

但是假设我们等待,让 merge commit M 先进入,这样我们就可以从这个开始:

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

然后我们做:

git checkout develop
git rebase master

这一次,当 Git 列出 developnot 可从 master 访问的提交时,有 none。从 M,git 通过其 second-parent 到达 K,因此 master 已经拥有所有提交。因此,rebase 操作首先复制 no 提交,将所有零提交放在 M:

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

git rebase 的最后一幕是强制名称 develop 到最后复制的提交,在这种情况下实际上意味着 M,并且 re-attach HEAD:

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

如果我们 运行 git checkout develop; git merge master 我们会得到完全相同的效果: Git 会 将名称向前发展 ,在fast-forward 操作,使 develop 指向提交 M。我们现在可以 git push origin develop 因为 他们的 developK 并且移动到 M 是 fast-forward,这是允许。

如果我们现在在 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)

两种方法的区别

这里的关键区别是如果我们fast-forwarddevelopN的parent是K 而不是 M,这意味着我们可以从 ONK 到 [=73] 线性跟踪 develop 的历史=] 等等。合并到位后,我们需要知道从 ON 再到 M,然后向下 second parent M(忽略第一个)到 KJ 等等。

如果你要对历史进行大量检查——也许是为了 bug-hunting-and-fixing,也许只是出于对历史的兴趣——straight-line,从不 fast-forwarded 方法给你一个优势,你可以使用 --first-parent(Git 标志说 * 在合并时,只跟随第一个 parent)让你未来的工作变得容易。如果您永远不会这样做,那么这种差异根本没有任何区别。

还有一个选择,虽然用处不大,但值得考虑。假设,在进行合并 M 之后,您在 develop 上进行 true 合并,如下所示:

git checkout develop
git merge --no-ff master

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

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

其中N第一个parent是K,第二个是M 哈希 ID两次提交的 会有所不同,而每个提交的 保存的快照 应该相同。5 这意味着您可以执行与以前相同的 history-searching 技巧,当您根本没有合并时,但您还表明 develop 的未来开发从与主线相同的代码开始 master.

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


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