ARTICLE / ai

GitHub mergeable_state 不可信实录:31 个 PR 批量巡检的真伪冲突分拣

一套每天自动巡检仓库 Pull Request 的无人值守会话,某天面对的是 31 个开放中的 PR。GitHub API 返回的合并状态里,29 个写着 mergeable_state=unknown。此刻脚本站在一个分岔路口:把 unknown 当成「有冲突」去 rebase,或者当成「可合并」去合——两条路都是错的,而且第二条更危险。这次巡检最终把 29 个「未知」拆成了 8 个真冲突和 21 个缓存噪音,全程没有动一次不该动的分支。这篇文章把整个处置链拆开讲:为什么 unknown 不能当判词、用什么办法在不依赖 GitHub 缓存的前提下完成真伪分拣、rebase 的六步安全链怎么走、一次「上游已修复」的等价判定怎么下、以及一个从 2.2GB 涨到 9.2GB 的 .git 目录是怎么在眼皮底下长出来的。

unknown 是缓存的占位符,不是合并的判词

GitHub 的合并可行性不是一个实时计算的字段。你在 PR 页面看到的绿勾红叉、API 返回的 mergeable 与 mergeable_state,背后是 GitHub 后端异步计算的缓存结果。常见取值包括 clean(可以合并)、dirty(存在冲突)、blocked(被分支保护规则拦住),以及 unknown——它的字面意思是「还没算出来」,不是「有冲突」,更不是「没冲突」。

这次 31 个 PR 里 29 个 unknown,正是批量巡检场景的典型形态:巡检触发时往往刚有一批 PR 更新过、base 分支也刚合入过新提交,GitHub 的合并计算队列被自己的批量操作冲刷一遍,缓存集体失效。也就是说,unknown 风暴在很大程度上是巡检行为自己制造的——查得越勤,缓存越容易处于「正在算」的状态。

比 unknown 更隐蔽的是另一个字段的行为:updatedAt 会被任何评论 bump。如果你用「updatedAt 是否变化」来做变更检测,一条无关评论就会让一个 PR 看起来像被更新过。变更指纹必须排除这类社交噪音字段,否则自动化的输入端就先脏了。

由此确定本次巡检的第一条决策规则:unknown 不下任何结论。它既不进「待 rebase」队列,也不进「可合并」队列,而是进第三个队列——待实测分拣。

三层信号源:快而错的、贵而卡的、慢而真的

判断一个 PR 到底能不能干净地合进 main,有三个层次的信息源,成本和可信度恰好互为倒挂:

信号源速度与成本可信度典型失败模式
GitHub API 的 mergeable_state快、免费缓存结果,批量场景大量 unknown把缓存伪象当真实冲突,盲动 rebase
gh GraphQL 实时查询慢、消耗配额实时,但可能触发后台同步计算请求长时间挂起,自动化流程被卡死
本地 git merge-tree秒级、零成本以本地完整提交图为准,确定性运算本地是 shallow clone 缺历史时反而失真

第三层是这次分拣的主力。merge 是定义在提交图上的确定性运算:给定两个分支和它们的合并基,结果只有一种。git 2.38 之后的 git merge-tree --write-tree <分支1> <分支2> 可以在完全不触碰工作区、不触碰远端的前提下模拟一次真实合并——退出码非零即存在冲突,同时给出冲突文件清单,加 --name-only 可以只列文件名。更老的三参数形式(显式指定合并基加两个分支)也仍然可用,语义等价。它不消耗任何 API 配额,结果只取决于你本地那份提交图,可复现、可离线、可批量。

一条由此自然形成的工程纪律是:把判定与执行彻底分开。判定阶段全部只读(merge-tree、diff、log),便宜到可以对全部 31 个 PR 无差别跑;执行阶段才改写历史(rebase、force-push),昂贵且有风险,必须限流。判定可以全量,执行必须排队——这两个阶段混在一起,是自动化维护脚本最常见的结构缺陷。

29 个 unknown 的真实构成:8 个真冲突

分拣结果:29 个 unknown 里,本地 merge-tree 实测只有 8 个是真 dirty,其余 21 个在本地完整提交图上都能干净合并——纯粹是 GitHub 缓存还没刷新。超过四分之三的「未知」是噪音。

这个比例值得停一下。如果按 API 状态盲动,21 次 rebase 是白做的,而每一次无谓的 rebase 都以一次 force-push 收尾——每一次 force-push 都是风险敞口:万一期间有人往分支推了提交,plain force push 会无声无息地覆盖掉。伪象的真实代价不是浪费算力,是把分支历史置于不必要的重写风险之下。

处置节奏上定了两条纪律。其一,8 个真冲突按冲突文件数排序,从最简单的开始处理,每轮限流 4 个——既防 API 限流,也防时间失控。其二,时间预算设硬顶:本轮进行到第 50 分钟时,剩余 PR 的自动 rebase 果断放弃,留待下一轮。无人值守任务的失败模式往往不是「做得太少」,而是「做起来停不下来」——半途而废的一轮,好过失控的一晚。

rebase 的六步安全链

真冲突分支的修复不是一条 rebase 命令,而是一条六步链,每一步都在防一种具体的事故:

步骤动作防的事故
1git diff --name-only main...<PR分支> 核验 diff 只含 PR 自己声明的文件防把无关改动 rebase 进主线
2rebase onto main标准动作
3冲突语义移植,保留 main 侧的兜底块防 rebase 时顺手删掉主干新加的保护逻辑
4vitest、esbuild 等构建与测试验证防语义移植引入隐性破坏
5git push --force-with-lease防覆盖他人在 PR 上的新提交
6mergeable 状态复核,dirty 翻转为 True 才收口防「修完」和「修好」混为一谈

其中第 3 步最考验判断力。冲突两边并非天然一边对一边错,rebase 工具只会机械地问你要哪边,而主干上新加的兜底逻辑(防御性检查、回退路径)恰恰是最容易被「取我这边」顺手删掉的东西。语义移植的含义是:解决冲突时问的不是「哪边是我改的」,而是「这行代码在两边各自为什么存在」。

第 5 步单独说一句。--force-with-lease 与 plain force push 的区别在于:它只在远端分支指针仍停在你最后一次看到的位置时才接受推送——如果在你 rebase 期间有人往这个分支推了新提交,推送会被拒绝。自动化场景里这是唯一可接受的历史重写方式,没有例外。

第 6 步是整个链条的验收位:rebase 完成后重新核对 mergeable 状态从 dirty 翻转为可合并,翻转了才算数。改了但没改好就宣告完成,等于把事故推迟到合并那一刻。

sha256 相同时,关不关 PR

巡检中还遇到一类分支场景:某个 PR 锁定了特定依赖的 wheel 版本,而 main 上后来也有人做了同样的事。此时 PR 不是过时,而是被「等价修复」覆盖了。

判定等价的判据是内容而非意图:PR 所钉住的 wheel 的 sha256,与 main 最新 lockfile 里的记录完全一致——一字不差,即为等价覆盖。不比 commit message,不比 PR 描述,只比内容指纹。

处置动作是评论说明等价性证据,然后把 close 的决策留给仓库维护者。这里立着自动化维护的一条红线:除非「本人 PR、已获 APPROVED、合并状态 clean」三个条件同时满足,否则 0 close、0 merge。所有判定与证据链以评论形式留档在对应的 PR 上,让任何人可以在事后重走一遍判断过程。

这条红线看起来牺牲了自动化吞吐,实际上守住的是工具的角色边界:自动化是分拣器和执行器,不是决策者。关一个别人的 PR 是不可逆动作,而不可逆动作的默认归属是人。同类边界还有一个实例——改动 .github/workflows/* 的 PR 会被仓库的 label gate 卡住(缺 ci-reviewed 标签,而该标签无法自行添加),此时唯一正确的动作是评论提醒维护者,而不是想办法绕过门禁。

一个 9.2GB 的 .git 是怎么长出来的

同一次巡检发现的另一件事:本地工作仓的 .git 目录从 2.2GB 膨胀到了 9.2GB。诊断结果与 git 本身无关,根因在 origin 指向——这个仓库的 origin 配置的是一个第三方镜像源,而该镜像不支持 partial-clone 的 blob 过滤器。

git 的按需取回(lazy fetch)机制允许 clone 时只拿提交树、checkout 哪个文件再取哪个文件的内容 blob,这在官方远端上能省下大量流量与磁盘。但它依赖远端在协议握手时声明能力:远端不支持 blob 过滤,git 不会报错,而是静默退化成全量拉包——每一次本该只取几百 KB 的操作,都变成把相关历史整段拉回来。日积月累,.git 里堆满了本不需要的冗余对象,2.2GB 膨胀到 9.2GB。

修复是永久性的:把 origin 切回 GitHub 官方远端(upstream、fork 等其他 remote 保持原样),随后 git gc 回收冗余对象。膨胀部分随即消失。

这个案例的普适结论值得单独记:git 那些「智能」行为——partial clone、lazy fetch、按需取对象——全部依赖远端能力协商。远端能力缺失时的失败模式不是报错,而是静默走最昂贵的路径。镜像源在别处帮你省下的,会在本地磁盘上悄悄还回来,而且是以最不容易察觉的方式。

本地不可信的反转情形

前文一直说本地 merge-tree 是 ground truth,但这个结论有一个必须成立的前提:本地提交图完整。如果本地是 shallow clone(浅克隆),merge-tree 只能在残缺的提交图上运算——它算不出真实合并,此时 GitHub 的后台判定反而比本地更可信。

判别方法一行就够:git rev-parse --is-shallow-repository 返回 true,就先 git fetch --unshallow 把历史补全,再谈本地判定。

所以规则不是「信本地」,而是「信完整的一方」。两边都完整,本地赢——可复现、零成本、不受缓存影响;一边残缺,以完整一方为准。任何 ground truth 声明都绑着它的成立条件,这是这次巡检里比任何具体命令都更值得带走的东西。

写在最后

把整条判断链收拢:unknown 不当结论;三层信号源按成本分层使用;真伪分拣交给本地确定性运算;执行走六步安全链;等价判定看内容指纹;不可逆动作留给人。几个数字留个印象——29 个「未知」里只有 8 个真冲突;执行每轮限流 4 个;50 分钟是本轮时间预算的全部。

这次巡检真正演示的是一件事:自动化维护的价值不在于把人从决策里赶走,而在于把需要决策的少数(8 个真冲突)从大量不需要决策的多数(21 个缓存噪音)里精确地捞出来。分拣得越准,留给人的决策就越值得。

诚实边界:本文数字来自单次巡检会话的运行记录(31 个开放 PR、29 个 unknown、8 个真 dirty、50 分钟预算、.git 2.2GB 至 9.2GB),是那次快照而非统计规律;GitHub 对 mergeable_state 的缓存刷新周期没有公开的确定数值,本文不做猜测;vitest 与 esbuild 验证环节取决于具体仓库的技术栈,读者应替换为各自项目的构建与测试验证,机制等价。