Git: squash是什么?为什么要挑选一个较旧的提交?

3

最新提交应该是逐步包含之前所有更改的提交,对吗?

如果是这样,那么-squash有什么用,只是将所有提交压缩成一个以清理历史记录?

squash实际上做的是删除除最新提交外的所有提交?

对于cherry-picking也是如此。如果最新提交包含之前所有更改,那么为什么需要选择旧的提交?

2个回答

12

我不确定你是否有误解。很多老的源代码管理系统将每个提交(或称为check-in等)存储为“更改”。Git并不是这样:每个提交都具有完整的文件,作为类型为“blob”的仓库对象存储。1

“blob”对象位于底层,由“tree”对象指向(它们保存文件/目录的名称和可执行位),而树则由“commit”对象指向。(还有一个最后一种仓库级别的对象类型,“注释标记”,通常指向一个提交。)因此,给定提交SHA-1和仓库路径(例如dir/file),git首先提取提交对象,然后导航到需要拥有一个名为“dir”的条目的树。该条目需要导向另一个树,该树必须有一个名为“file”的条目,然后该条目应该是一个blob,那就是该提交中出现的dir/file的版本。

分支和标签名称(如master)只是人类可读的单词,给出底层仓库对象的“真实名称”SHA-1。提交对象在其中具有父SHA-1值,允许git动态提取提交图。

当然,您仍然可以从git中获取change-set。它只是每次都会动态计算。

假设我们有这个提交的图形,其中我使用一个大写字母来表示每个40个字符的SHA-1,以及一些分支名称:

A - B - C - D      <-- master
      \
        E - F      <-- branch
master指向提交记录D(实际上可能是dcfaa9d9767a010c143ffd42b01b84d2abb4cffc )。该提交记录有一个父提交C(实际上是222c4dd…)。C有一个父提交BB有一个父提交A,而A根本没有父提交-它是一个根提交。
无法通过从提交D开始并向后通过任何父项来到达提交F;只能通过其分支名称branch到达。 F的父提交是EE(唯一)父提交是B,因此,从F开始,我们可以通过B向后工作到A
这就是常规合并产生作用的地方:它们操作提交图。如果我们“将branch合并到master”-让我们暂时这样做,然后再撤消:
$ git checkout master
Switched to branch 'master'
$ git merge -m regular-merge branch
[snip]

我们创建一个新的合并提交M

A - B - C - D - M  <-- master
      \       /
        E - F      <-- branch

这个(真实的、非“压扁”)合并有两个父提交,DF。(重要的是,D 是“第一个”父提交:这告诉你哪个提交最初是“在 master 上”的,而不是来自于 branch。)因此,现在通过 branch 可以到达的两个提交(从 F 开始向后遍历),也可以在 master 上找到。

那么各种文件内容怎么处理呢?这取决于执行合并的人。如果使用 Git 的自动合并,在没有冲突变更的情况下,“将 branch 合并到 master” 将得到预期结果。但是,你可以使用 -s ours 进行合并,以丢弃 EF 中的变更内容。仍然会得到一个合并提交 M,但其树与提交 D 中的树相同。2

随时可以要求 Git 生成从一个提交到另一个提交的变更集。所以,如果想查看 BE 之间的差异,可以找到它们各自的 SHA-1 并执行:

git diff <sha1-for-B> <sha1-for-E>

要查看 EF 之间发生了什么变化,您只需使用它们各自的 SHA-1 值即可;要查看“分支 branch 上发生了什么事”,请使用 BF 的 SHA-1 值。

作为一种非常方便的便利,要查看某个提交与其父提交之间发生了什么 —— 在这里我们不用担心合并,因为它们有多个父节点 —— 我们可以直接使用例如:

git show <sha1-for-F>
git show 命令将(除其他功能外)查找 F 的父级,并在 EF 之间运行差异,以显示更改内容。
我们可以使用分支名称而不是完整的(或部分的)SHA-1 来代替编写它们。
git show branch

通常情况下,如果原始的SHA-1可用,则分支名称也可以使用(但反之不一定成立)。

自然地,由于SHA-1是笨重的,因此有更多的命名方式;但现在让我们忽略这些细节,最后来看一下“压缩合并”和“挑选提交”。

让我们“撤消”对master的合并操作,使得masterbranch再次分离:3

$ git status   # just checking! "git reset --hard" could lose work
# On branch master
nothing to commit, working directory clean
$ git reset --hard master^
[snip]

我们现在回到这个提交图:

A - B - C - D      <-- master
      \
        E - F      <-- branch

假设我们有一个解决了一个讨厌的bug的变更F,并且我们想要在master分支中复制它。我们尝试使用git merge branch将其合并,但是这也带来了还没有准备好的变更E

因此,现在我们只需要“挑选”提交F

$ git cherry-pick branch
[snip]

通常情况下,我们可以使用分支名称来确定该分支尖端的(单个)提交。

这告诉git收集EF之间的更改,就像git show一样。但是,它不会向我们显示这些更改,而是尝试将这些更改打补丁到当前(HEAD)提交中。由于我们在master分支上,HEAD提交是提交D。所以这提取了从EF的更改,将其应用于D,并且如果成功,则进行新提交。让我们称之为P(Pick):

A - B - C - D - P  <-- master
      \
        E - F      <-- branch

这里的PF内容可能会非常不同,但是从DP变化与从EF变化是相同的。使用git show mastergit show branch命令输出的差异应该非常相似-行号可能会有所改变(或者甚至有很大的变化),但是显示的更改应该是一样的。

让我们像之前丢弃合并M一样丢弃P4。请注意,我们仍然在分支master上,并且它是干净的(没有任何操作,也没有需要提交的内容,尽管这次我没有用git status查看):

$ git reset --hard master^
[snip]

这一次,让我们进行一个“压缩合并”(squash-merge)。

这个 git merge 命令的操作与常规合并非常相似,只不过它不是像合并提交 M 那样使用两个父提交,而是设置了一个 "压缩合并" 提交。 让我们称其为 S,它只有一个父提交。 由于它不会真正执行提交(压缩意味着--no-commit选项),因此我们必须显式地进行提交的步骤:

$ git merge --squash branch
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested
$ git commit -m squash-merge
[snip]

现在我们有这个:

A - B - C - D - S  <-- master
      \
        E - F      <-- branch
S代表的提交中,tree(即所有文件的集合)与常规合并所得到的树相同。在这种情况下,这相当于将BF之间的git diff作为单个补丁应用于提交D的树内容。5

换句话说,S包含了"BE之间的更改以及EF之间的更改",并应用于提交D。但它只有一个父提交。正是这种提交图形的差异使其成为“压缩合并”,而不是常规合并。

当然,如果常规合并无法完成——因为提交E未准备好合并到master——那么压缩合并也不会成功。因此,在这种情况下,使用 cherry-pick 是明智的选择。

重要说明:请注意,每次我们对分支master进行操作添加新提交时——合并M、挑选P或压缩提交S——分支master都会自动向前移动到指向最新的提交。这就是在 Git 中区分分支(或“本地分支”)与其他标签的方式。分支名称只是一个提交 ID,随着添加新提交,它会自动移动。标签名称的工作方式与分支名称完全相同,只是它们不会移动。


1好吧,blob 具有它们的副本,但是它们被压缩(使用 deflate 压缩),只要它们是“松散对象”。最终,松散对象会被“打包”,以节省更多的空间,并且可以对包进行“增量压缩”,这样就可以获得旧增量型 SCM 中提供的所有空间节省效果——实际上还要更多,因为任何一个对象都可以对任何其他对象进行压缩,至少在理论上。文件 foo 不必只针对“文件 foo 的先前版本”进行压缩。

2这主要是作为记录“关闭”分支的一种方法,而不是简单地舍弃它。

3reset --hard 做了两件事情:把工作目录修改回到提交 D 时的状态,同时将分支标签 master 再次指向提交 D。这里的简单后缀符号 ^ 告诉 Git 跟随“第一个父提交”(first parent)。另一种主要的语法,“向前回溯 N 次提交”,例如master~3 表示回溯3个提交,也是跟随“第一个父提交”的,所以从合并提交 M 开始,master~3 回溯到 DCB。但是,一旦重置生效,master 就再次指向提交 D,因此往回走3步会先到 C,然后是 B,最后是 A

4 顺带说一下,你或者说你应该会好奇:“我们随意扔掉的这些提交会发生什么?” 答案是:它们在仓库中通过“reflog”标记存活,直到reflog条目过期。默认情况下,若条目可达,则 reflog 条目在90天后“过期”——为了避免这个脚注变得太长而变得过于技术化,这里不过多展开;如果条目是“不可达”的,则在30天后过期。这些提交都是“不可达的”,因此它们将在大约一个月后被垃圾回收(garbage-collected),以及这些提交使用的任何树和 blob。

5假设您没有像将 ours 策略应用到合并提交一样做一些奇怪的事情,但那是无用的。(此外,与不属于 Git 的 patch 命令不同的是,Git 在执行合并、挑选某些提交等操作时是聪明的,因为它通常可以判断您是否已经应用了特定的补丁,并且不会尝试重复应用它。)


1
总之,当我们在D时,cherry-pick不会带走E的更改,但是squash会。 - leoly
@AkemiHomura:根据上述设置,是的,确切无误。 - torek

2
是的,压缩会折叠提交历史记录。您可能会这样做,例如,如果您一直在单独的分支上开发功能,并且不想用数百个提交污染主干分支。另一方面,您可能只想压缩要关闭的分支,因为您失去了非压缩合并的跟踪优势(在“正常”合并的情况下,您可以查看树并查看合并来自哪个提交;但在“压缩”合并的情况下则不行)。
通常,您从其他分支中挑选更改;您当前的分支将不包含您想要挑选的提交。

您当前的分支不包含您想要挑选的提交。是的,抱歉没有明确说明我是在谈论另一个分支。那么为什么要挑选另一个分支的旧提交? - microwth
通常情况下,这是因为您尚未将分支合并到主线;也许在该分支上有其他提交,您不想要。 - mpontillo
1
实际上,您可以挑选一个提交以“重新引入”它,例如,在撤消提交后的一段时间内,您可以使用-n特别选取它进行修复并恢复。 - torek
@torek,没错;我总是把它看作“撤销一次撤销”,但这也可以;-) - mpontillo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接