Git 判断提交是否已合入:祖先关系成立,和补丁内容相同是两回事

10-01 3阅读

文件里已经有修改,提交为什么还没有合入

维护分支里已经出现某次修复,脚本却说原来的提交不在这个分支历史里。这并不矛盾:修复可能通过 cherry-pick 重新形成了另一个提交。两次提交的内容改动可以相同,但父提交不同,原提交的身份与祖先关系不会因此自动保留下来。

判断“是否合入”之前,应把问题具体化。若关心某个提交是否能从目标分支沿父链追溯到,可以使用 merge-base 的 is-ancestor 模式。若关心补丁是否已经等价应用,则需要另外比较改动。两种答案可能不同,不能拿文件里出现了某行文字作为祖先证据。

在一个自动清理的仓库里对照

下面保存为 Python 文件运行,需要已安装 Git 二点二十八及以上和 Python 三点八及以上,本文使用 Git 二点五十二。Python 只负责建立临时目录、调用命令和断言;所有仓库操作都在临时目录中,运行结束会清理,不连接远程,也不接触现有仓库。

实验让主分支和功能分支各自前进,再把功能提交摘取到主分支。此时补丁等价但祖先判断仍为否。随后真正合并功能分支,原提交才进入主分支的可达历史。最后故意使用不存在的引用,检查错误没有被错误归类成普通的“尚未合入”。

Git 判断提交是否已合入:祖先关系成立,和补丁内容相同是两回事

AI概念配图,非真实界面

import os
from pathlib import Path
import subprocess
import tempfile

env = os.environ.copy()
env.update(GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_SYSTEM=os.devnull,
           GIT_TERMINAL_PROMPT='0')
with tempfile.TemporaryDirectory() as folder:
    def git(*args, check=True):
        return subprocess.run(['git', *args], cwd=folder, env=env,
                              text=True, capture_output=True, check=check)
    def commit_file(name, content):
        Path(folder, name).write_text(content, encoding='utf-8')
        git('add', name)
        git('commit', '-qm', name)
    def ancestry(old, target):
        return git('merge-base', '--is-ancestor', old, target,
                   check=False).returncode

    git('init', '-q', '-b', 'main')
    git('config', 'user.name', 'Example Author')
    git('config', 'user.email', 'author@example.invalid')
    git('config', 'commit.gpgsign', 'false')
    commit_file('base.txt', 'base\n')
    git('switch', '-qc', 'feature')
    commit_file('feature.txt', 'feature\n')
    original = git('rev-parse', 'HEAD').stdout.strip()
    git('switch', '-q', 'main')
    commit_file('main.txt', 'main\n')
    assert ancestry(original, 'main') == 1
    print('before:', 1)
    git('cherry-pick', original)
    assert ancestry(original, 'main') == 1
    assert git('cherry', 'main', 'feature').stdout.split() == ['-', original]
    print('after cherry-pick:', 1)
    print('equivalent patch:', True)
    git('merge', '--no-ff', '-qm', 'merge feature', 'feature')
    assert ancestry(original, 'main') == 0
    assert ancestry('main', 'main') == 0
    print('after merge:', 0)
    error = ancestry('missing-ref', 'main')
    assert error not in (0, 1)
    print('invalid reference is an error:', True)
print('ancestry checks passed')

三个退出状态要分开处理

is-ancestor 的第一个参数是待检查的旧提交,第二个参数是目标提交。返回零表示前者是后者的祖先,一个提交与自身比较也成立;返回一表示祖先关系不成立。其他非零状态表示命令遇到错误,不能当作否定答案继续发布或删除分支。

这个模式通常不靠标准输出给出判断,脚本应读取退出状态。若使用 Bash,可以把命令放入条件分支,并在失败分支立即保存状态;若使用 Python,像示例一样保留 returncode。不要先运行别的命令再读取上一次状态,那时记录的已经是另一件事。

参数方向同样重要。判断功能提交是否进入主线,是检查功能提交能否从主线追溯,反过来问则含义不同。为脚本中的两个变量取明确名称,记录实际解析到的提交编号,有助于复现某一次检查,而不是只记录会继续移动的分支名。

祖先检查不能代替其他验收

示例用 git cherry 证明摘取后的补丁存在等价版本,但这种等价判断也不是产品功能正确的证明。之后可能又有修改抵消了效果,也可能上下文变化使功能失效。祖先关系、补丁比较和运行测试各自回答不同问题,应根据具体发布要求组合使用。

对 squash 合并同样要留意:它可能把一串修改压成新提交,而不保留原功能提交的祖先身份。因此祖先判断返回一,不能直接推断那项功能从未上线。团队若采用这种合并方式,追踪规则应结合合并记录与明确的变更标识设计。

读取远程跟踪分支时,答案只针对本地已经取得的历史。引用没有更新、仓库为浅克隆或所需提交不在本地,都会影响你能检查的范围。先确认数据完整性和引用时间,再解释结果;命令本身不会替你自动刷新远端状态。

把它接入自动化前,还应验证用户传入的名称确实解析为提交,并妥善处理以连字符开头的参数。本文只使用自己创建的固定分支名。最有价值的回归样本是明确祖先、明确分叉、补丁相同但身份不同,以及无效引用,这四类足以暴露常见误判。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。