如何确定给定的提交是否在分支的第一父级链上

6
我正在尝试编写脚本以确定给定提交是否在给定分支的第一父级链中。例如,merge-base不能使用,因为该提交可能已合并。我想知道确切的提交是否曾经是该分支的顶部。
注意:所讨论的分支受到不允许快速转发合并策略的影响。
3个回答

3

高级方法

一个简单的“是祖先”测试显然不够,因为沿着第二个或更晚的父链的提交也是祖先:

...o--o--A--o--o--o--T
    \            /
 ...-o--*--B----o
         \
          C

在这个场景中,AB都是T的祖先,但你想接受A而拒绝BC。(假设--first-parent是最高行。)

然而,使用git merge-base会实际执行部分工作。虽然你不需要git merge-base--is-ancestor模式,但确实需要进行一些额外的处理。

请注意,无论T和某个祖先之间的路径如何,合并基础(例如AB)始终是该祖先本身,或该祖先的某个祖先,例如如果我们将TC作为一对,则为提交*。(即使存在多个合并基础,这也成立,尽管我留给你构建证明。)

如果测试提交和分支末端的合并基础,或任意选择的所有合并基础集之一,还没有是测试提交本身,那么我们就像C一样拒绝它。 (或者,我们可以使用--is-ancestor来拒绝它,或者...请参见下文。)如果没有,我们必须枚举介于问题提交和分支末端之间的祖先路径。对于A而言:

         o--o--*--T

而对于B而言,它是:

               *--T
              /
             o

如果提交是一个合并提交,比如标记为*的提交,我们需要确保第一个父提交包含了沿着这条路径列出的提交之一。最棘手的情况是那些拓扑结构类似于:
       o--o
      /    \
...--A      o--T
      \    /
       o--o

由于在这些提交之间的--ancestry-path包含合并和到达A的两种方式,其中一种是第一父路径,另一种不是。 (如果T本身也是合并,则这是正确的。)

实际上,我们根本不需要首先找到合并基。 我们只是为了检查祖先路径而使用合并基。 如果合并基不是测试提交本身,则测试提交不是提示提交的祖先,testcommit..tipcommit也不会包括testcommit本身。 此外,添加--ancestry-path——这会丢弃此处不是左侧中子对象的所有提交——然后将丢弃git rev-list输出中的所有提交:例如C没有是T的祖先的后代(如果有,则C将是一个合并基)。

因此,我们希望检查git rev-list --ancestry-path testcommit..branchtip中的提交。 如果此列表为空,则测试提交本身不是分支尖端的祖先。 我们有一个像提交C的情况;所以我们已经得到了答案。 如果列表非空,则将其缩小为其合并组件(再次运行--merges,或将列表提供给git rev-list --stdin --merges,以产生缩小的列表)。 如果此列表非空,请检查每个合并项,找到其--first-parent ID,并确保结果在第一个列表中。

以下是实际的(尽管未经测试的)shell脚本代码:

TF=$(mktemp) || exit 1
trap "rm -f $TF" 0 1 2 3 15
git rev-list --ancestry-path $testcommit..$branch > $TF
test -s $TF || exit 1  # not ancestor
git rev-list --stdin --merges < $TF | while read hash; do
    parent1=$(git rev-parse ${hash}^1)
    grep "$parent1" $TF >/dev/null || exit 1 # on wrong path
done
exit 0 # on correct path

暴力方式

上述测试尽可能少地测试了提交,但从某种意义上来说,更加实际的方法是运行以下命令:

git rev-list --first-parent ${testcommit}^@..$branch

如果输出中包括$testcommit本身,则$testcommit仅通过第一个父提交$branch可达。(我们使用^@来排除$testcommit 的所有父提交,以便即使对于根提交也可以正常工作;对于其他提交,${testcommit}^就足够了,因为我们使用了--first-parent)。此外,如果我们确保按拓扑顺序完成,从git rev-list命令发出的最后一个提交ID将仅在$testcommit$branch可达时是$testcommit本身。因此:
hash=$(git rev-parse "$testcommit") || exit 1
t=$(git rev-list --first-parent --topo-order $branch --not ${hash}^@ | tail -1)
test $hash = "$t"

这应该就可以解决问题了。在$t周围加上引号是为了防止其扩展为空字符串。


暴力方法也是我的第一次尝试。我真的以为这会起作用:git rev-list --first-parent $hash --not "$hash^@" $branch(也就是说,如果它产生了结果,那么它不在链上)。但没有。我正在阅读关于--topo-order的内容,但我不知道它如何真正帮助这里。在我看来,最终元素将始终是感兴趣的哈希(或不是)。我真的希望有人知道一种聪明而快速易懂的方法来做到这一点。但是,唉。 - Steve Benz
不清楚为什么 --first-parent $hash ^$hash^@ ^$branch 不能正常工作,但我自己也见过这种情况。(正如你所说,从逻辑上讲,它应该只在 $hash 可以通过 --first-parent 弧从 $branch 到达时才发出空值,否则发出 $hash。) - torek
1
我在使用 Git 2.28 时无法正确使用 ${hash}^@..$branch 的语法。阅读 git-rev-parse 手册后,我得出结论这不是一个合法的修订范围。最终我使用了 $branch --not ${hash}^@ 并编辑了你的答案,希望这样可以。 - PiQuer
我认为 --first-parent --ancestry-path 是有缺陷的,因为它没有将祖先路径限制在第一个父级上,而只是该路径上的步骤(这排除了基础)。因此,基础提交可以是路径上最后一个第一个父级提交的第二个父级。但它是核心命令,我不认为它很可能被修复,因为那可能会破坏工作脚本。 ( set -- `git rev-list --first-parent --ancestry-path --parents $base..$tip | tail -1`; [[ $2 = `git rev-parse $base` ]] ) - jthill
@jthill:有趣……是的,似乎--ancestry-path --first-parent应该以那种方式限制遍历。 - torek
显示剩余2条评论

2

使用保留提交历史的no-fast-forward策略,可以在git log --first-parent中进行grep。 如果只需要哈希码,可以改用git rev-list

git rev-list --first-parent | grep <commit hash>

否则,您可以使用git log--format来显示所需的数据。
编辑:这篇文章可能会给您一些想法。 如何告诉一个提交是否是另一个提交的祖先(或反之亦然)?

这是一个可行的解决方案,就像我的暴力方法一样,只不过更加暴力(我们查看所有可达的第一父提交,而不是修剪掉一些明显太早的提交)。 - torek

2
这是一个性能友好的单行代码:
git rev-parse HEAD~"$( git rev-list --count --first-parent --ancestry-path <commit>..HEAD )"

如果输出是您的<commit>,那么它是第一个父级祖先。
思路是我们使用rev-list --count --ancestry-path测量两个提交之间的最短路径,然后获取在第一个父级链中处于该位置的提交。显然,如果检查的提交是第一个父级祖先,则这些必须相同。被压制的错误(例如第一个父级链太短)是无关紧要的。
为了使其更加复杂,您可以创建一个由非常易读的shell脚本支持的git别名。
首先编写脚本文件:
#!/bin/sh

ref="$1"
head="$2"
if [ -z "$head" ]; then
    head="HEAD"
fi

commit=$( git rev-parse "$ref"^{commit} )
distance="$( git rev-list --count --ancestry-path --first-parent "$commit".."$head" )"
found="$( git rev-parse HEAD~"$distance" )"

if [ "$commit" != "$found" ]; then
    echo "${ref} is not a first-parent ancestor of ${head}"
    exit 1
fi

echo "${ref} is a first-parent ancestor of ${head} at a distance of ${distance}"
exit 0


将其保存到系统中适当的位置,使其可执行,然后将其设置为git别名:
git config --global alias.fp '!<script-path>'

fp替换为您更喜欢的任何内容。将<script-path>替换为您的脚本文件位置,但保留!字符,这是使用外部文件所必需的。
完成后,您可以像普通的git命令一样使用新的别名:
$ git fp 66e339c
66e339c is a first-parent ancestor of HEAD at a distance of 45

好的。在rev-list中添加“--first-parent”以限制路径到第一个父级,这样就不会跟踪大量合并历史记录。 - jthill
不,你仍然需要检查基础是否也是第一个父级,参见我对torek答案的评论,我认为这种组合方式存在缺陷,修复起来可能会非常痛苦。但是将HEAD〜$count与基础进行测试比我想出的方法更快更干净。 - jthill
@jthill 好的,我误解了你的建议。已更新答案。 - Dávid Horváth

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