有没有一种方法可以引用当前分离的HEAD的子提交?

6

我知道如何将父提交称为HEAD^

但是是否有可能以类似的方式引用子提交?


在 Git 中检出一个分支时,通常会落在该分支的 HEAD 上,因此没有子节点。因此,能够导航到任何一个 父节点 通常是令人满意的。您能详细说明一下为什么您认为需要这个功能吗? - Tim Biegeleisen
4个回答

7
答案既是否定的也是肯定的,或者说“这个问题毫无意义”,具体取决于你的意思。
你的问题特指“当前分离的HEAD的一个子提交”。这就是为什么这个问题没有意义,至少没有额外的信息。

正如Tim Biegeleisen在评论中指出的那样,当你按分支名称进行检查时,这会将您置于该分支的末端。这在Git中是定义上发生的,因为Git与大多数版本控制系统不同。在Git中,“分支”一词有些模糊:我们有分支名称,其被定义为“指向表示该分支末端的提交的标签”,以及在提交DAG中存在的分支和合并结构(我喜欢称之为“DAGlets”)。有关此内容,请参见“分支”到底指什么?。然而,所有这些的一个关键结果是,提交经常同时位于多个分支上

缺失的信息

当您指向提交DAG中的某个提交并询问子提交时,您正在做出假设。具体而言,您假定了一个特定的分支或一组分支。为了看到这是如何工作的,让我们来看看分支的演变。
运动中的分支
分支的顶端,根据定义,就是其末尾:在该分支上没有更多的提交。如果您运行git commit并创建一个新的提交,则分支名称会自动指向您刚刚创建的新提交。现在,该分支具有了一个新的最顶部的提交,并且 - 这是真正的关键 - 您现在在该提交上,该提交当然没有子项:它是一个新的无子项提交。它只能通过添加新的提交来获得新的子项。
这在视觉上是有意义的:在这里,我们处于某个分支的顶端,即分支master中的提交D:
                        HEAD
                         |
                         v
A <- B <- C <- D   <-- master

我们现在处于分支master上(HEAD指向master),并且master指向提交D
添加新提交的操作——成功执行git commit——意味着我们创建一个新的提交E,其父提交是D,Git会立即master也指向E
                             HEAD
                              |
                              v
A <- B <- C <- D <- E   <-- master

这些图表中的内部分支箭头总是指向左侧(时间倒流),因此我将在此处开始绘制没有内部箭头的提交记录。现在让我们为最后一个图表做到这一点:
A--B--C--D--E   <- (HEAD) master

现在,即使我们决定需要从提交D创建一个新的分支,关于孩子们的所有这些内容仍然是正确的。假设此时我们这样做:
git checkout -b feature master~1

这个操作是创建一个名为feature的新分支标签,直接指向提交D。提交E——master的顶端——仍然存在,但现在HEAD将指向feature,而feature将指向D
           E   <-- master
          /
A--B--C--D     <-- (HEAD) feature

提交 AD 现在同时存在于两个分支: masterfeature

如果我们现在在 feature 上进行新的提交,会得到如下结果:

           E    <-- master
          /
A--B--C--D--F   <-- (HEAD) feature

也就是说,F 现在是 feature 分支上最新的提交:HEAD 没有改变,而 feature 现在指向 F。提交 EF 分别位于一个分支上,提交 AD 仍然在两个分支上。

如果我们不想要 F,现在可以:

git checkout master

切换回 master 分支(并提交 E)然后:

git branch -D feature

删除 feature 分支并忘记关于提交 F 的所有内容。1现在提交 AD 只在一个分支 master 上。

1如果您改变主意,提交记录通常会在存储库中停留一段时间。通过“reflogs”,Git通常会记住“废弃”提交的ID至少30天。有一个用于HEAD的reflog,并保留提交F的原始SHA-1哈希值,因此您可以使用git reflog查找F并将其取回。

对于分支名称feature也有一个reflog,但当我们执行git branch -D feature时,我们让Git将其丢弃了。


运动中的匿名分支

在Git中,“分离头部”充当一种类似于未命名的分支。让我们使用之前相同的 AE(尚未添加 F),但只需使用 git checkout master~1 而不是 git checkout -b feature master~1,并绘制它。现在,不再是 HEAD 指向 feature 并且 feature 指向提交 D,而是直接将 HEAD 指向提交 D

           E   <-- master
          /
A--B--C--D     <-- HEAD

这是一个分离的 HEAD:HEAD 包含某个提交的原始提交哈希(ID,a123456...),而不是保存分支名称并让分支名称指向最新提交。尽管如此,在这种情况下,我们可以添加新的提交,就像之前创建提交 F 一样。由于我们原来的提交 F 仍然存在,我也会将其绘制出来,并使用 G 代替这个新提交。
           E    <-- master
          /
A--B--C--D--G   <-- HEAD
          \
           F    [abandoned]

所有这些意味着,就像当你在一个命名的分支上(如feature)一样,当提交D是当前提交时,它在当前分支上没有子提交(即使在master上有一个提交E,它有提交D作为父提交,而且还有一个被放弃的提交F作为子提交!)。

分离HEAD只是匿名分支

在Git中,处于分支状态和处于分离HEAD模式之间的关键区别在于,当你处于分支状态时,HEAD文件包含了分支的名称。也就是说,它"指向"分支名称,而分支名称指向提交,就像上面的图示那样。当你处于分离HEAD状态时,HEAD文件直接指向提交……也就是分支的尖端。没有分支名称;当前提交是匿名分支的尖端。

作为分支的尖端,它自动没有子提交。

恢复缺失的信息

你可能会对此提出反对意见:我在一个分支上,因此我的游离HEAD应该被认为是我之前所在的分支的一部分! 事实上,这是一个合理的反对意见。但你之前在哪个分支上?你只说了:“我目前有一个游离HEAD,并想找到一个子提交。”
假设你这样重新表述问题:“我有一个指向某个提交的游离HEAD。当使用分支(分支名称)B时,我想找到当前提交的子提交。”
现在我们有足够的信息了!由于Git的工作方式,我们必须从B的末尾开始——即分支名称B指向的提交——并从B向后退回到当前提交(如果有的话)。2有几种内置的方法可以做到这一点。
之前讨论的BVengerov的回答中使用了git log --children,或者等效地,git rev-list --childrengit loggit rev-list本质上是相同的命令,只是输出格式不同)。为了避免在只想获取哈希ID时烦扰于--pretty=format--no-patch,我们可以使用git rev-list
正如我们刚才提到的,git rev-list的工作方式是从某些提交点(通常是分支的末端或多个分支的末端)开始向后工作,跟随父指针。默认情况下,它只打印出每个提交的哈希ID:
$ git rev-list master
f8f7adce9fc50a11a764d57815602dcb818d1816
8213178c86d5583ff809c582d6727ad17b6a0bed
[snip]

使用--parents选项,它会打印每个提交及其父级的ID(为了避免需要[snip],我将添加-n 2以在打印两项后停止):
$ git rev-list --parents -n 2 master
f8f7adce9fc50a11a764d57815602dcb818d1816 8213178c86d5583ff809c582d6727ad17b6a0bed 08df31eeccfe1576971ea4ba42570a424c3cfc41
8213178c86d5583ff809c582d6727ad17b6a0bed 2a96d39824464c28f2f45f2f4a4d53d7c390c9eb

这里master的提示是一次合并,有两个父节点,因此在一行上打印了三个哈希值。

使用--children告诉git rev-list做一些有趣(且技术难度较高)的事情:它不会为每个提交打印父节点,而是遍历整个链,找到父节点,然后反转父/子关系。请记住,我们最初绘制图形的方式是这样的:

A <- B <- C <- D   <-- master

提交 C 只知道它的父提交,而不知道它的子提交。 rev-list 命令可以从我们提供的尖端 master 开始向后遍历到根提交 A,然后完成这个过程后,它可以反转 它所遵循的所有箭头(我为了一个原因加粗了这个短语):

A -> B -> C -> D

完成上述步骤后,现在可以打印提交C的ID,后跟提交D的ID,全部显示在一行上。如果现在在Git本身的Git存储库中执行此操作,将会得到以下结果:
$ git rev-list --children -n 2 master
f8f7adce9fc50a11a764d57815602dcb818d1816
8213178c86d5583ff809c582d6727ad17b6a0bed f8f7adce9fc50a11a764d57815602dcb818d1816

输出开始打印之前有一个明显的暂停,原因是 git rev-list 实际上遍历了 43807 个提交才得到这个结果。3 这是目前分支 master 上的提交数量。我们没有限制遍历范围,所以 Git 遍历了从 master 可达的每个提交,翻转了结果遍历中的所有箭头,并最终打印出两个提交哈希值及其相应的反向箭头子 ID:分支 master 本身的哈希值 (f8f7adc...) 和主线上 "紧挨着" 分支 master 的提交的哈希值 (master^18213178...)。
如果当前提交实际上不是分支B末端的祖先,例如在我们上面的图表中,当我们在提交F或提交G时询问master,这两个提交都不包含在master分支中,那么此git rev-list将永远无法到达当前提交。
为了找到此计数,我运行了:
git rev-list --count master

这只是简单地给出了在rev-list遍历中访问的提交计数。另一种方法是运行:

git rev-list master | wc -l

这个命令会在标准输出中列出每个提交,将输出导向wc程序,并指示wc计算行数。但是使用git rev-list进行计数大约快两倍。


简单的方法并不完全适用

我在 Git 自身的 Git 存储库中,执行了以下操作:

$ git checkout master
$ git checkout HEAD^

现在git status显示HEAD detached at 8213178。我们想要找到8213178的(唯一)子节点,即master指向的提交。因此我们尝试这样做:

$ git rev-list --children -n 1 HEAD
8213178c86d5583ff809c582d6727ad17b6a0bed

好的,那个失败了!但是出了什么问题呢?

还记得我之前加粗的那句话吗:git rev-list会反转它所遵循的所有箭头。可惜,它根本没有遵循任何箭头来到达HEAD!它HEAD8213178...)开始。这是它唯一需要的修订版本(-n 1),所以它也在那里停止,并打印出HEAD的ID并完成。

使用-n 2至少使其跟随一个箭头-一个父链接-但这并不真正有帮助:

$ git rev-list --children -n 2 HEAD
8213178c86d5583ff809c582d6727ad17b6a0bed
2a96d39824464c28f2f45f2f4a4d53d7c390c9eb 8213178c86d5583ff809c582d6727ad17b6a0bed

这一次,它再次从HEAD开始,并沿着箭头到达HEAD^2a96d39...),因此它能够反转那个箭头:它告诉我们8213178...2a96d39...的子节点。但我们想知道的是:哪些节点以8213178...为父节点?为了让Git发现这一点,我们必须从8213178...之外的某个地方开始。
只有一个正确的起始位置
起始位置是master的末端。我们知道这一点,因为我们感兴趣的是“在通向master末端的路上的HEAD的子节点”。
如果我们愿意,我们可以询问“在通向next末端的路上的HEAD的子节点”,或者“在通向pu末端的路上的HEAD的子节点”,或者任何其他分支的子节点。我们甚至可以要求任何分支上的子节点。
git rev-list --children [other options] --branches

--branches 标志表示“所有分支”;--branches=abc* 表示“所有以 abc 开头的分支”;等等。

重点在于,我们必须告诉 Git 从哪里开始。我们不能从 HEAD 开始。我们可以停在那里,但我们不能从那里开始。停在 HEAD 可以加快速度——不需要查看超过 43,000 个提交——所以我们可以尝试使用 HEAD..master

$ git rev-list --children HEAD..master
f8f7adce9fc50a11a764d57815602dcb818d1816
08df31eeccfe1576971ea4ba42570a424c3cfc41 f8f7adce9fc50a11a764d57815602dcb818d1816
1ecc6b291c162b9fc7b59a3251c4cbbcf3b07b84 08df31eeccfe1576971ea4ba42570a424c3cfc41
6cbec0da471590a2b3de1b98795ba20f274d53fa 1ecc6b291c162b9fc7b59a3251c4cbbcf3b07b84
8e4571e57a1a3cc6f1318b3da8612b2e3c8e1252 6cbec0da471590a2b3de1b98795ba20f274d53fa
c81d2836753a268be07346d362ffab3c6a5e14a9 8e4571e57a1a3cc6f1318b3da8612b2e3c8e1252
[12 more lines snipped]

哇,这里发生了什么?答案有点棘手,但与master是合并提交有关。让我们先看一下这个相对简单的表达式:

$ git rev-list --count HEAD..master
18

即使HEAD只是master^1,也就是master的第一个父提交,实际上在HEAD..master中有18个可到达的提交。这是因为master^2上有17个提交,而这些提交不在master^1上。加上合并本身,你就得到了这18个提交。通常情况下,准确地绘制它很困难,因为Git的提交DAG非常混乱,但是一个简化的图片看起来像这样“浴缸里的HEAD”:
                       HEAD
                        |
                        v
...--x--x---------------x--o   <-- master
         \                /
          o--o--o--o--o--o

表达式 HEAD..master 的意思是Git应该从HEAD开始向后划掉(用x表示)提交,同时从master开始向后获取提交。因此,这将获取master本身,并试图获取master^1(即HEAD提交),但这会被划掉,然后尝试(并成功)获取master^2以及沿着底部行的所有提交,在重新加入被划掉的提交的顶部行之前停止。

有效方法

这里的诀窍在于我们必须让git rev-list检查HEAD提交本身,以便跟随任何指向HEAD的箭头。然后,我们可以使用grep或类似工具来挑选出包含HEAD提交的行,并使用--children使该行列出具有HEAD作为父级的提交:

$ git rev-list --children master | grep "^$(git rev-parse HEAD)"

这段代码将遍历所有43,000多个提交,找到我们关心的一切以及许多我们不关心的内容,然后提取以当前提交ID开头的一行(即以我们关心的提交开始的那一行),这个过程通过使用grep "^$(git rev-parse HEAD)"实现——这里的帽字符是grep表示“行首”的符号,因此与Git本身无关。
我们可以通过在任何HEAD的父提交处终止遍历来加快这个过程:
git rev-list --children master ^HEAD^@

诱人的做法是尝试将此与-n和/或--reverse结合使用,但由于以下两个原因而注定会失败:

  • HEAD提交可以位于此遍历中的任何位置,具体取决于后面跟随HEAD的DAGlet结构
  • -n限制在反转列表之前进行,因此-n 1始终只获取master提交。

因此,我们可以在此停止并宣布胜利,使用git rev-list --children来反转箭头,使用master来获取正确的选择起点,可选择使用HEAD^HEAD^@以停止遍历以加快git rev-list步行速度,并且-至关重要-使用grep来挑选出所需的行,然后查看所有子提交ID。

但这是Git,还有另一种方法

rev-list命令还支持一个标志,--ancestry-path,正是我们需要的。

通常情况下,如我上面所述,git rev-list X..Y 的意思是 git rev-list Y ^X:查找所有从提交 Y 可达的提交,但排除所有从提交 X 可达的提交。如果 DAGlet(由 X..Y 选择)中没有合并,则该列表(至少在正确顺序下打印)以第一个在提交 X 后的提交结束或开始。问题出现在存在合并时: ^X 部分会删除提交 X 及其祖先,但未删除不是提交 X 的子孙节点的 Y 的祖先。再次查看“浴缸里的 HEAD”图,这次我将在浴缸另一侧添加几个提交,并实际上进行另一个分支与合并:

                       HEAD
                        |
                        v
...--x--x---------------x--o--o---o   <-- master
         \                /    \ /
          o--o--o--o--o--o      o

我们想要的是“划掉”那些不在“您在此处”点的右侧(后代)的提交。也就是说,我们希望如下所示:
                       HEAD
                        |
                        v
...--x--x---------------x--o--o---o   <-- master
         \                /    \ /
          x--x--x--x--x--x      o

所有剩余的 o 都是 master分支末端的祖先 HEAD的后代。这正是 --ancestry-path 的作用:它的意思是“对于我们明确排除的任何提交,也排除那些没有这些提交作为祖先的提交”。 (也就是说,Git 反转了条件:它不能轻易地测试“是后代”,但是它可以测试“是祖先”。如果 DA 的后代,则根据定义,AD 的祖先,因此通过测试“非祖先”,它可以推断出“非后代”。)
如果我们按某种拓扑排序列出生成的提交,然后选择一个“最接近” HEAD 的提交,我们就得到了一个合适的“下一个提交”。请注意,有时会有两个或更多这样的提交。例如,让我们向前移动一次提交,将 HEAD 拖到浴缸的边缘:
                          HEAD
                           |
                           v
...--x--x---------------x--x--o---o   <-- master
         \                /    \ /
          x--x--x--x--x--x      o

现在让我们再次向前迈进:
                             HEAD
                              |
                              v
...--x--x---------------x--x--x---o   <-- master
         \                /    \ /
          x--x--x--x--x--x      o

哪一个剩余的提交应该被访问?假设我们选择“最顶端”的那个。我们会访问下面的那个吗?我们可以尝试选择下面的那个,这样就会让我们访问它们所有。(我不会提出一种方法来实现这个。)
现在考虑这个DAGlet,我认为它类似于苯环或苯基(phenyl group)
          o--o
         /    \
...--x--x      o   <-- branch
         \    /
          o--o

如果我们移动到顶行,我们如何再次访问底行?如果我们移动到底行,我们如何再次访问顶行?
真正正确的方法是,在离开命名分支尖端之前标记要访问的提交。也就是说,如果您的目标是访问某个范围内的每个提交(或某个合理定义的“每个提交”的子集),从“我现在所在的位置”到“过去的某个点”,您应该先标记整个范围。一种简单的方法是运行git rev-list来获取该范围内每个提交的ID列表(使用--boundary^X^@语法或显式添加起始点X,如果您想在像X..Y这样的范围中包括提交X)。然后,您可能已将要访问的每个提交的ID保存在文件中,因此在遍历“苯环”DAGlet时不会错过任何一个提交。

或者,您可以标记两个提交并在它们之间工作。例如,git bisect 的工作方式就是这样。


4好的,显然这取决于你如何限制问题。这就是为什么定义你想要做什么是如此重要的原因!


1

git log --reverse --children -n1 HEAD 可以正常工作。

如果您只想查看提交的哈希值,请使用 git log --reverse --children -n1 HEAD --pretty=format:"%H" --no-patch

类似的问题已经在这里这里提出过。


1
使用git log(或者在这种情况下更好的是git rev-list)与--chlidren选项可以工作,但不能以这种方式和这个案例中使用(即"detached HEAD"并查看当前HEAD)。我会在回答中解释原因——在评论中没有足够的空间来进行解释。 - torek

1

列出所有分离 HEAD 的子节点

使用此别名:

# Get all children of current or specified commit-ish
children = "!bash -c 'c=${1:-HEAD}; set -- $(git rev-list --all --not \"$c\"^@ --children | grep $(git rev-parse \"$c\") ); shift; echo $*' -"

git children 将列出游离 HEAD 的 所有 子提交。

列出特定的子提交

如果你只想要某个分支祖先中的子提交:

根据 this question 的答案,我在我的 .gitconfig 中编写了这个别名。

# Get the child commit of the current commit.
# Use $1 instead of 'HEAD' if given. Use $2 instead of curent branch if given.
child = "!bash -c 'git log --format=%H --reverse --ancestry-path ${1:-HEAD}..${2:\"$(git rev-parse --abbrev-ref HEAD)\"} | head -1' -"

在您的情况下,您将使用它作为:git child HEAD<tip>,其中tip是一个commit-ish,通常是包含分离头的分支名称。
例如:git child HEAD branchname 它默认为给出HEAD的子级(除非给出另一个commit-ish参数),通过向当前分支的尖端追踪祖先一步来实现(除非给出第二个参数作为另一个commit-ish)。但请注意,对于分离头,“当前分支”未定义。
如果要使用短哈希形式,请使用%h而不是%H

1
Git通过查看记录的父子关系来动态获取子信息。基线提交查找器是git rev-list,它可以跟踪到有趣的提交的祖先路径,因此:
git rev-list --ancestry-path --all --reflog --parents ^HEAD | grep `git rev-parse HEAD`

或者,另一种选择是。
git rev-list --boundary --ancestry-path --all --reflog --children ^HEAD \
| grep ^-$(git rev-parse HEAD)

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