有没有办法引用当前分离的HEAD的子提交? [英] Is there a way to refer to a child commit of the current detached HEAD?

查看:65
本文介绍了有没有办法引用当前分离的HEAD的子提交?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

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

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

解决方案

答案是否"和是",或者这个问题没有道理",具体取决于您的意思. >

您的问题专门针对当前分离的HEAD的子提交".这就是为什么这个问题毫无意义,至少在没有其他信息的情况下如此.

names ,定义为指向提交的标签,表示该分支的尖端",而我们有 branch-并在提交DAG中合并结构(我喜欢将其称为"DAGlets").有关更多信息,请参见分支"到底是什么意思?.但是,所有这些的主要后果是,提交通常一次在多个分支上.

缺少的信息

当您指向提交DAG中某处的某个提交并询问子提交时,您就在做一个假设.具体来说,您是假设一个特定的分支或一组分支.要查看其工作原理,让我们看一下分支如何演变.

运动分支

根据定义,分支的尖端是其末端:该分支上不再有提交.如果您运行git commit并进行新的创建,则分支 name 会自动指向您刚刚进行的新提交.分支现在有一个新的最尖端的提交,并且-这是真正的踢脚-您现在是在那个提交中的 ,它当然没有子进程:这是一个新的无子进程的提交.它只能通过添加新的提交来获取新的孩子.

这在视觉上是有意义的:这里我们在某个分支的顶端,在分支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扩展而来的 new 分支,有关子级的所有这些内容仍然适用.假设此时我们要做:

git checkout -b feature master~1

此操作是创建一个新的分支标签feature,直接指向提交D.提交E(master的尖端)仍在 ,但是现在HEAD将指向feature,而feature将指向D:

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

提交AD都在两个分支上:master feature.

如果我们现在在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 如果您改变主意,则提交可能会在存储库中停留一段时间. Git通常会通过"reflogs"记住已放弃"提交的ID至少30天.有一个HEAD的引用日志,它保留了提交F的原始SHA-1哈希,因此您可以使用git reflog查找F并将其取回.

也有 分支名称feature的引用日志,但是当我们执行git branch -D feature时,我们让Git丢弃了它.


运动中的匿名分支

在Git中,分离的HEAD"充当未命名分支的一种.让我们使用与以前相同的A -through- E(尚未添加F),但是只使用git checkout master~1而不是git checkout -b feature master~1并绘制它.现在,我们得到的是HEAD直接指向提交D:

,而不是HEAD指向feature并具有feature指向提交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]

这意味着,就像您在名为分支上一样,当提交D是当前的时,它在当前分支上没有子代(即使在主服务器上有一个提交E,它也有一个提交D作为父级-而且,还有一个废弃的提交F还是一个孩子!). /p>

分离的HEAD只是一个匿名分支

在Git中,在分支上在分离式HEAD模式下的主要区别在于,当您在分支上时,HEAD文件包含分支的名称.也就是说,如上图所示,它指向"分支名称,而分支名称指向提交.当您有一个分离的HEAD时,HEAD文件直接指向commit ...,这是分支提示.没有分支名称;当前的提交是匿名分支的提示.

作为分支的顶端,它自动没有子代.

恢复丢失的信息

您可能会反对所有这一切:在一个分支上,所以应该将我分离的HEAD视为我以前所在的分支的一部分!

实际上,这是一个合理的反对. 但是您之前在哪个分支 上呢?您所说的是:我目前拥有一个独立的HEAD,并且希望找到一个子提交."

假设您用以下方式重新表述问题:我有一个指向某个提交的HEAD头.在使用分支(分支名称) B 时,我想找到当前提交的子提交. em>.

现在,我们有足够的信息!由于Git的工作方式,我们要做的是从 B 的尖端(分支名称​​ B 指向的提交)开始,然后从向后工作B ,直到我们到达当前提交为止(如果有的话). 2 有几种内置方法可以做到这一点.

之前在 BVengerov的答案中讨论的一个问题是使用git log --children或等效的git rev-list --children(git loggit rev-list本质上是相同的命令,但输出格式不同.当我们只想获取哈希ID时,让我们使用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做一些有趣的事情(并且在技术上很困难):与其为每个提交打印 parents ,它会遍历整个过程,找到要执行的操作.父母,然后逆转父母/子女关系.请记住,我们最初绘制的图形是这样的:

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

提交C仅了解其 parent 提交,而不了解其子级. 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 这是分支此刻.我们没有限制遍历,因此Git遍历了master所能到达的每个提交,反转了结果遍历中的所有箭头,最后,打印了两个带有其反向箭头子ID的提交哈希:master的子哈希.本身(f8f7adc...),以及在主行(master^18213178...)上提交正好在"主机上的提交.


2 实际上,如果当前提交不是分支 B 尖端的祖先,例如,在上面的图表中,如果我们询问有关master当我们执行提交F或提交G时,这两个分支都不包含在master分支中–那么此git rev-list永远不会 到达当前提交. >

3 要找到此计数,我跑了:

git rev-list --count master

只是给出了在修订列表中访问的提交计数.另一种方法是运行:

git rev-list master | wc -l

列出了标准输出上的每个提交,该输出通过管道传送到wc程序,并且wc指示对行进行计数.但是让git rev-list进行计数大约快一倍.


简单的方法行不通

我在Git本身的Git存储库中,并且确实:

$ git checkout master
$ git checkout HEAD^

,现在git statusHEAD detached at 8213178.我们想要找到8213178的(单个)子级,这是提交master指向的.所以我们尝试一下:

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

好吧,那是半身像!但是出了什么问题?

请记住我之前加粗的短语:git rev-list将使跟随的所有箭头反转. las,完全没有箭头 HEAD!它开始于 HEAD(8213178...).那是它唯一需要的版本(-n 1),所以它也在那里停下来,并打印HEAD的ID并完成.

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

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

这次,它再次从HEAD开始,然后跟随箭头指向HEAD^(2a96d39...),因此它能够反转 that 箭头:它告诉我们8213178...2a96d39...的子级.但是我们想知道:哪些节点具有8213178...作为父节点?为了让Git发现这一点,我们必须从超越 8213178...的某个地方开始.

只有一个正确的地方可以开始

开始的地方是master的提示.我们之所以知道这一点,是因为我们对通往HEAD master的头上的HEAD的孩子"感兴趣.

如果需要,我们可以问在通往next尖端的路上的HEAD的孩子",或者在通往",或任何其他分支.或者我们甚至可以在任何分支上要求孩子:

git rev-list --children [other options] --branches

--branches标志的意思是所有分支"; --branches=abc*表示名称以abc开头的所有分支";等等.

这里的意思是,我们必须告诉Git从哪里开始.我们无法在HEAD 开始.我们可以在那里停止,但不能在那里开始.停止在HEAD可以加快处理速度-无需查看超过4.3万次提交-因此我们可以尝试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
                        |
                        v
...--x--x---------------x--o   <-- master
         \                /
          o--o--o--o--o--o

表达式HEAD..master表示Git应该从后面的HEAD开始删除(x -ing)提交,同时从后面的master进行提交.因此,这本身需要master并尝试采用master^1(HEAD提交),但将得到x-ed,然后尝试(并成功)采用master^2,然后沿着底行进行所有提交,一旦它重新回到提交被x-ed的第一行,便停止.

有效的方法

这里的诀窍是我们必须必须得到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的所有提交.如果X..Y选择的DAGlet中没有合并,则该列表(如果至少以正确的顺序打印)至少在 提交X之后从第一个提交结束或开始.当有 are 合并时,会发生问题:^X部分扔掉了提交X及其祖先,但没有扔掉不是X后代的Y祖先.再次查看浴缸中的头部"图,尽管这次我将在浴缸的另一侧添加一些提交,实际上,在其中进行了另一个分支合并:

                       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都是主提示的祖先 HEAD的后裔.这正是--ancestry-path所做的:它的意思是对于任何我们明确排除的提交,还排除那些没有作为祖先的提交". (也就是说,Git颠倒了条件:它不能如此轻松地测试是...的后代",但是它可以可以测试是...的祖先".如果 D 是……的后代). A ,然后根据定义, A D 的祖先,因此通过测试"not祖先",可以推论不是后代" -of".)

如果我们以某种拓扑排序的顺序列出生成的提交,然后选择一个最接近"的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,我认为它类似于苯环或苯基:

          o--o
         /    \
...--x--x      o   <-- branch
         \    /
          o--o

如果我们移至第一行,我们将如何重新访问第二行?如果我们移至最下面一行,我们将如何重新访问最上面一行?

正确的方法

完整问题 4 的唯一真正解决方案是标记要离开 的提交,并离开命名分支提示.也就是说,如果您的目标是访问从我现在所在的位置"到过去的某个时刻"的一定范围内的每次提交(或每次提交"的定义合理的子集),则应从标记开始超出整个范围.一种简单的方法是运行git rev-list以获取该范围内每个提交ID的列表(使用--boundary^X^@语法,或者如果需要的话可以显式添加起点XX..Y之类的范围内包含提交X).然后,您可能已经将每次访问的提交ID都保存在了文件中,因此在遍历苯环" DAGlet时不会错过一些ID.

或者,您可以标记两个提交,然后在两个提交之间进行操作.这就是 git bisect 的工作方式,例如.


4 好吧,显然这取决于您如何约束问题.这就是为什么定义您想要 如此重要的原因!

I know how to refer to a parent commit as HEAD^.

But is it possible to refer to a child commit in a similar way?

解决方案

The answer is both no and yes, or perhaps "this question makes no sense", depending on what you mean.

Your question refers specifically to "a child commit of the current detached HEAD". This is why the question makes no sense, at least not without additional information.

As Tim Biegeleisen noted in a comment, when you check out by branch name, this puts you on the tip of the branch. This occurs by definition in Git, because Git is different from most version control systems. In Git, the term "branch" is somewhat ambiguous: we have branch names, which are defined as "labels pointing to a commit that denote that branch's tip", and we have the branch-and-merge structures (what I like to call "DAGlets") within the commit DAG. See What exactly do we mean by "branch"? for more about this. However, a key consequence of all this is that commits are often on more than one branch at a time.

The missing information

When you are pointing to some commit somewhere in the commit DAG, and you ask about child commits, you are making an assumption. Specifically, you are assuming a particular branch or set of branches. To see how this works, let's look at how branches evolve.

Branches in motion

The tip of a branch is, by definition, its end: there are no more commits on that branch. If you run git commit and make a new one, the branch name automatically points to the new commit you just made. The branch now has a new tip-most commit, and—here's the real kicker—you're now at that commit, which of course has no children: it's a new childless commit. It can only acquire new children by having new commits added to it.

This makes sense visually: here we are at the tip of some branch, commit D in branch master:

                        HEAD
                         |
                         v
A <- B <- C <- D   <-- master

We are on branch master (HEAD points to master) and master points to commit D.

The act of adding a new commit—doing a successful git commit—means we create a new commit E, whose parent is D, and Git immediately makes master point to E as well:

                             HEAD
                              |
                              v
A <- B <- C <- D <- E   <-- master

The internal branch arrows within these graphs always point left-ward (backwards in time) so I'm going to start drawing the commits without internal arrows here. Let's do that now for this last graph:

A--B--C--D--E   <- (HEAD) master

Now, all this stuff about children remains true even if we decide we need to make a new branch that grows from commit D. Suppose at this point we do:

git checkout -b feature master~1

What this does is to make a new branch label, feature, pointing directly to commit D. Commit E—the tip of master—is still there, but now HEAD will point to feature and feature will point to D:

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

Commits A through D are now on both branches: both master and feature.

If we make a new commit now on feature, we get this:

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

That is, F is now the tip-most commit of feature: HEAD has not changed at all, and feature now points to F. Commits E and F are on just one branch each, and commits A through D are still on both branches.

If we don't want F after all, we can now:

git checkout master

to switch back to master (and commit E) and then:

git branch -D feature

to delete the feature branch and forget all about commit F.1 And now commits A through D are only on one branch, master.


1In case you change your mind, the commit tends to linger in the repository for a while. Git normally remembers the IDs of "abandoned" commits for at least 30 days via "reflogs". There is a reflog for HEAD and it retains the raw SHA-1 hash of commit F, so that you can use git reflog to look for F and get it back.

There was a reflog for branch-name feature as well, but when we did git branch -D feature, we made Git throw it away.


Anonymous branches in motion

In Git, a "detached HEAD" acts as a sort of unnamed branch. Let's use this same A-through-E as before (without adding F yet), but just use git checkout master~1 instead of git checkout -b feature master~1, and draw that. Now instead of HEAD pointing to feature and having feature point to commit D, what we get is HEAD pointing directly to commit D:

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

This is what a detached HEAD is: HEAD contains the raw commit hash (the ID, the a123456... thing) of some commit, instead of holding the name of a branch and letting the branch-name point to the tip-most commit.

Nonetheless, in this situation, we can add new commits, just as we did to make commit F before. Since our original F is still actually in there, I'll draw that in too, and use G instead for this new commit:

           E    <-- master
          /
A--B--C--D--G   <-- HEAD
          \
           F    [abandoned]

What all this means is that, just as when you are on a named branch like feature, when commit D is current, it has no children on the current branch (even though there's a commit E on master that has commit D as a parent—and, for that matter, there's an abandoned commit F still in there as a child as well!).

A detached HEAD is just an anonymous branch

In Git, the key difference between being on a branch and being in detached HEAD mode is that when you are on a branch, the HEAD file contains the name of the branch. That is, it "points to" the branch name, and the branch name points to the commit, as in our drawings above. When you have a detached HEAD, the HEAD file points directly to the commit ... which is the branch tip. There is no branch name; the current commit is the tip of the anonymous branch.

Being the tip of a branch, it automatically has no children.

Restoring the missing information

You might well object to all of this: I was on a branch, so my detached HEAD should be considered to be part of the branch I was on before!

That is, in fact, a reasonable objection. But what branch were you on before? All you said is: "I currently have a detached HEAD and would like to find a child commit."

Suppose you rephrase the question this way: "I have a detached HEAD pointing to some commit. I would like to find a child commit of the current commit, when working with branch (branch-name) B."

Now we have enough information! Because of the way Git works, what we have to do is start at the tip of B—the commit to which branch name B points—and work backwards from B until we arrive at the current commit (if ever).2 There are several built-in ways to do this.

The one discussed earlier, in BVengerov's answer, is using git log --children, or equivalently, git rev-list --children (git log and git rev-list are essentially the same command with different output formats). Let's use git rev-list to avoid having to fuss with --pretty=format and --no-patch when we just want to get hash IDs.

As we just noted, the way git rev-list works is to start at some commit(s)—often, the tip of a branch, or many tips of many branches—and work backwards, following the parent pointers. By default, it just prints out each commit's hash ID:

$ git rev-list master
f8f7adce9fc50a11a764d57815602dcb818d1816
8213178c86d5583ff809c582d6727ad17b6a0bed
[snip]

With --parents it prints each commit and its parent IDs (and to avoid having to [snip] I'll add -n 2 to stop after 2 things are printed):

$ git rev-list --parents -n 2 master
f8f7adce9fc50a11a764d57815602dcb818d1816 8213178c86d5583ff809c582d6727ad17b6a0bed 08df31eeccfe1576971ea4ba42570a424c3cfc41
8213178c86d5583ff809c582d6727ad17b6a0bed 2a96d39824464c28f2f45f2f4a4d53d7c390c9eb

(the tip of master here is a merge, with two parents, hence the three hashes printed on one very long line).

Using --children tells git rev-list to do something interesting (and technically difficult): instead of printing the parents for each commit, it walks the entire chain that it would have walked, finding the parents, then reversing the parent/child relationships. Remember that we initially drew our graphs like this:

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

Commit C knows only about its parent commits, not its children. The rev-list command can walk from the tip we gave it, master, back to root commit A, and then having done so, it can reverse all the arrows it followed (I have boldfaced this phrase for a reason):

A -> B -> C -> D

Having done all that, it can now print commit C's ID followed by commit D's ID, all on one line. If I do that now, in the Git repository for Git itself, I get this:

$ git rev-list --children -n 2 master
f8f7adce9fc50a11a764d57815602dcb818d1816
8213178c86d5583ff809c582d6727ad17b6a0bed f8f7adce9fc50a11a764d57815602dcb818d1816

There's a noticeable pause before the output starts printing, and the reason for that is that git rev-list actually walked through 43807 commits to get this result.3 That's the number of commits on branch master at the moment. We didn't limit the traversal, so Git walked through every commit reachable from master, reversed all the arrows in the resulting walk, and finally, printed two commit hashes with their attached reversed-arrow child IDs: that of master itself (f8f7adc...), and that of the commit "just before" master on the main line (master^1 or 8213178...).


2If the current commit is not, in fact, an ancestor of the tip of branch B—for instance, in our graphs above, if we ask about master when we're on commit F or commit G, neither of which is contained in the master branch—then this git rev-list will never reach the current commit.

3To find this count, I ran:

git rev-list --count master

which simply gives a count of the commits visited in the rev-list walk. Another way is to run:

git rev-list master | wc -l

which lists every commit on standard output, with that output piped to the wc program and wc instructed to count lines. But getting git rev-list to do the counting is roughly twice as fast.


The easy way doesn't quite work

I'm in the Git repository for Git itself, and I did:

$ git checkout master
$ git checkout HEAD^

and now git status says HEAD detached at 8213178. We want to find the (single) child of 8213178, which is the commit master points to. So we try this:

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

Well, that was a bust! But what went wrong?

Remember the phrase I bolded earlier: git rev-list will reverse all the arrows it followed. Alas, it followed no arrows at all to get to HEAD! It started at HEAD (8213178...). That was the only revision it needed (-n 1) so it stopped there too, and printed HEAD's ID and finished.

Using -n 2 makes it at least follow one arrow—one parent link—but it's not really helpful:

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

This time, it started at HEAD again and followed an arrow to HEAD^ (2a96d39...), so it was able to reverse that arrow: it told us that 8213178... is a child of 2a96d39.... But we want to know: what nodes have 8213178... as a parent? And to get Git to discover that, we have to start somewhere beyond 8213178....

There's just one right place(s) to start

The place to start is the tip of master. We know this because we're interested in "children of HEAD that are on the road that leads to the tip of master".

If we wanted, we could ask instead of "children of HEAD that are on the road that leads to the tip of next", or "children of HEAD that are on the road that leads to the tip of pu", or of any other branch. Or we could even ask for children on any branch:

git rev-list --children [other options] --branches

The --branches flag means "all branches"; --branches=abc* means "all branches whose name starts with abc"; and so on.

The point here is that we must tell Git where to start. We cannot start at HEAD. We can stop there, but we cannot start there. Stopping at HEAD can speed things up—there's no need to look at over 43 thousand commits—so we might try HEAD..master:

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

Whoa, what happened here? The answer is a bit tricky but has to do with the fact that master is a merge commit. Let's look at this, somewhat simpler, expression first:

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

Even though HEAD is just master^1, i.e., the first parent of master, there are in fact 18 reachable commits in HEAD..master. This is because there are 17 commits on master^2 that are not also on master^1. Add the merge itself and you get these 18 commits. Drawing it accurately is tough in general because Git's commit DAG is very messy, but a simplified picture looks something like this "HEAD in a bathtub":

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

The expression HEAD..master means that Git should start crossing out (x-ing) commits from HEAD on back, while taking commits from master on back. So this takes master itself, and tries to take master^1 (the HEAD commit) but that gets x-ed out, and tries (and succeeds) to take master^2 and then all the commits along the bottom row, stopping once it rejoins the top row where the commits get x-ed out.

What works

The trick here is that we must get git rev-list to examine the HEAD commit itself, so that it follows any arrows that lead to HEAD. Then we can use grep or similar to pick out the line that has the HEAD commit, and use --children so that that line lists the commits that have HEAD as their parent:

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

This walks all 43-thousand-plus commits, finding everything we care about and lots of things we don't, and then extracts the one line that starts with the commit we do care about, which is the one starting with the current commit ID (grep "^$(git rev-parse HEAD)"—the hat character here is grep's notation for "beginning of line", and thus has nothing to do with Git itself).

We can speed this up a little bit by terminating the walk at any parent of HEAD:

git rev-list --children master ^HEAD^@

It's tempting to try to combine this with -n and/or --reverse, but this is doomed to fail for two reasons:

  • The HEAD commit can be anywhere in this traversal depending on the DAGlet structure that follows HEAD
  • The -n limiting is done before reversing the list, so that -n 1 always just gets you the master commit anyway.

So we could stop here and declare victory, using git rev-list --children to reverse the arrows, using master to get the selection to start at the right place, optionally using HEAD^ or HEAD^@ to stop the traversal to speed up the git rev-list walk, and—crucially—using grep to pick out the desired line, and then view all the child commit IDs.

But this is Git, so there is another way

The rev-list command also supports a flag, --ancestry-path, that does just what we need here.

Normally, as I noted above, git rev-list X..Y "means" git rev-list Y ^X: find all commits reachable from commit Y, excluding all commits reachable from commit X. If there are no merges in the DAGlet selected by X..Y, this list—if printed in the correct order, at least—ends or begins with the first commit after commit X. The problem occurs when there are merges: the ^X part tosses out commit X and its ancestors, but fails to toss out the ancestors of Y that are not descendants of X. Look at the "HEAD in a bathtub" graph again, though this time I will add a few more commits on the other side of the bathtub, and in fact, make another branch-and-merge in it:

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

What we want is to "x out" commits that are not to the right of (descendants of) the "you are here" point. That is, we want this instead:

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

All the remaining os are both ancestors of the tip of master and descendants of HEAD. This is exactly what --ancestry-path does: it means "for any commits we explicitly exclude, also exclude commits that do not have those commits as an ancestor". (That is, Git inverts the condition: it cannot test "is descendant of" so easily, but it can test "is ancestor of". If D is a descendant of A, then by definition, A is an ancestor of D, so by testing for "not ancestor-of" it can deduce "not descendant-of".)

If we list the resulting commits in some sort of topologically-sorted order, then pick one "closest to" HEAD, we get a suitable "next commit". Note that sometimes there are two or more such commits. For instance, let's move forward one commit, dragging our HEAD right onto the edge of the bathtub:

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

Now let's step forward again:

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

Which of the two remaining commits should we visit? Suppose we pick the "topmost" one. Will we ever visit the lower one? We could try to pick the lower one, which will make us visit them all. (I am not going to suggest a method for this.)

Now consider this DAGlet, which I think resembles a benzene ring or phenyl group:

          o--o
         /    \
...--x--x      o   <-- branch
         \    /
          o--o

If we move to the top row, how will we ever revisit the bottom row? If we move to the bottom row, how will we ever revisit the top row?

The really-right way

The only real solution to the complete problem4 is to mark up the commits to visit before leaving the named-branch-tip. That is, if your goal is to visit every commit (or some reasonably well defined subset of "every commit") in some range of commits from "where I am now" to "some point in the past", you should start by marking out the entire range. An easy way to do that is to run git rev-list to get a list of every commit ID in that range (using --boundary, the ^X^@ syntax, or an explicit addition of the starting-point X if you want to include commit X in a range like X..Y). You then have, probably saved in a file, the IDs of every commit to visit, so you won't miss some when traversing a "benzene ring" DAGlet.

Alternatively, you can mark two commits and then work between them. This is how git bisect works, for instance.


4Well, obviously this depends on just how you constrain the problem. Which is why defining what you want to do is so important!

这篇关于有没有办法引用当前分离的HEAD的子提交?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆