Git 判断提交是否已合入:祖先关系成立,和补丁内容相同是两回事
文件里已经有修改,提交为什么还没有合入
维护分支里已经出现某次修复,脚本却说原来的提交不在这个分支历史里。这并不矛盾:修复可能通过 cherry-pick 重新形成了另一个提交。两次提交的内容改动可以相同,但父提交不同,原提交的身份与祖先关系不会因此自动保留下来。
判断“是否合入”之前,应把问题具体化。若关心某个提交是否能从目标分支沿父链追溯到,可以使用 merge-base 的 is-ancestor 模式。若关心补丁是否已经等价应用,则需要另外比较改动。两种答案可能不同,不能拿文件里出现了某行文字作为祖先证据。
在一个自动清理的仓库里对照
下面保存为 Python 文件运行,需要已安装 Git 二点二十八及以上和 Python 三点八及以上,本文使用 Git 二点五十二。Python 只负责建立临时目录、调用命令和断言;所有仓库操作都在临时目录中,运行结束会清理,不连接远程,也不接触现有仓库。
实验让主分支和功能分支各自前进,再把功能提交摘取到主分支。此时补丁等价但祖先判断仍为否。随后真正合并功能分支,原提交才进入主分支的可达历史。最后故意使用不存在的引用,检查错误没有被错误归类成普通的“尚未合入”。
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 合并同样要留意:它可能把一串修改压成新提交,而不保留原功能提交的祖先身份。因此祖先判断返回一,不能直接推断那项功能从未上线。团队若采用这种合并方式,追踪规则应结合合并记录与明确的变更标识设计。
读取远程跟踪分支时,答案只针对本地已经取得的历史。引用没有更新、仓库为浅克隆或所需提交不在本地,都会影响你能检查的范围。先确认数据完整性和引用时间,再解释结果;命令本身不会替你自动刷新远端状态。
把它接入自动化前,还应验证用户传入的名称确实解析为提交,并妥善处理以连字符开头的参数。本文只使用自己创建的固定分支名。最有价值的回归样本是明确祖先、明确分叉、补丁相同但身份不同,以及无效引用,这四类足以暴露常见误判。


