TL;DR
您几乎可以肯定确实想要git merge --squash,但使用分离的 HEAD 后跟分支移动操作完成,例如:
$ git checkout upstream/master
$ git merge --squash yourbranch
$ git checkout -B yourbranch # or git branch -f yourbranch HEAD
(但请参阅下面的长答案)。
长
我知道我可以尝试合并 --squash(例如,https://stackoverflow.com/a/5309051/947889),但这会创建一个单独的分支。
使用git merge --squash 不会创建一个单独的分支(它只是创建一个提交)。但如果这样做也没关系,因为 Git 的分支基本上没有意义:您可以随时以您喜欢的方式更改或重新排列您的分支名称。在 Git 中重要的不是分支——或者更准确地说,分支名称——而是提交。 Git 存储库是提交的集合,以及一些辅助信息。可以更改辅助信息。提交不能。 分支名称是这个可变辅助信息的一部分。
每个提交都有自己独特的大丑哈希 ID。这些哈希 ID 是提交的真实名称。每个提交都是完全的,完全只读的。您不能更改任何现有的提交。提交的哈希 ID 表示 that 提交,没有其他提交。但是这些哈希 ID 的问题是它们看起来是完全随机的。您将如何找到正确的哈希 ID?
嗯,一方面,每个提交都存储了一些其他较早提交的哈希 ID。这些是这一特定提交的 父 提交。大多数提交只存储一个父哈希 ID。
当一个提交存储其他一些较早提交的哈希 ID 时,我们说后来的提交指向较早的提交。 (请注意,没有提交可以存储 later 提交的哈希 ID,因为在创建较早的提交时,该稍后提交的哈希 ID 尚不存在,并且一旦创建,就没有提交可以永远更改。)因此,当您有一个又一个创建的一长串提交时,每一个都将 backwards 指向上一个提交。如果我们把它画出来——使用大写字母代表真正的提交哈希 ID——我们会得到一张看起来像这样的图片:
... <-F <-G <-H
这里H 是最新 提交,带有一些哈希ID H。实际提交本身,现在一直被冻结,包含先前提交 G 的原始哈希 ID。提交G 包含早期提交F 的原始哈希ID,而后者又包含另一个早期提交哈希ID,依此类推。
这是 branch names 的用武之地。分支名称仅包含 last 提交的哈希 ID,我们想说的是“在分支上”。因此,如果H 是某个分支上的last 提交,我们只需将其哈希 ID 放入分支名称中。这个名字现在指向提交H,就像H指向G:
...--F--G--H <-- branch1
我们现在可以创建另一个分支名称,例如base,并使其指向现有的提交F(使用git branch base <hash-of-F>):
...--F <-- base
\
G--H <-- branch1
我们必须绘制提交图——F-G-H 线——有点不同以挤入名称,但 提交 在此操作中完全没有改变。我们所做的只是创建一个指向F 的新名称,以便F 是分支base 上的最后一次提交。名称branch1 仍然标识H,因此H 是分支branch1 上的最后一次提交。
让我们删除base (git branch -d base),并创建一个指向H 的新名称feature。我们也会确保git checkout feature,以便HEAD 附加到名称feature:
...--F--G--H <-- branch1, feature (HEAD)
(例如,我们可以使用git checkout -b feature branch1 来做到这一点。)现在我们将以通常的方式进行新的提交。这个新的提交获得了一个新的、唯一的哈希 ID,但我们称之为I。 Git 现在所做的是移动名称feature,使其指向新的提交I。新提交I 的父级是H,因此I 指向H:
...--F--G--H <-- branch1
\
I <-- feature (HEAD)
这就是分支:它们只是指向特定提交的名称或标签,具有特殊技巧,当您 git checkout 其中一个时,您不仅可以使用该提交,您还安排 next git commit 操作以更新名称。
您在 Git 中所做的几乎所有事情都是关于创建或获取提交,然后使各种名称指向这些新创建或获得的提交中的特定名称。分支名称只是让您找到一些特定的提交。根据定义,名称是该分支上的 last 提交。如何移动分支名称并不重要。只要该名称存在,它就指向某个提交。该提交是分支上的最后一次提交。移动名称,您已经更改了哪个提交是分支上的最后一个提交。您没有更改任何 commits — 它们都还在那里 — 您只是更改了 which 提交是 该分支的最后一个提交 .
这让我们变基
git rebase 所做的是复制 一些提交,然后移动分支名称。例如,考虑这个图表:
...--F--G--H <-- master
\
I--J <-- feature
您首先要在复制后使用您想要移动的名称的git checkout:
$ git checkout feature
然后运行git rebase 命令。它需要一些参数,文档称之为--onto 和upstream 参数。这些指定了一个 target 提交,这是副本应该去的地方,以及哪些提交应该被复制:
$ git rebase master
你可以只给出一个参数,就像这里一样——git rebase master——在这种情况下,目标提交和提交集都可以使用那个名称找到。这里,目标提交是提交 H,要复制的提交集是提交 I 和 J。
rebase 命令现在复制每个提交,就像使用 git cherry-pick 一样。副本获得新的哈希 ID。这里有很多棘手的极端情况,您可以使用git rebase 的选项,但在这种情况下,它很简单,我们最终会得到一个I 的副本,它有一个新的和不同的哈希ID,它我们将调用I',以及J 的副本,我们将调用J'。 I 和 I' 之间有两个很大的区别,我们在图中可以看到,I' 的父级不是G,而是H。提交副本J' 也是如此:
I'-J' <-- HEAD (detached)
/
...--F--G--H <-- master
\
I--J <-- feature
(您在此图中看不到的区别在于,使用提交I' 保存的快照可能与使用I 保存的快照不同,因为cherry-pick 有效地接受了更改从G 到I 并将该更改应用于H 中的快照,而不是G。)
复制这两个提交后,通过 移动分支名称来完成 rebase:
I'-J' <-- feature (HEAD)
/
...--F--G--H <-- master
\
I--J [abandoned]
提交I 和J 会发生什么?答案有点复杂,但我们现在需要的是:什么都没有。 Git 将它们保留一段时间,以防你认为 rebase 是一个坏主意。但他们已经变得很难找到。新提交 J' 很容易找到:名称 feature 找到它。提交I' 很容易找到:我们只需转到J',然后按照它的向后箭头指向I'。但是提交J 的哈希ID 是什么?它曾经在名称feature 中,但现在不再是了。如果你能找到J,你可以用它来找到I,但除非你把J的哈希ID保存在某个地方,否则这可能有点棘手。1最终——通常从现在起 30 天后的某个时间——如果你没有使用其他名称(例如其他分支或标签名称),Git 会完全回收它们,以确保它们存在。
1现在,它真的很容易找到:Git 将它保存在名称 ORIG_HEAD 下。但其他命令将替换ORIG_HEAD 的哈希ID。不过,还有第二种方法可以找到它,使用 git reflog,这就是默认情况下让提交至少再持续一个月左右的原因。
为什么变基复制太多
合并提交 有两个(或更多)父级。当我们让 Git 遵循从提交到其父级的向后指向链接时,它通常遵循 所有 链接。因此,从合并提交开始,Git 会沿着 两个 路径。
您的真实提交图一点也不简单,但您的问题示例提交图可能还不错。它可能看起来像这样:
Y--Z <-- upstream/master
/
...--o--o--o--W--o--o--o--X <-- upstream/dev
\ \ \
A--B-----M--C--D--N <-- yourbranch (HEAD)
这里,“开启”(可从)yourbranch 无法从 upstream/dev 访问的六个提交,即提交 X,是 A-B-M-C-D-N。让我们更仔细地看一下:
1234563 987654422@.
1234563 (通过N 的链接)。从D 到C,然后到M,然后到both B 和一些未命名的提交。我们也从X 获得了那个未命名的提交。
1234563 /p>
如果我们像这样运行git rebase:
git checkout yourbranch
git rebase upstream/master
您的 Git 将列出所有可从 N 访问的提交,而 不 可从 Z 访问。您将upstream/master 用作目标(--onto)和上游(“不要复制可从Z 访问的提交”)。这意味着 Git 不会复制 W 和更早的提交——它们可以从 Z 访问——但 将复制 o-o-o-X 提交以及 A-B-C-D . Rebase 通常会丢弃所有合并提交,因此它会同时丢弃 M 和 N,但你只剩下 8 个提交被复制而不是 4 个。
您可以减少 rebase 复制的提交次数
你可以做的一件事就是运行:
git rebase --onto upstream/master upstream/dev
这将 什么不要复制 参数与 将副本放在哪里 参数分开。我们仍然告诉 rebase:将副本放在提交 Z 之后,但是这一次,我们告诉 rebase:*不要复制来自 X 的提交。所以 Git 列出了提交 A-B-M-C-D-N 作为要复制的提交,然后抛出 M 和 N 因为它们是合并的,剩下的就是复制 A-B-C-D 的工作。
如果这个 rebase 一切顺利,你会得到这个:
A'-B'-C'-D' <-- yourbranch (HEAD)
/
Y--Z <-- upstream/master
/
...--o--o--o--W--o--o--o--X <-- upstream/dev
\ \ \
A--B-----M--C--D--N [abandoned]
您现在可以从中创建拉取请求。
你想要一个提交
我想 [结束] 一次提交。
也就是说,如果我们绘制想要的结果,它可能看起来像这样:
ABCD <-- yourbranch (HEAD)
/
Y--Z <-- upstream/master
/
...--o--o--o--W--o--o--o--X <-- upstream/dev
\ \ \
A--B-----M--C--D--N [abandoned]
ABCD 是一个单一的提交,如果你第一次 rebase,然后进行第二次 rebase 以将它们全部压缩为一个提交,就会产生效果。
要到达那里,您可以使用以下命令序列:
$ git checkout upstream/master
$ git merge --squash yourbranch
$ git checkout -B yourbranch # or git branch -f yourbranch HEAD
第一个git checkout 为您提供一个分离的HEAD,指向upstream/master 标识的提交,即提交Z。如果您愿意,可以使用临时分支名称:
$ git checkout -b temp upstream/master
这给了你:
Y--Z <-- upstream/master, HEAD
/
...--o--o--o--W--o--o--o--X <-- upstream/dev
\ \ \
A--B-----M--C--D--N <-- yourbranch
或:
Y--Z <-- upstream/master, temp (HEAD)
/
...--o--o--o--W--o--o--o--X <-- upstream/dev
\ \ \
A--B-----M--C--D--N <-- yourbranch
git merge --squash 使用您想要的内容构建一个新的非合并提交:
ABCD <-- HEAD
/
Y--Z <-- upstream/master
/
...--o--o--o--W--o--o--o--X <-- upstream/dev
\ \ \
A--B-----M--C--D--N <-- yourbranch
(或将HEAD 附加到名称temp 的同一绘图,现在已移动到指向ABCD)。
最后一步是将名称 yourbranch 从提交 N 拉出(yoink?)并使其指向新提交 ABCD,这是 git branch -f 或 git checkout -B 的来源。主要区别这两者之间是HEAD是否附加到yourbranch之后:
ABCD <-- HEAD, yourbranch
/
Y--Z <-- upstream/master
/
...--o--o--o--W--o--o--o--X <-- upstream/dev
\ \ \
A--B-----M--C--D--N [abandoned]
或:
ABCD <-- yourbranch (HEAD)
/
Y--Z <-- upstream/master
/
...--o--o--o--W--o--o--o--X <-- upstream/dev
\ \ \
A--B-----M--C--D--N [abandoned]
(在 HEAD 和 yourbranch 的 reflog 中的最终结果方面存在一些细微的其他差异,但我们在此并未真正介绍 reflog)。
(我不会讨论 git merge --squash 是如何工作的,因为这已经很长了。)