From f6940764dde5c9e35b39640c9a2e13e1d5b886e8 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sun, 13 Sep 2026 22:42:02 +0800 Subject: [PATCH 1/2] emrg: refresh the base in every spelling it may be written in, in the sequence gate too `check-merge-sequence.py::_refresh_base` held the same predicate the sibling `check-merge-landing-diff.py` was fixed for (#1193): only `origin/` was refreshed, so the *same* mutable remote-tracking ref written fully qualified was never refreshed and the tool answered from whatever the ref happened to be - the stale commit printed as if it named the remote branch, which is the defect this function's own docstring is about, one spelling over. Measured (`cyc20260913-223417`) in a hermetic clone whose `refs/remotes/origin/master` sat one commit behind the remote: --base origin/master -> base a0889b36 (true master) refreshed --base refs/remotes/origin/master -> base aa5e70f8 (stale) not refreshed --base refs/remotes/origin/HEAD -> base aa5e70f8 (stale) not refreshed The fully-qualified spelling is not exotic: `check-merge-pairs.py::_resolve_base` passes `refs/...` through untouched and its refusal text tells callers to "pass the fully-qualified ref you mean". `/HEAD` is the same defect through a symref: it names a remote branch only by pointing at one, `refs/heads/HEAD` does not exist upstream, and a fetch into a symref cannot be locked at all. It is now resolved through `symbolic-ref` and the target is refreshed; a `HEAD` spelling that names no remote-tracking branch of origin is refused (exit 2) instead of answered. The probe is confined to names ending in `/HEAD`, so an ordinary branch spelling still costs exactly one call and the existing single-call assertion stands. Also: the literal-ref test now covers `refs/heads/origin/master` (the shadowing stray), another remote and a tag. --- Agent.md | 2 +- scripts/check-merge-sequence.py | 51 ++++++++- tests/test_check_merge_sequence.py | 176 ++++++++++++++++++++++++++++- 3 files changed, 221 insertions(+), 8 deletions(-) diff --git a/Agent.md b/Agent.md index c41b0c30..29c4b8ef 100644 --- a/Agent.md +++ b/Agent.md @@ -133,7 +133,7 @@ Vote count: `uv run --no-sync python3 scripts/check-vote-count.py ...` — Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) Git-over-https push 兜底: `python scripts/push-branch-from-api.py --branch feature/x [--ref HEAD] [--force]` — 同一宕机场景下的 push 方向(#988 配对):从本地 ref 沿一父链找到远端基点(已有分支头或首个远端已知祖先),自底向上上传 blobs(原始字节)/trees(`git mktree` 语义复算)/commits(结构化创建,author/committer 携带原始 +0800 偏移、消息去尾随换行——GitHub 规范化行为),更新远端 ref 后把本地分支 ref 重写为远端 sha 并 `git diff` 验证内容一致;失败即止不触碰 refs(hermetic 测试经忠实假 API 验证字节级 sha 一致) Merge pairs: `uv run --no-sync python3 scripts/check-merge-pairs.py [--base ] ...` — 合并前问「**任意两张** PR 能不能一起落地」:m 张 PR 有 m*(m-1) 个有序对,每对实测 master -> A -> B 并让守卫判 A+B 合并后的树。存在理由(cyc20260913-091152 实测 6 张可合并 PR 全 15 对、两个方向共 30 次):14 个有序对**冲突**(安全,git 拦下),**唯 1173↔1174 静默合并且落地坏树**(都写 `1564` ⇒ git 保留一份、零冲突,而合并树实收 `1566`)——两张各自 MERGEABLE/CLEAN、各自双绿、各自单测都 OK,**只有这一对不行**,而该工具族的默认计划(全部在飞 PR 升序)在第 1 步冲突即停、根本走不到这一对。顺序敏感:先落一张会推新 head 重测另一张,故每次落地前重跑。成本二次方(m 张 ≈ m*(m-1) 次守卫运行,master+A 每张只合一次),所以要传你真正在权衡的那几张。退出码:0 = 每个有序对都有答案且无「干净合并却落地坏树」的对(被冲突挡住的对**已作答**——它落不了地,也就不会落地落坏;这与 check-merge-sequence 的冲突=3 相反,那里链上后续步骤没测),1 = 存在这样的对,2 = 测不出来(响亮失败)。 -Merge sequence: `uv run --no-sync python3 scripts/check-merge-sequence.py [--base ] ...` — 合并前问「按**这个顺序**合,每一步落地的树还过守卫吗」。健康是**步骤**属性:这一步的输入是上一步产出的树,所以任何「单 PR」事实都推不出它。存在理由(cyc20260912-174026 实测):#1167 与 #1166 都带 master、都写 `(1541)` ⇒ 各自合入都 CLEAN 且自洽,**依次**合入却仍是 CLEAN 而守卫在合并树上红(1541 文档 vs 1560 实收)——两个相等值写同一行,git 保留一份、冲突为零、陈旧数字进 master。危险与信号**反向**:计数**值不同**必冲突(安全,逼人看一眼),**值相同**才静默通过,而 `check-merge-order.py` 恰好按「弄脏别人最少」排序 ⇒ 「挑最省事顺序」读起来正是那句不安全的建议。逐 PR 健康检查在此队列会**退化**:所有 head 都含 master ⇒ `merge-tree master head` 的结果等于分支自己的树 ⇒ 守卫只在回答「这条分支自洽吗」,而待审分支永远自洽。退出码:0 = **每一步都测过**且落地健康,1 = 有「干净合并却落地不健康树」的步骤(本工具存在的那个发现),2 = 无法测量(响亮失败,绝不把「没测成」报成健康;**空计划也算无法测量**——`args.prs or _open_pr_numbers(...)` 决定「零步」只可能是**计划根本没取到**,而旧行为会印 `all 0 step(s) ... pass the guards` 并退出 0,即对一个树都没量过的运行**断言通过**,故改为 2;实测同一行为在父修订 `6456a98` 与本修订上逐字相同 ⇒ 既有缺口而非本次回归),3 = **计划在冲突步中断**(只测了前缀,其后各步**根本没测**——`cyc20260913-084752` 实测:默认调用(全部在飞 PR 升序)在第 1 步 `#1136` 冲突即停,**一个树都没判**却退出 0,与「全部验证通过」在退出码上不可区分;三种状态药方不同——解冲突重排 vs 重排危险步 vs 重试测量失败——故各自一码,同 `check-vote-count.py` 分 `BLOCKED`/`SHORT`。只想知道「有没有发现问题」的调用方可判 `rc in (0, 3)`。默认计划自 `cyc20260913-144807` 起是**累积式**的:按升序逐个试合到**前面各步建出的树**上(不是各自对 base——一个「两两都能干净合」的集合不是一条序列),合不动的**点名排除** ⇒ 默认调用能把整条队列测完(同一队列实测:11 张里只测到 3 步、退出 3 → 8 步全测、退出 0),而 3 现在只在 `--all` 或显式给号时出现)。 +Merge sequence: `uv run --no-sync python3 scripts/check-merge-sequence.py [--base ] ...` — 合并前问「按**这个顺序**合,每一步落地的树还过守卫吗」。健康是**步骤**属性:这一步的输入是上一步产出的树,所以任何「单 PR」事实都推不出它。存在理由(cyc20260912-174026 实测):#1167 与 #1166 都带 master、都写 `(1541)` ⇒ 各自合入都 CLEAN 且自洽,**依次**合入却仍是 CLEAN 而守卫在合并树上红(1541 文档 vs 1560 实收)——两个相等值写同一行,git 保留一份、冲突为零、陈旧数字进 master。危险与信号**反向**:计数**值不同**必冲突(安全,逼人看一眼),**值相同**才静默通过,而 `check-merge-order.py` 恰好按「弄脏别人最少」排序 ⇒ 「挑最省事顺序」读起来正是那句不安全的建议。逐 PR 健康检查在此队列会**退化**:所有 head 都含 master ⇒ `merge-tree master head` 的结果等于分支自己的树 ⇒ 守卫只在回答「这条分支自洽吗」,而待审分支永远自洽。退出码:0 = **每一步都测过**且落地健康,1 = 有「干净合并却落地不健康树」的步骤(本工具存在的那个发现),2 = 无法测量(响亮失败,绝不把「没测成」报成健康;**空计划也算无法测量**——`args.prs or _open_pr_numbers(...)` 决定「零步」只可能是**计划根本没取到**,而旧行为会印 `all 0 step(s) ... pass the guards` 并退出 0,即对一个树都没量过的运行**断言通过**,故改为 2;实测同一行为在父修订 `6456a98` 与本修订上逐字相同 ⇒ 既有缺口而非本次回归),3 = **计划在冲突步中断**(只测了前缀,其后各步**根本没测**——`cyc20260913-084752` 实测:默认调用(全部在飞 PR 升序)在第 1 步 `#1136` 冲突即停,**一个树都没判**却退出 0,与「全部验证通过」在退出码上不可区分;三种状态药方不同——解冲突重排 vs 重排危险步 vs 重试测量失败——故各自一码,同 `check-vote-count.py` 分 `BLOCKED`/`SHORT`。只想知道「有没有发现问题」的调用方可判 `rc in (0, 3)`。默认计划自 `cyc20260913-144807` 起是**累积式**的:按升序逐个试合到**前面各步建出的树**上(不是各自对 base——一个「两两都能干净合」的集合不是一条序列),合不动的**点名排除** ⇒ 默认调用能把整条队列测完(同一队列实测:11 张里只测到 3 步、退出 3 → 8 步全测、退出 0),而 3 现在只在 `--all` 或显式给号时出现)。base 写成**任一 remote-tracking 写法**(`origin/` 或 `refs/remotes/origin/`——后者正是兄弟 `check-merge-pairs.py` 的拒绝文案明确让调用者用的写法,它把 `refs/...` 原样透传)都会**先 fetch 刷新**再测量;`origin/HEAD` / `refs/remotes/origin/HEAD` 则先经 symref 解析到它指向的那份 remote-tracking 分支再刷新(**直接 fetch 进 symref 无法加锁,git 拒绝**,而 `refs/heads/HEAD` 上游不存在;解析不出来即 exit 2,绝不照抄旧值)。cyc20260913-223417 实测:同一个 clone 里 `--base origin/master` 刷新到真 base(`a0889b36`),而 `--base refs/remotes/origin/master` 读的仍是那个陈旧提交(`aa5e70f8`)——**同一个可变 ref 的另一种写法不能是另一个答案**。 Merge tree health: `uv run --no-sync python3 scripts/check-merge-tree-health.py [PR...] [--base ]` — 合并前问**别人都不问的那一句**:把这张 PR 合下去,**产出的那棵树**还能不能通过本仓库自己的守卫?现有闸门问的都是「关于这张 PR」的问题(票还算不算数、base 还能不能到 master、CI 绿灯是不是关于这棵树、合下去会弄脏谁),没有一个问合并**之后** master 是否健康。存在理由(cyc20260912-040220 实测,清空 11 张绿灯 PR 队列时):#1133 与 #1140 各自加测试、各自把 Agent.md 的 Python 计数行改写成**对自己成立**的值(两张自己的树都是 1500 收 1500,自洽),而**合起来**:两边都写 1500 ⇒ git **无冲突**保留一份 ⇒ 合并树收 **1506** 而文档仍写 1500 ⇒ 守卫 FAIL。**无冲突的那种最危险**:有冲突会逼人看一眼,同一行干净合并看起来什么都没发生;而 `check-merge-order.py` 恰恰把这对报成「弄脏别人最少」,即「最便宜先合」会主动推荐那个会落地不一致树的合并(#1137 也是同一形态,只是走的是「CI 绿灯过期」那条路)。为什么不建模:计数**无法**由两侧推出来,不是 max 也不是相加——它是合并树实际收集到的数(取决于 import、conftest、参数化),所以**把合并树建出来**(scratch 目录,绝不动工作区)并在里面跑**该树自己的** `scripts/check-doc-count.py`(与 CI 同一个守卫、同一个路径、读它自己的树,后者自 #1140 起会打印 tree:),建模只会把猜的位置往上挪一层。**同一个周期实测的第二个坑(本工具存在的另一半理由)**:守卫会打印 `Fix with: ... --write`,而 `--write` 改的是**该守卫解析到的树**里的文件;跑完只提交**合并**而不提交**那次修改**,推上去的 head 依旧写着旧计数,CI 自己的守卫随即在推上去的 commit 上红(本地跑一次全绿也没用)——本工具判定的是**你问的那个已提交 head**,因为推前要问的是「我要合的这个 commit 过不过」,不是「我的工作区过不过」。exit 0 全部干净且健康 / 1 有「干净但合并树自身守卫不过」的(真正的发现)/ 2 问不出来(gh/git/守卫失败,绝不报健康);**冲突单列为 CONFLICT 不算失败**——合不成就没有合并树可判,那是 `check-merge-order.py` 的问题。`check-vote-count.py` → `check-pr-base.py` → `check-merge-freshness.py` → `check-merge-order.py` → 本工具:最后这一环才是「合完 master 还健康吗」 Merge-order forecast: `uv run --no-sync python3 scripts/check-merge-order.py [PR...] [--json]` — 合并前预报**每张 PR 合下去会弄脏哪些别的 PR**,把「合并的代价」提前变成可见数字。存在理由(cyc20260911-225712 实测):11 张 PR 各自 `MERGEABLE/CLEAN` 且各自 CI 绿、又都领先 master,于是**任何一张都能合**;而合掉一张会让其余十张在**同一行**(Agent.md 的 Python 计数行)转 `CONFLICTING/DIRTY` — 脏 PR 拿不到 `pull_request` run(无 CI)、不能合,而解冲突必须推 head ⇒ **那些 PR 的票全部作废**(三张差一票的 PR 被打回 0/3)。代价是真的,且此前只在事后才发现。为什么不靠推理:直觉说法「它们都动计数行」**近乎**正确却**不能当规则**——同一天 55 对里有 4 对虽然共享 Agent.md 却**不冲突**(是否冲突取决于改动落点多近,是树的性质不是文件列表的性质),故问 `git merge-tree --write-tree`:rc 0 = 干净、1 = 冲突(并从输出首块读出**冲突的是哪个文件**,只报「有冲突」不足以决策)、其它 rc 一律算**没问出来**(**绝不当作冲突**——把测量失败报成冲突会凭空造出一场级联,调用方会为不存在的冲突白花一次解冲突)。推荐顺序看「合掉它弄脏几张」:弄脏最少的先合最便宜,但**最便宜 ≠ 最有价值**(当天正确的第一步恰恰是冲突**最多**的 #1149,因为它解开了后面每一张堆叠 PR 的 CI)。**自曝的坑(写这个工具的当天就被自己的实测抓住)**:先把 master fetch 进 `FETCH_HEAD`、再把**名字** `FETCH_HEAD` 当 base 传下去,而 `git fetch refs/pull//head` **也会重写 `FETCH_HEAD`** ⇒ 每对测量时的 base 已经变成最后 fetch 的那张 PR head;输出**完全像模像样**(「只有 #1152 对 master 可合、其余全冲突」),实际 base 就是 #1152 自己的 head(所以那两侧才「相同」);破绽在计数行——那次运行关于 master 一个数字都没说,而真 master 是 1490、运行却表现得像 base 写着 1503。规则因此是:**可变 ref 名永不进 `merge-tree`**,base 先用 `rev-parse` 解析成 SHA,并由 `test_no_mutable_ref_name_reaches_merge_tree` 钉住该不变量(正是本可最早抓住它的测试) Conflict triage: `python3 scripts/classify-conflict.py ... [--all]` — 合并冲突的**分类器(只报告,绝不改文件,exit 1 = 有需要人判的块)**,把「两边都动了同一个文件」拆成五种可判定的形状并给出证据支持的解法:`count-line`(两侧只差一个**带括号的计数**数字 ⇒ 先分清它是「存储的计数」还是「旧分支带进来的写死值」:GUI/Renderer 的分解是文档里的事实,必须在合并后的树上**测量**;Python 测试数自 2026-09-13 起不写进任何文档,故那一行一律**删掉数字**,永不选边,交给 `check-doc-count.py --resolve-conflict`)、`duplicate`(两侧**声明符号**互为真超集**且共有符号的正文全同** ⇒ 取超集那侧)、`disjoint`(声明符号不相交 ⇒ **两边都保留**)、`overlapping`(同名符号,或超集但共有符号正文不同,或单行无判据 ⇒ 必须人读)、`identical`。存在理由(cyc20260911-112155 实测):同一轮里同一形状需要**相反**的解法——#1136 的分支带着 #1134 的**未合并副本**(分支基于 #1134 而后者已被 squash,祖先关系不可见),master 是严格超集故「取 theirs」正确;#1140 两侧是**不相交新增**,取任何一侧都会静默丢掉另一侧 4 个探针,唯一正解「两边都留」。一条 `--theirs` 对前者对、对后者错。两条判别轴都是被实测打出来的:① 必须是**声明符号**而非文本行——初版按内容行比较,因两侧都含 ` """` 与 ` )` 这种样板行,把两个真实冲突全判成 `overlapping`;② 名字子集**不等于**内容包含——只比名字时「master 声明了分支所有名字」被读成「master 含分支全部内容」,而两侧同名 `test_alpha` 正文不同时取超集会**静默丢掉分支对它的修改**(正是本工具要防的数据丢失);③ `count-line` 必须限定为**带括号的计数**,否则 `x = compute(1)` vs `compute(2)` 这种纯代码差异也会被答「测量、永不选边」并 exit 0,把唯一该人看的情况关掉。三条均已钉为变异验证的回归测试(cyc20260911-120717)。**第四类:读不了的布局即拒绝**(2026-09-11 实测)——`merge.conflictStyle = diff3` 会在 ours 与分隔符之间插入 `||||||| ` 段,而 `CONFLICT_BLOCK` **照样匹配**(把 base 段吞进 ours),于是判出来的两侧是(ours+base)vs theirs:一段 `disjoint` 被判成 `KEEP BOTH` 并 exit 0,而「都保留」恰好会把**谁都不想要的 base 副本**拼回去;本仓库的真实形态(Agent.md 计数行)更隐蔽——判成 `duplicate - ours is a strict superset - take OURS`,因为 base 段(带着**陈旧**计数的第三份拷贝)被算成 ours 多出来的行。故先于解析检查该标记并报 `unparsed-layout` + exit 1,与 `check-doc-count.py` 对同一布局的具名拒绝一致(那条路是 read-only 无损失,只会不必要地拦住一个本可解的 PR;这条路的错误建议会让解冲突的人**删掉自己的改动**,代价不对称)。**第五类:跨行聚合与「同一段落的两个修订」**(cyc20260911-190629 实测,两个缺陷都在**本工具自己**里,且都靠「拿全部真实冲突块做差分」而非手写 fixture 才暴露出来——手写 fixture 只会覆盖我本来就相信的形状):① 一条 `count-line` 原先是「两侧各一行」,但 51 次改 Agent.md 的提交里有 **3 次一次动掉 2 个以上计数**,git 会把它们并成**一个**块,该块因此落到内容行兜底、被判「两侧无共同行 ⇒ KEEP BOTH(拼接)」并 exit 0——拼接恰好把每个计数行**各留两份**,正是本仓库 `_duplicated_count_line_kinds` 守卫要拒的状态;现改为**对齐的、逐对只差数字的**块一律 `count-line`(任意长度,且每对都要满足带括号计数)。② 「两侧无共同行」**不等于**「各自新增」——同一段落的新旧两个修订永远不相等。当前 #1140 的 Agent.md 真实块里,ours 的两行段落正是 master 两行的**严格前缀**(890 vs 539、601 vs 471 字符),兜底判 KEEP BOTH 会同时留下陈旧与当前两份段落;现改为见到严格前缀关系即报 `overlapping` 交人读(前缀证据弱于符号子集,误差不对称:升级只是多读一次,「取 theirs」会静默丢一行)。两个修复各配变异验证(还原旧行为即测试变红)。**第六类:计数行与正文修订混在一个块里**(cyc20260911-194733 实测,发生在刚合入的第五类修复**自身**上):第五类要求「两侧等长且**每一对**都只差数字」,而真实块常把**计数行**和**正文行**配在一起——cb651a4 的真实块正是 ours「Python 计数(1393) + Doc count sync 正文」对 theirs「同一计数行的另一个值 + 同一正文的另一次改写」,两侧等长却**有一对不是计数对**,于是仍落到内容行兜底、判 KEEP BOTH 并 exit 0,而拼接结果里有两个 ``Python: `uv run pytest` `` 行(即文档声称两个 pytest 数),正是 `_duplicated_count_line_kinds` 要拒的状态;且计数数字**在行内**,前面的严格前缀判据(`b.startswith(a)`)对它**结构性不可见**——它只认「短的是长的前缀」,而这里数字之后的内容被改写,两侧互不为前缀。改判据为**按下标配对**:只看对应位置上的行对,任一对「两边都带括号计数且遮罩数字后相等」即报 `overlapping` 交人读(不再要求等长);证据强度就是遮罩本身——遮罩后相等意味着**同一个事实被重新测量过**,而同一事实出现两个值正是仓库守卫要拒的,故 KEEP BOTH 对它必错。实测收益(最近 400 次改 Agent.md 的提交,逐 hunk 差分):7 个 hunk 在兜底里带上计数行且**全部**给出**重复内容**的建议——e46c160/0c8a212(对齐,已由第五类改判 count-line)、cb651a4(2v2)/5c039b4(3v3)(等长但混了正文修订,第五类看不见)、3335877(1v2)/444e1d5(1v2)/18fd0af(1v13)(不等长);本条修复把余下 5 个全部转为 `overlapping`,全仓 150 次提交 1039 个 hunk 差分仅 6 处类别变化(5 个 Agent.md + 1 个 `preload-api.test.js`,后者也是真阳性:两侧写着 54/53 与 55/54 两个不同成员数)。变异验证:退回「只查前缀」即新增测试变红;对面(真实互不相干的两个新增)仍判 disjoint。**第七类:同一计数行的「重新拆分」**(cyc20260912-002444 实测,仍是拿真实冲突块差分出来的):第六类要在「遮罩数字后**完全相等**」,而真实块里同一个计数**种类**还会被**重新拆分**——47af6bc2 的真实合并块里 ours 是 `GUI: … (92: 45 daemon_client + … + 3 preload-api + 3 boot-contract)`,master 是**同一行**的 `(89: … + 3 preload-api)`(少一个组件、总数也从 92 变 89),两侧**行数 1 vs 2**且遮罩后不等,于是两条规则都看不见,仍判 `disjoint - KEEP BOTH` 并 exit 0,而拼接结果里有两个 `GUI: ` 行——正是 `_duplicated_count_line_kinds` 要拒的状态(本测试直接把拼接结果喂给该守卫,不是靠眼看)。判据改为**按「计数种类」而非「整行相等」**:两侧「首个计数之前的文本完全相同」即同一行被重新测量过(该文本正是命令与种类名),报 `overlapping` 交人读。实测范围:用**真实合并提交**(对每个 merge 的三个真实 blob 跑 legacy `git merge-tree`)重建出 **185** 个冲突块,该规则**只改 1 个类**(就是这一块,`disjoint`→`overlapping`),其余不动;反向对照(`Python:` 对 `GUI:` 这种不同种类)不升级。变异验证:删掉该子句,新增测试即变红(`disjoint`)。**第八类:同一行的「行内」修订**(cyc20260913-182526 实测):第五类的判据是**严格前缀**,而那只描述「改在行尾」——真实块里同一行还会被**行中插入**(复现 #1182 时 Agent.md 的真实块:ours 1 行 = theirs 3 行中那行的偏移 1091 处插入 187 字符,即 `ours = theirs[:1091] + 187 字符 + theirs[1091:]`),两侧互不为前缀,第五、六、七类都看不见,仍判 `disjoint - KEEP BOTH` 并 exit 0,而拼接把该文档行**留了两份**(1280 与 1093 字符、同 head),正是 `_duplicated_count_line_kinds` 要拒的状态。判据改为**恰好一处连续插入或删除**(取最长公共前缀与后缀后,两段中间至少有一段为空),位置不限。实测(把真实 PR 头逐个合入当前 master 的 scratch worktree:16 个会冲突的 PR 得 **67** 个真实冲突块):该判据只改 4 个类,全部 `disjoint` → `overlapping` 且全部为真阳性(两个是上述文档句形态、一个是代码注释、一个是折行散文),反向一个没有;误升级代价在 20 万对**无关**真实内容行上量得 10 对(0.005%,旧前缀规则 1 对),多出的都是 `def stop_all` 与 `def _stop_all` 这类改名/缩进对——本就该人看一眼。反向对照:**两处**独立改动(改名外加插入短语)中间两段都非空 ⇒ 仍判 `disjoint`,防止退化成「两行长得像」。变异验证:退回前缀判据、以及把判据放宽成「公共前后缀够长」,各自的新增测试分别变红。 diff --git a/scripts/check-merge-sequence.py b/scripts/check-merge-sequence.py index 7dbc7e54..a7cc4f99 100755 --- a/scripts/check-merge-sequence.py +++ b/scripts/check-merge-sequence.py @@ -475,9 +475,23 @@ def _refresh_base(base: str) -> None: already documents for `__file__`-relative tools; the base is the same trap in the time dimension. - Only `origin/` is refreshed: any other ref is taken literally, and a - SHA is immutable by construction. A fetch failure is a measurement error - - the caller must not silently continue against a base it could not verify. + A **remote-tracking** ref is refreshed, in either spelling the caller may write + it: `origin/` and `refs/remotes/origin/` are the same mutable + ref, and the fully-qualified one is not exotic - `check-merge-pairs.py`'s + `_resolve_base` passes `refs/...` through untouched and its refusal text tells + callers to "pass the fully-qualified ref you mean". Accepting only the short + spelling meant that one answered from whatever the ref happened to be, with the + stale commit printed as if it named the remote branch - the same wrong-tree + defect this docstring is about, one spelling over (`cyc20260913-223417`; the + sibling `check-merge-landing-diff.py` measured it first and the fix landed + there in #1193). + + Anything else is taken literally: a SHA is immutable by construction, a local + branch is not the remote ref whatever it is called, and a refspec the caller + already wrote (`origin/x:dest`) is passed to git as given. A fetch failure - or + a `/HEAD` spelling that is not a symref to a remote-tracking branch of + `origin` - is a measurement error: the caller must not silently continue + against a base it could not verify. The destination is written **fully qualified**. A bare `origin/master` as a fetch destination is ambiguous, and git resolves it by creating a *local @@ -485,11 +499,36 @@ def _refresh_base(base: str) -> None: version of this fix silently littered the checkout with a ref that shadows the remote-tracking one and makes every later `origin/master` ambiguous (`cyc20260912-203927`, caught by git's own "refname is ambiguous" warning). + + A `/HEAD` spelling is resolved through its symref first: it names a + remote branch only by pointing at one, and a fetch *into* a symref cannot be + locked (git refuses and leaves the symref unchanged). Refreshing the *target* is + what makes `--base origin/HEAD` mean "origin's default branch as it is now" - + and it is the natural spelling for a checkout whose default branch is not + `master`, which used to fail with a fetch of the non-existent + `refs/heads/HEAD`. Under `refs/remotes/` the only symbolic ref git creates is + `/HEAD`, so the probe is confined to that name and an ordinary branch + spelling still costs exactly one call. """ - if ":" in base or not base.startswith("origin/"): + if ":" in base: + return + if base.startswith("origin/"): + dest = f"refs/remotes/origin/{base[len('origin/'):]}" + elif base.startswith("refs/remotes/origin/"): + dest = base + else: return - branch = base[len("origin/"):] - dest = f"refs/remotes/origin/{branch}" + if dest.endswith("/HEAD"): + link = _run(["git", "symbolic-ref", "--quiet", dest]) + target = link.stdout.strip() + if link.returncode != 0 or not target.startswith("refs/remotes/origin/"): + detail = link.stderr.strip() or "no such ref" + raise MeasurementError( + f"could not refresh {base}: {dest} is not a symbolic ref to a " + f"remote-tracking branch of origin ({detail})" + ) + dest = target + branch = dest[len("refs/remotes/origin/"):] proc = _run(["git", "fetch", "--quiet", "origin", f"+refs/heads/{branch}:{dest}"]) if proc.returncode != 0: detail = proc.stderr.strip() or proc.stdout.strip() or "unknown error" diff --git a/tests/test_check_merge_sequence.py b/tests/test_check_merge_sequence.py index d0f6a61c..2b90b89e 100644 --- a/tests/test_check_merge_sequence.py +++ b/tests/test_check_merge_sequence.py @@ -441,12 +441,186 @@ def test_a_sha_or_local_ref_base_is_never_fetched(mod, monkeypatch): mod, "_run", lambda argv, cwd=None: (calls.append(argv), None)[1] ) - for ref in ("0" * 40, "localbase", "refs/heads/x", "FETCH_HEAD"): + for ref in ( + "0" * 40, + "localbase", + "refs/heads/x", + "FETCH_HEAD", + # a local branch that merely *looks* like a remote-tracking name: the fetch + # destination is written fully qualified, so this is the stray that shadows + # `origin/master` - refreshing "it" would overwrite the caller's own branch + "refs/heads/origin/master", + # another remote, and a tag: `origin` is the only remote this tool fetches from + "refs/remotes/upstream/master", + "refs/tags/v1.0.0", + ): mod._refresh_base(ref) assert calls == [], calls +# --- both spellings of the same mutable ref, and the symref spelling ---------------- +# +# Refreshing only `origin/` left the *same* remote-tracking ref unrefreshed when +# it was written fully qualified - the spelling `check-merge-pairs.py`'s `_resolve_base` +# passes through untouched while its refusal text tells callers to "pass the +# fully-qualified ref you mean". Measured (`cyc20260913-223417`) in a hermetic clone whose +# `refs/remotes/origin/master` sat one commit behind, against the master tip they were +# meant to name: +# +# --base origin/master -> base a0889b36 (true master) refreshed +# --base refs/remotes/origin/master-> base aa5e70f8 (stale) not refreshed +# --base refs/remotes/origin/HEAD -> base aa5e70f8 (stale) not refreshed +# +# The sibling `check-merge-landing-diff.py` measured the shape first and the fix landed +# there in #1193; this is the same defect in this file's own copy of the helper. + + +def test_every_remote_tracking_spelling_is_refreshed(mod, monkeypatch): + """`refs/remotes/origin/` is the same mutable ref as `origin/`. + + Pinned at the argv level so both spellings are visible in one place: a predicate + that admits only the short one is exactly the shape that was shipped, and it is + invisible to a test that only ever passes the short spelling. + """ + calls: list[list[str]] = [] + + class _Done: + returncode = 0 + stdout = "" + stderr = "" + + monkeypatch.setattr(mod, "_run", lambda argv, cwd=None: (calls.append(argv), _Done())[1]) + + for base in ("origin/master", "refs/remotes/origin/master", "refs/remotes/origin/main"): + calls.clear() + mod._refresh_base(base) + assert len(calls) == 1, (base, calls) + joined = " ".join(calls[0]) + branch = base.rsplit("/", 1)[-1] + assert f"refs/heads/{branch}:refs/remotes/origin/{branch}" in joined, (base, calls) + assert "+" in joined, "the refspec must be forced, as for PR heads" + + +def test_a_head_spelling_is_refreshed_through_its_symref(mod, monkeypatch): + """`origin/HEAD` and `refs/remotes/origin/HEAD` are fetched at the ref they point to. + + Not at their own name: `refs/heads/HEAD` does not exist upstream (the fetch fails), + and writing into a symref cannot be locked at all - git refuses and leaves the + symref unchanged, so "refresh it in place" is not an option git offers. + """ + calls: list[list[str]] = [] + + class _Done: + returncode = 0 + stdout = "" + stderr = "" + + def fake_run(argv, cwd=None): + calls.append(argv) + done = _Done() + if "symbolic-ref" in argv: + done.stdout = "refs/remotes/origin/trunk\n" + return done + + monkeypatch.setattr(mod, "_run", fake_run) + + for base in ("origin/HEAD", "refs/remotes/origin/HEAD"): + calls.clear() + mod._refresh_base(base) + probes = [c for c in calls if "symbolic-ref" in c] + fetches = [c for c in calls if "fetch" in c] + assert len(probes) == 1 and len(fetches) == 1, (base, calls) + assert probes[0][-1].endswith("/HEAD"), (base, calls) + assert "refs/heads/trunk:refs/remotes/origin/trunk" in " ".join(fetches[0]), (base, calls) + + +def test_a_head_spelling_that_names_no_remote_branch_is_a_measurement_error(mod, monkeypatch): + """A `HEAD` spelling that is not a symref to `origin`'s tracking refs is refused. + + Two shapes, both refusals rather than answers: the name is not a symbolic ref at + all (a hand-made plain ref, or a clone without the symref), and it points somewhere + outside `refs/remotes/origin/` - in neither case does the tool know which remote + branch to refresh, and answering without one is the stale-base reading again. + """ + + class _Plain: + returncode = 1 + stdout = "" + stderr = "" + + class _Elsewhere: + returncode = 0 + stdout = "refs/heads/master\n" + stderr = "" + + for outcome, base in ( + (_Plain(), "refs/remotes/origin/HEAD"), + (_Elsewhere(), "origin/HEAD"), + ): + monkeypatch.setattr(mod, "_run", lambda argv, cwd=None: outcome) + with pytest.raises(mod.MeasurementError) as excinfo: + mod._refresh_base(base) + assert "symbolic ref" in str(excinfo.value), (base, excinfo.value) + + +def test_a_stale_qualified_base_is_refreshed_with_real_git(mod, tmp_path, monkeypatch): + """The defect's effect, with real git: the qualified spelling follows the remote. + + The mocked tests above pin the argv; this one pins that the ref the tool then + *reads* is the remote's current commit. It is the arm that fails before the fix - + `_refresh_base` returned immediately, so the ref stayed stale and every reading + below it was about a tree the caller did not name - and it is re-armed between + spellings so one arm cannot hand the next an already-current ref. + """ + def git(cwd: Path, *args: str) -> str: + out = subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ) + return out.stdout.strip() + + bare = tmp_path / "remote.git" + bare.mkdir() + git(bare, "init", "-q", "--bare", "-b", "master") + + seed = tmp_path / "seed" + seed.mkdir() + git(seed, "init", "-q", "-b", "master") + git(seed, "config", "user.email", "t@example.com") + git(seed, "config", "user.name", "t") + (seed / "a.txt").write_text("one\n", encoding="utf-8") + git(seed, "add", "-A") + git(seed, "commit", "-qm", "first") + git(seed, "remote", "add", "origin", str(bare)) + git(seed, "push", "-q", "origin", "master") + + repo = tmp_path / "clone" + subprocess.run( + ["git", "clone", "-q", str(bare), str(repo)], + capture_output=True, text=True, encoding="utf-8", check=True, + ) + stale = git(repo, "rev-parse", "refs/remotes/origin/master") + + (seed / "a.txt").write_text("one\ntwo\n", encoding="utf-8") + git(seed, "add", "-A") + git(seed, "commit", "-qm", "the remote moves on") + git(seed, "push", "-q", "origin", "master") + advanced = git(seed, "rev-parse", "HEAD") + + assert git(repo, "rev-parse", "refs/remotes/origin/master") == stale, "precondition" + + monkeypatch.chdir(repo) + for base in ("refs/remotes/origin/master", "origin/HEAD", "refs/remotes/origin/HEAD"): + git(repo, "update-ref", "refs/remotes/origin/master", stale) + assert git(repo, "rev-parse", "refs/remotes/origin/master") == stale, base + mod._refresh_base(base) + assert git(repo, "rev-parse", "refs/remotes/origin/master") == advanced, base + assert git(repo, "rev-parse", "master") == stale, ( + "the refresh moves the remote-tracking ref only, never a local branch" + ) + + def test_main_refreshes_the_base_before_measuring(mod, monkeypatch, capsys): """`main` must actually call `_refresh_base` - a correct helper nobody calls is dead. From 35e7f2e6c3912ee14881fefa4cbd12279793d05f Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sun, 13 Sep 2026 23:03:05 +0800 Subject: [PATCH 2/2] emrg: decide the /HEAD spelling by what the ref is, not by its name `_refresh_base` refused any base whose name ends in `/HEAD` unless `git symbolic-ref` resolved it to a remote-tracking branch of `origin`. But `feature/HEAD` is a legal branch name (`git check-ref-format --branch feature/HEAD` accepts it), so `refs/remotes/origin/feature/HEAD` is an ordinary remote-tracking branch that merely ends in `/HEAD` - and it was reported as a measurement error instead of being fetched. That is a regression: before the `/HEAD` handling landed (#1195) the spelling was fetched correctly. Measured (`cyc20260913-225642`) in a hermetic clone whose `refs/remotes/origin/feature/HEAD` sat one commit behind the remote (`git symbolic-ref` confirms the ref is not a symbolic ref): --base origin/feature/HEAD -> MeasurementError, ref left stale --base refs/remotes/origin/feature/HEAD -> MeasurementError, ref left stale The suffix is not the discriminator. The only symbolic ref git creates under `refs/remotes/` is `/HEAD`, but that name is also the legitimate tracking ref of a branch named `.../HEAD`, so only git can say which one a ref is. The probe now *resolves* a name git reports as symbolic; a name it does not is fetched like any other branch, and a symref that leads outside `origin`'s tracking refs stays a measurement error - as does a failed fetch, so nothing is ever answered from a base that could not be verified. Tests: the name-keyed refusal test is replaced by three - a branch named `feature/HEAD` is refreshed (pinned at the argv level), a symref leading elsewhere is still refused, and a non-symref name whose fetch fails is still never answered - plus a real-git arm that fails before the fix (the tracking ref must follow the remote). Four mutants - name-keyed refusal, probe-every-base, dropped target validation, skip-non-symref - are each killed by the test aimed at them. --- Agent.md | 2 +- scripts/check-merge-sequence.py | 40 ++++--- tests/test_check_merge_sequence.py | 163 ++++++++++++++++++++++++++--- 3 files changed, 177 insertions(+), 28 deletions(-) diff --git a/Agent.md b/Agent.md index 29c4b8ef..a384eaa3 100644 --- a/Agent.md +++ b/Agent.md @@ -133,7 +133,7 @@ Vote count: `uv run --no-sync python3 scripts/check-vote-count.py ...` — Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) Git-over-https push 兜底: `python scripts/push-branch-from-api.py --branch feature/x [--ref HEAD] [--force]` — 同一宕机场景下的 push 方向(#988 配对):从本地 ref 沿一父链找到远端基点(已有分支头或首个远端已知祖先),自底向上上传 blobs(原始字节)/trees(`git mktree` 语义复算)/commits(结构化创建,author/committer 携带原始 +0800 偏移、消息去尾随换行——GitHub 规范化行为),更新远端 ref 后把本地分支 ref 重写为远端 sha 并 `git diff` 验证内容一致;失败即止不触碰 refs(hermetic 测试经忠实假 API 验证字节级 sha 一致) Merge pairs: `uv run --no-sync python3 scripts/check-merge-pairs.py [--base ] ...` — 合并前问「**任意两张** PR 能不能一起落地」:m 张 PR 有 m*(m-1) 个有序对,每对实测 master -> A -> B 并让守卫判 A+B 合并后的树。存在理由(cyc20260913-091152 实测 6 张可合并 PR 全 15 对、两个方向共 30 次):14 个有序对**冲突**(安全,git 拦下),**唯 1173↔1174 静默合并且落地坏树**(都写 `1564` ⇒ git 保留一份、零冲突,而合并树实收 `1566`)——两张各自 MERGEABLE/CLEAN、各自双绿、各自单测都 OK,**只有这一对不行**,而该工具族的默认计划(全部在飞 PR 升序)在第 1 步冲突即停、根本走不到这一对。顺序敏感:先落一张会推新 head 重测另一张,故每次落地前重跑。成本二次方(m 张 ≈ m*(m-1) 次守卫运行,master+A 每张只合一次),所以要传你真正在权衡的那几张。退出码:0 = 每个有序对都有答案且无「干净合并却落地坏树」的对(被冲突挡住的对**已作答**——它落不了地,也就不会落地落坏;这与 check-merge-sequence 的冲突=3 相反,那里链上后续步骤没测),1 = 存在这样的对,2 = 测不出来(响亮失败)。 -Merge sequence: `uv run --no-sync python3 scripts/check-merge-sequence.py [--base ] ...` — 合并前问「按**这个顺序**合,每一步落地的树还过守卫吗」。健康是**步骤**属性:这一步的输入是上一步产出的树,所以任何「单 PR」事实都推不出它。存在理由(cyc20260912-174026 实测):#1167 与 #1166 都带 master、都写 `(1541)` ⇒ 各自合入都 CLEAN 且自洽,**依次**合入却仍是 CLEAN 而守卫在合并树上红(1541 文档 vs 1560 实收)——两个相等值写同一行,git 保留一份、冲突为零、陈旧数字进 master。危险与信号**反向**:计数**值不同**必冲突(安全,逼人看一眼),**值相同**才静默通过,而 `check-merge-order.py` 恰好按「弄脏别人最少」排序 ⇒ 「挑最省事顺序」读起来正是那句不安全的建议。逐 PR 健康检查在此队列会**退化**:所有 head 都含 master ⇒ `merge-tree master head` 的结果等于分支自己的树 ⇒ 守卫只在回答「这条分支自洽吗」,而待审分支永远自洽。退出码:0 = **每一步都测过**且落地健康,1 = 有「干净合并却落地不健康树」的步骤(本工具存在的那个发现),2 = 无法测量(响亮失败,绝不把「没测成」报成健康;**空计划也算无法测量**——`args.prs or _open_pr_numbers(...)` 决定「零步」只可能是**计划根本没取到**,而旧行为会印 `all 0 step(s) ... pass the guards` 并退出 0,即对一个树都没量过的运行**断言通过**,故改为 2;实测同一行为在父修订 `6456a98` 与本修订上逐字相同 ⇒ 既有缺口而非本次回归),3 = **计划在冲突步中断**(只测了前缀,其后各步**根本没测**——`cyc20260913-084752` 实测:默认调用(全部在飞 PR 升序)在第 1 步 `#1136` 冲突即停,**一个树都没判**却退出 0,与「全部验证通过」在退出码上不可区分;三种状态药方不同——解冲突重排 vs 重排危险步 vs 重试测量失败——故各自一码,同 `check-vote-count.py` 分 `BLOCKED`/`SHORT`。只想知道「有没有发现问题」的调用方可判 `rc in (0, 3)`。默认计划自 `cyc20260913-144807` 起是**累积式**的:按升序逐个试合到**前面各步建出的树**上(不是各自对 base——一个「两两都能干净合」的集合不是一条序列),合不动的**点名排除** ⇒ 默认调用能把整条队列测完(同一队列实测:11 张里只测到 3 步、退出 3 → 8 步全测、退出 0),而 3 现在只在 `--all` 或显式给号时出现)。base 写成**任一 remote-tracking 写法**(`origin/` 或 `refs/remotes/origin/`——后者正是兄弟 `check-merge-pairs.py` 的拒绝文案明确让调用者用的写法,它把 `refs/...` 原样透传)都会**先 fetch 刷新**再测量;`origin/HEAD` / `refs/remotes/origin/HEAD` 则先经 symref 解析到它指向的那份 remote-tracking 分支再刷新(**直接 fetch 进 symref 无法加锁,git 拒绝**,而 `refs/heads/HEAD` 上游不存在;解析不出来即 exit 2,绝不照抄旧值)。cyc20260913-223417 实测:同一个 clone 里 `--base origin/master` 刷新到真 base(`a0889b36`),而 `--base refs/remotes/origin/master` 读的仍是那个陈旧提交(`aa5e70f8`)——**同一个可变 ref 的另一种写法不能是另一个答案**。 +Merge sequence: `uv run --no-sync python3 scripts/check-merge-sequence.py [--base ] ...` — 合并前问「按**这个顺序**合,每一步落地的树还过守卫吗」。健康是**步骤**属性:这一步的输入是上一步产出的树,所以任何「单 PR」事实都推不出它。存在理由(cyc20260912-174026 实测):#1167 与 #1166 都带 master、都写 `(1541)` ⇒ 各自合入都 CLEAN 且自洽,**依次**合入却仍是 CLEAN 而守卫在合并树上红(1541 文档 vs 1560 实收)——两个相等值写同一行,git 保留一份、冲突为零、陈旧数字进 master。危险与信号**反向**:计数**值不同**必冲突(安全,逼人看一眼),**值相同**才静默通过,而 `check-merge-order.py` 恰好按「弄脏别人最少」排序 ⇒ 「挑最省事顺序」读起来正是那句不安全的建议。逐 PR 健康检查在此队列会**退化**:所有 head 都含 master ⇒ `merge-tree master head` 的结果等于分支自己的树 ⇒ 守卫只在回答「这条分支自洽吗」,而待审分支永远自洽。退出码:0 = **每一步都测过**且落地健康,1 = 有「干净合并却落地不健康树」的步骤(本工具存在的那个发现),2 = 无法测量(响亮失败,绝不把「没测成」报成健康;**空计划也算无法测量**——`args.prs or _open_pr_numbers(...)` 决定「零步」只可能是**计划根本没取到**,而旧行为会印 `all 0 step(s) ... pass the guards` 并退出 0,即对一个树都没量过的运行**断言通过**,故改为 2;实测同一行为在父修订 `6456a98` 与本修订上逐字相同 ⇒ 既有缺口而非本次回归),3 = **计划在冲突步中断**(只测了前缀,其后各步**根本没测**——`cyc20260913-084752` 实测:默认调用(全部在飞 PR 升序)在第 1 步 `#1136` 冲突即停,**一个树都没判**却退出 0,与「全部验证通过」在退出码上不可区分;三种状态药方不同——解冲突重排 vs 重排危险步 vs 重试测量失败——故各自一码,同 `check-vote-count.py` 分 `BLOCKED`/`SHORT`。只想知道「有没有发现问题」的调用方可判 `rc in (0, 3)`。默认计划自 `cyc20260913-144807` 起是**累积式**的:按升序逐个试合到**前面各步建出的树**上(不是各自对 base——一个「两两都能干净合」的集合不是一条序列),合不动的**点名排除** ⇒ 默认调用能把整条队列测完(同一队列实测:11 张里只测到 3 步、退出 3 → 8 步全测、退出 0),而 3 现在只在 `--all` 或显式给号时出现)。base 写成**任一 remote-tracking 写法**(`origin/` 或 `refs/remotes/origin/`——后者正是兄弟 `check-merge-pairs.py` 的拒绝文案明确让调用者用的写法,它把 `refs/...` 原样透传)都会**先 fetch 刷新**再测量;`origin/HEAD` / `refs/remotes/origin/HEAD` 则先经 symref 解析到它指向的那份 remote-tracking 分支再刷新(**直接 fetch 进 symref 无法加锁,git 拒绝**,而 `refs/heads/HEAD` 上游不存在;**是不是 symref 由 git 判、不由名字后缀判**——`feature/HEAD` 是合法分支名,`refs/remotes/origin/feature/HEAD` 只是恰好以 `/HEAD` 结尾的普通 remote-tracking 分支,按后缀一律拒绝会把一个只需 fetch 的 base 报成测不出来(ref 留在陈旧 commit),而 `/HEAD` 处理之前这个写法本来就 fetch 得动 ⇒ 回归(cyc20260913-225642 实测:同 clone 里该写法两条 spelling 均被拒、ref 未刷新,修后跟 `master` 一样跟上远端);现在非 symref 即照常 fetch,只有 symref 指向 `origin` 之外才 exit 2,fetch 失败照旧 exit 2,绝不照抄旧值)。cyc20260913-223417 实测:同一个 clone 里 `--base origin/master` 刷新到真 base(`a0889b36`),而 `--base refs/remotes/origin/master` 读的仍是那个陈旧提交(`aa5e70f8`)——**同一个可变 ref 的另一种写法不能是另一个答案**。 Merge tree health: `uv run --no-sync python3 scripts/check-merge-tree-health.py [PR...] [--base ]` — 合并前问**别人都不问的那一句**:把这张 PR 合下去,**产出的那棵树**还能不能通过本仓库自己的守卫?现有闸门问的都是「关于这张 PR」的问题(票还算不算数、base 还能不能到 master、CI 绿灯是不是关于这棵树、合下去会弄脏谁),没有一个问合并**之后** master 是否健康。存在理由(cyc20260912-040220 实测,清空 11 张绿灯 PR 队列时):#1133 与 #1140 各自加测试、各自把 Agent.md 的 Python 计数行改写成**对自己成立**的值(两张自己的树都是 1500 收 1500,自洽),而**合起来**:两边都写 1500 ⇒ git **无冲突**保留一份 ⇒ 合并树收 **1506** 而文档仍写 1500 ⇒ 守卫 FAIL。**无冲突的那种最危险**:有冲突会逼人看一眼,同一行干净合并看起来什么都没发生;而 `check-merge-order.py` 恰恰把这对报成「弄脏别人最少」,即「最便宜先合」会主动推荐那个会落地不一致树的合并(#1137 也是同一形态,只是走的是「CI 绿灯过期」那条路)。为什么不建模:计数**无法**由两侧推出来,不是 max 也不是相加——它是合并树实际收集到的数(取决于 import、conftest、参数化),所以**把合并树建出来**(scratch 目录,绝不动工作区)并在里面跑**该树自己的** `scripts/check-doc-count.py`(与 CI 同一个守卫、同一个路径、读它自己的树,后者自 #1140 起会打印 tree:),建模只会把猜的位置往上挪一层。**同一个周期实测的第二个坑(本工具存在的另一半理由)**:守卫会打印 `Fix with: ... --write`,而 `--write` 改的是**该守卫解析到的树**里的文件;跑完只提交**合并**而不提交**那次修改**,推上去的 head 依旧写着旧计数,CI 自己的守卫随即在推上去的 commit 上红(本地跑一次全绿也没用)——本工具判定的是**你问的那个已提交 head**,因为推前要问的是「我要合的这个 commit 过不过」,不是「我的工作区过不过」。exit 0 全部干净且健康 / 1 有「干净但合并树自身守卫不过」的(真正的发现)/ 2 问不出来(gh/git/守卫失败,绝不报健康);**冲突单列为 CONFLICT 不算失败**——合不成就没有合并树可判,那是 `check-merge-order.py` 的问题。`check-vote-count.py` → `check-pr-base.py` → `check-merge-freshness.py` → `check-merge-order.py` → 本工具:最后这一环才是「合完 master 还健康吗」 Merge-order forecast: `uv run --no-sync python3 scripts/check-merge-order.py [PR...] [--json]` — 合并前预报**每张 PR 合下去会弄脏哪些别的 PR**,把「合并的代价」提前变成可见数字。存在理由(cyc20260911-225712 实测):11 张 PR 各自 `MERGEABLE/CLEAN` 且各自 CI 绿、又都领先 master,于是**任何一张都能合**;而合掉一张会让其余十张在**同一行**(Agent.md 的 Python 计数行)转 `CONFLICTING/DIRTY` — 脏 PR 拿不到 `pull_request` run(无 CI)、不能合,而解冲突必须推 head ⇒ **那些 PR 的票全部作废**(三张差一票的 PR 被打回 0/3)。代价是真的,且此前只在事后才发现。为什么不靠推理:直觉说法「它们都动计数行」**近乎**正确却**不能当规则**——同一天 55 对里有 4 对虽然共享 Agent.md 却**不冲突**(是否冲突取决于改动落点多近,是树的性质不是文件列表的性质),故问 `git merge-tree --write-tree`:rc 0 = 干净、1 = 冲突(并从输出首块读出**冲突的是哪个文件**,只报「有冲突」不足以决策)、其它 rc 一律算**没问出来**(**绝不当作冲突**——把测量失败报成冲突会凭空造出一场级联,调用方会为不存在的冲突白花一次解冲突)。推荐顺序看「合掉它弄脏几张」:弄脏最少的先合最便宜,但**最便宜 ≠ 最有价值**(当天正确的第一步恰恰是冲突**最多**的 #1149,因为它解开了后面每一张堆叠 PR 的 CI)。**自曝的坑(写这个工具的当天就被自己的实测抓住)**:先把 master fetch 进 `FETCH_HEAD`、再把**名字** `FETCH_HEAD` 当 base 传下去,而 `git fetch refs/pull//head` **也会重写 `FETCH_HEAD`** ⇒ 每对测量时的 base 已经变成最后 fetch 的那张 PR head;输出**完全像模像样**(「只有 #1152 对 master 可合、其余全冲突」),实际 base 就是 #1152 自己的 head(所以那两侧才「相同」);破绽在计数行——那次运行关于 master 一个数字都没说,而真 master 是 1490、运行却表现得像 base 写着 1503。规则因此是:**可变 ref 名永不进 `merge-tree`**,base 先用 `rev-parse` 解析成 SHA,并由 `test_no_mutable_ref_name_reaches_merge_tree` 钉住该不变量(正是本可最早抓住它的测试) Conflict triage: `python3 scripts/classify-conflict.py ... [--all]` — 合并冲突的**分类器(只报告,绝不改文件,exit 1 = 有需要人判的块)**,把「两边都动了同一个文件」拆成五种可判定的形状并给出证据支持的解法:`count-line`(两侧只差一个**带括号的计数**数字 ⇒ 先分清它是「存储的计数」还是「旧分支带进来的写死值」:GUI/Renderer 的分解是文档里的事实,必须在合并后的树上**测量**;Python 测试数自 2026-09-13 起不写进任何文档,故那一行一律**删掉数字**,永不选边,交给 `check-doc-count.py --resolve-conflict`)、`duplicate`(两侧**声明符号**互为真超集**且共有符号的正文全同** ⇒ 取超集那侧)、`disjoint`(声明符号不相交 ⇒ **两边都保留**)、`overlapping`(同名符号,或超集但共有符号正文不同,或单行无判据 ⇒ 必须人读)、`identical`。存在理由(cyc20260911-112155 实测):同一轮里同一形状需要**相反**的解法——#1136 的分支带着 #1134 的**未合并副本**(分支基于 #1134 而后者已被 squash,祖先关系不可见),master 是严格超集故「取 theirs」正确;#1140 两侧是**不相交新增**,取任何一侧都会静默丢掉另一侧 4 个探针,唯一正解「两边都留」。一条 `--theirs` 对前者对、对后者错。两条判别轴都是被实测打出来的:① 必须是**声明符号**而非文本行——初版按内容行比较,因两侧都含 ` """` 与 ` )` 这种样板行,把两个真实冲突全判成 `overlapping`;② 名字子集**不等于**内容包含——只比名字时「master 声明了分支所有名字」被读成「master 含分支全部内容」,而两侧同名 `test_alpha` 正文不同时取超集会**静默丢掉分支对它的修改**(正是本工具要防的数据丢失);③ `count-line` 必须限定为**带括号的计数**,否则 `x = compute(1)` vs `compute(2)` 这种纯代码差异也会被答「测量、永不选边」并 exit 0,把唯一该人看的情况关掉。三条均已钉为变异验证的回归测试(cyc20260911-120717)。**第四类:读不了的布局即拒绝**(2026-09-11 实测)——`merge.conflictStyle = diff3` 会在 ours 与分隔符之间插入 `||||||| ` 段,而 `CONFLICT_BLOCK` **照样匹配**(把 base 段吞进 ours),于是判出来的两侧是(ours+base)vs theirs:一段 `disjoint` 被判成 `KEEP BOTH` 并 exit 0,而「都保留」恰好会把**谁都不想要的 base 副本**拼回去;本仓库的真实形态(Agent.md 计数行)更隐蔽——判成 `duplicate - ours is a strict superset - take OURS`,因为 base 段(带着**陈旧**计数的第三份拷贝)被算成 ours 多出来的行。故先于解析检查该标记并报 `unparsed-layout` + exit 1,与 `check-doc-count.py` 对同一布局的具名拒绝一致(那条路是 read-only 无损失,只会不必要地拦住一个本可解的 PR;这条路的错误建议会让解冲突的人**删掉自己的改动**,代价不对称)。**第五类:跨行聚合与「同一段落的两个修订」**(cyc20260911-190629 实测,两个缺陷都在**本工具自己**里,且都靠「拿全部真实冲突块做差分」而非手写 fixture 才暴露出来——手写 fixture 只会覆盖我本来就相信的形状):① 一条 `count-line` 原先是「两侧各一行」,但 51 次改 Agent.md 的提交里有 **3 次一次动掉 2 个以上计数**,git 会把它们并成**一个**块,该块因此落到内容行兜底、被判「两侧无共同行 ⇒ KEEP BOTH(拼接)」并 exit 0——拼接恰好把每个计数行**各留两份**,正是本仓库 `_duplicated_count_line_kinds` 守卫要拒的状态;现改为**对齐的、逐对只差数字的**块一律 `count-line`(任意长度,且每对都要满足带括号计数)。② 「两侧无共同行」**不等于**「各自新增」——同一段落的新旧两个修订永远不相等。当前 #1140 的 Agent.md 真实块里,ours 的两行段落正是 master 两行的**严格前缀**(890 vs 539、601 vs 471 字符),兜底判 KEEP BOTH 会同时留下陈旧与当前两份段落;现改为见到严格前缀关系即报 `overlapping` 交人读(前缀证据弱于符号子集,误差不对称:升级只是多读一次,「取 theirs」会静默丢一行)。两个修复各配变异验证(还原旧行为即测试变红)。**第六类:计数行与正文修订混在一个块里**(cyc20260911-194733 实测,发生在刚合入的第五类修复**自身**上):第五类要求「两侧等长且**每一对**都只差数字」,而真实块常把**计数行**和**正文行**配在一起——cb651a4 的真实块正是 ours「Python 计数(1393) + Doc count sync 正文」对 theirs「同一计数行的另一个值 + 同一正文的另一次改写」,两侧等长却**有一对不是计数对**,于是仍落到内容行兜底、判 KEEP BOTH 并 exit 0,而拼接结果里有两个 ``Python: `uv run pytest` `` 行(即文档声称两个 pytest 数),正是 `_duplicated_count_line_kinds` 要拒的状态;且计数数字**在行内**,前面的严格前缀判据(`b.startswith(a)`)对它**结构性不可见**——它只认「短的是长的前缀」,而这里数字之后的内容被改写,两侧互不为前缀。改判据为**按下标配对**:只看对应位置上的行对,任一对「两边都带括号计数且遮罩数字后相等」即报 `overlapping` 交人读(不再要求等长);证据强度就是遮罩本身——遮罩后相等意味着**同一个事实被重新测量过**,而同一事实出现两个值正是仓库守卫要拒的,故 KEEP BOTH 对它必错。实测收益(最近 400 次改 Agent.md 的提交,逐 hunk 差分):7 个 hunk 在兜底里带上计数行且**全部**给出**重复内容**的建议——e46c160/0c8a212(对齐,已由第五类改判 count-line)、cb651a4(2v2)/5c039b4(3v3)(等长但混了正文修订,第五类看不见)、3335877(1v2)/444e1d5(1v2)/18fd0af(1v13)(不等长);本条修复把余下 5 个全部转为 `overlapping`,全仓 150 次提交 1039 个 hunk 差分仅 6 处类别变化(5 个 Agent.md + 1 个 `preload-api.test.js`,后者也是真阳性:两侧写着 54/53 与 55/54 两个不同成员数)。变异验证:退回「只查前缀」即新增测试变红;对面(真实互不相干的两个新增)仍判 disjoint。**第七类:同一计数行的「重新拆分」**(cyc20260912-002444 实测,仍是拿真实冲突块差分出来的):第六类要在「遮罩数字后**完全相等**」,而真实块里同一个计数**种类**还会被**重新拆分**——47af6bc2 的真实合并块里 ours 是 `GUI: … (92: 45 daemon_client + … + 3 preload-api + 3 boot-contract)`,master 是**同一行**的 `(89: … + 3 preload-api)`(少一个组件、总数也从 92 变 89),两侧**行数 1 vs 2**且遮罩后不等,于是两条规则都看不见,仍判 `disjoint - KEEP BOTH` 并 exit 0,而拼接结果里有两个 `GUI: ` 行——正是 `_duplicated_count_line_kinds` 要拒的状态(本测试直接把拼接结果喂给该守卫,不是靠眼看)。判据改为**按「计数种类」而非「整行相等」**:两侧「首个计数之前的文本完全相同」即同一行被重新测量过(该文本正是命令与种类名),报 `overlapping` 交人读。实测范围:用**真实合并提交**(对每个 merge 的三个真实 blob 跑 legacy `git merge-tree`)重建出 **185** 个冲突块,该规则**只改 1 个类**(就是这一块,`disjoint`→`overlapping`),其余不动;反向对照(`Python:` 对 `GUI:` 这种不同种类)不升级。变异验证:删掉该子句,新增测试即变红(`disjoint`)。**第八类:同一行的「行内」修订**(cyc20260913-182526 实测):第五类的判据是**严格前缀**,而那只描述「改在行尾」——真实块里同一行还会被**行中插入**(复现 #1182 时 Agent.md 的真实块:ours 1 行 = theirs 3 行中那行的偏移 1091 处插入 187 字符,即 `ours = theirs[:1091] + 187 字符 + theirs[1091:]`),两侧互不为前缀,第五、六、七类都看不见,仍判 `disjoint - KEEP BOTH` 并 exit 0,而拼接把该文档行**留了两份**(1280 与 1093 字符、同 head),正是 `_duplicated_count_line_kinds` 要拒的状态。判据改为**恰好一处连续插入或删除**(取最长公共前缀与后缀后,两段中间至少有一段为空),位置不限。实测(把真实 PR 头逐个合入当前 master 的 scratch worktree:16 个会冲突的 PR 得 **67** 个真实冲突块):该判据只改 4 个类,全部 `disjoint` → `overlapping` 且全部为真阳性(两个是上述文档句形态、一个是代码注释、一个是折行散文),反向一个没有;误升级代价在 20 万对**无关**真实内容行上量得 10 对(0.005%,旧前缀规则 1 对),多出的都是 `def stop_all` 与 `def _stop_all` 这类改名/缩进对——本就该人看一眼。反向对照:**两处**独立改动(改名外加插入短语)中间两段都非空 ⇒ 仍判 `disjoint`,防止退化成「两行长得像」。变异验证:退回前缀判据、以及把判据放宽成「公共前后缀够长」,各自的新增测试分别变红。 diff --git a/scripts/check-merge-sequence.py b/scripts/check-merge-sequence.py index a7cc4f99..b8016f37 100755 --- a/scripts/check-merge-sequence.py +++ b/scripts/check-merge-sequence.py @@ -488,10 +488,10 @@ def _refresh_base(base: str) -> None: Anything else is taken literally: a SHA is immutable by construction, a local branch is not the remote ref whatever it is called, and a refspec the caller - already wrote (`origin/x:dest`) is passed to git as given. A fetch failure - or - a `/HEAD` spelling that is not a symref to a remote-tracking branch of - `origin` - is a measurement error: the caller must not silently continue - against a base it could not verify. + already wrote (`origin/x:dest`) is passed to git as given. A fetch failure, or a + `/HEAD` spelling whose symref leads outside `origin`'s tracking refs, is + a measurement error: the caller must not silently continue against a base it + could not verify. The destination is written **fully qualified**. A bare `origin/master` as a fetch destination is ambiguous, and git resolves it by creating a *local @@ -509,6 +509,22 @@ def _refresh_base(base: str) -> None: `refs/heads/HEAD`. Under `refs/remotes/` the only symbolic ref git creates is `/HEAD`, so the probe is confined to that name and an ordinary branch spelling still costs exactly one call. + + Which ref it *is* is decided by git, not by that name: `/HEAD` is a legal + branch name (`git check-ref-format --branch feature/HEAD` accepts it), so + `refs/remotes/origin/feature/HEAD` is an ordinary remote-tracking branch that + merely ends in `/HEAD`. Keying the *refusal* on the suffix refused a base that + needed no resolving at all - and it was a regression, since the spelling was + fetched correctly before the `/HEAD` handling existed. Measured in a hermetic + clone holding a `feature/HEAD` branch one commit ahead of its tracking ref + (`cyc20260913-225642`): + + --base origin/feature/HEAD -> MeasurementError, ref left stale + --base refs/remotes/origin/feature/HEAD -> MeasurementError, ref left stale + + A name git does not report as symbolic is therefore fetched like any other + branch; a name that resolves to something outside `origin`'s tracking refs is + still a measurement error. """ if ":" in base: return @@ -520,14 +536,14 @@ def _refresh_base(base: str) -> None: return if dest.endswith("/HEAD"): link = _run(["git", "symbolic-ref", "--quiet", dest]) - target = link.stdout.strip() - if link.returncode != 0 or not target.startswith("refs/remotes/origin/"): - detail = link.stderr.strip() or "no such ref" - raise MeasurementError( - f"could not refresh {base}: {dest} is not a symbolic ref to a " - f"remote-tracking branch of origin ({detail})" - ) - dest = target + if link.returncode == 0: + target = link.stdout.strip() + if not target.startswith("refs/remotes/origin/"): + raise MeasurementError( + f"could not refresh {base}: {dest} is a symbolic ref to " + f"{target}, which is not a remote-tracking branch of origin" + ) + dest = target branch = dest[len("refs/remotes/origin/"):] proc = _run(["git", "fetch", "--quiet", "origin", f"+refs/heads/{branch}:{dest}"]) if proc.returncode != 0: diff --git a/tests/test_check_merge_sequence.py b/tests/test_check_merge_sequence.py index 2b90b89e..736f5baf 100644 --- a/tests/test_check_merge_sequence.py +++ b/tests/test_check_merge_sequence.py @@ -535,35 +535,100 @@ def fake_run(argv, cwd=None): assert "refs/heads/trunk:refs/remotes/origin/trunk" in " ".join(fetches[0]), (base, calls) -def test_a_head_spelling_that_names_no_remote_branch_is_a_measurement_error(mod, monkeypatch): - """A `HEAD` spelling that is not a symref to `origin`'s tracking refs is refused. - - Two shapes, both refusals rather than answers: the name is not a symbolic ref at - all (a hand-made plain ref, or a clone without the symref), and it points somewhere - outside `refs/remotes/origin/` - in neither case does the tool know which remote - branch to refresh, and answering without one is the stale-base reading again. +def test_a_branch_whose_name_ends_in_head_is_refreshed_not_refused(mod, monkeypatch): + """`/HEAD` is a legal branch name, so that spelling is an ordinary ref. + + The discriminator is `git symbolic-ref`'s exit code, never the `/HEAD` suffix: + `git check-ref-format --branch feature/HEAD` accepts the name, so + `refs/remotes/origin/feature/HEAD` is a remote-tracking branch for a branch called + `feature/HEAD`, and a predicate keyed on the name refused a base that only needed + filling in - which is also why the fix is a regression fix: the spelling was + fetched correctly before the `/HEAD` handling existed. """ + calls: list[list[str]] = [] class _Plain: - returncode = 1 - stdout = "" - stderr = "" + """`symbolic-ref` fails - the ref is not a symref; `fetch` succeeds.""" + + def fake_run(argv, cwd=None): + calls.append(argv) + done = _Plain() + done.returncode = 1 if "symbolic-ref" in argv else 0 + done.stdout = "" + done.stderr = "" + return done + + monkeypatch.setattr(mod, "_run", fake_run) + + for base in ("origin/feature/HEAD", "refs/remotes/origin/feature/HEAD"): + calls.clear() + mod._refresh_base(base) + probes = [c for c in calls if "symbolic-ref" in c] + fetches = [c for c in calls if "fetch" in c] + assert len(probes) == 1 and len(fetches) == 1, (base, calls) + assert probes[0][-1] == "refs/remotes/origin/feature/HEAD", (base, calls) + # The branch is refreshed at its own name - not at `HEAD`, and not at the + # symref's target (there is none). + joined = " ".join(fetches[0]) + assert "+refs/heads/feature/HEAD:refs/remotes/origin/feature/HEAD" in joined, ( + base, + calls, + ) + + +def test_a_head_spelling_that_resolves_outside_origin_is_a_measurement_error(mod, monkeypatch): + """A symref that does not lead to `origin`'s tracking refs is refused. + + The tool only knows how to refresh a remote-tracking branch of `origin`; a symref + pointing at a local branch (or another remote) leaves it unable to name the branch + to fill in, and answering anyway is the stale-base reading this helper exists to + prevent. + """ class _Elsewhere: returncode = 0 stdout = "refs/heads/master\n" stderr = "" - for outcome, base in ( - (_Plain(), "refs/remotes/origin/HEAD"), - (_Elsewhere(), "origin/HEAD"), - ): - monkeypatch.setattr(mod, "_run", lambda argv, cwd=None: outcome) + monkeypatch.setattr(mod, "_run", lambda argv, cwd=None: _Elsewhere()) + + for base in ("origin/HEAD", "refs/remotes/origin/HEAD"): with pytest.raises(mod.MeasurementError) as excinfo: mod._refresh_base(base) assert "symbolic ref" in str(excinfo.value), (base, excinfo.value) +def test_a_head_spelling_that_git_cannot_resolve_is_still_never_answered(mod, monkeypatch): + """Not symbolic is not the same as fine: the fetch still has to succeed. + + Dropping the name-keyed refusal must not open a quiet path: when the ref is not a + symref and the fetch of that name fails (no such branch upstream), the caller gets + a measurement error rather than the stale commit in a header that names it. + """ + calls: list[list[str]] = [] + + class _Fail: + returncode = 128 + stdout = "" + stderr = "fatal: couldn't find remote ref refs/heads/HEAD" + + def fake_run(argv, cwd=None): + calls.append(argv) + done = _Fail() + if "symbolic-ref" in argv: + done.returncode = 1 + done.stderr = "" + return done + + monkeypatch.setattr(mod, "_run", fake_run) + + with pytest.raises(mod.MeasurementError) as excinfo: + mod._refresh_base("origin/HEAD") + + assert "could not refresh" in str(excinfo.value) + assert any("fetch" in c for c in calls), ("the fetch must be attempted", calls) + + def test_a_stale_qualified_base_is_refreshed_with_real_git(mod, tmp_path, monkeypatch): """The defect's effect, with real git: the qualified spelling follows the remote. @@ -621,6 +686,74 @@ def git(cwd: Path, *args: str) -> str: ) +def test_a_remote_branch_named_head_is_refreshed_with_real_git(mod, tmp_path, monkeypatch): + """The regression, with real git: `feature/HEAD` is a branch, not the symref. + + `git check-ref-format --branch feature/HEAD` accepts the name, so a remote may + legitimately have one and its tracking ref is an ordinary ref whose last path + component happens to be `HEAD`. This is the arm that fails before the fix: keyed on + the name, `_refresh_base` raised a measurement error and left the tracking ref at + its stale commit, so every reading below it named a tree the caller did not ask + about. Re-armed between spellings so one arm cannot hand the next a current ref. + """ + + def git(cwd: Path, *args: str) -> str: + out = subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ) + return out.stdout.strip() + + bare = tmp_path / "remote.git" + bare.mkdir() + git(bare, "init", "-q", "--bare", "-b", "master") + + seed = tmp_path / "seed" + seed.mkdir() + git(seed, "init", "-q", "-b", "master") + git(seed, "config", "user.email", "t@example.com") + git(seed, "config", "user.name", "t") + (seed / "a.txt").write_text("one\n", encoding="utf-8") + git(seed, "add", "-A") + git(seed, "commit", "-qm", "first") + git(seed, "remote", "add", "origin", str(bare)) + git(seed, "push", "-q", "origin", "master") + + git(seed, "checkout", "-qb", "feature/HEAD") + (seed / "a.txt").write_text("one\nfeature\n", encoding="utf-8") + git(seed, "commit", "-qam", "a branch whose last name is HEAD") + git(seed, "push", "-q", "origin", "feature/HEAD:refs/heads/feature/HEAD") + git(seed, "checkout", "-q", "master") + + repo = tmp_path / "clone" + subprocess.run( + ["git", "clone", "-q", str(bare), str(repo)], + capture_output=True, text=True, encoding="utf-8", check=True, + ) + stale = git(repo, "rev-parse", "refs/remotes/origin/feature/HEAD") + # The name is not what makes a ref a symref - git is asked, and says no. + assert subprocess.run( + ["git", "symbolic-ref", "refs/remotes/origin/feature/HEAD"], + cwd=repo, capture_output=True, text=True, encoding="utf-8", + ).returncode != 0, "precondition: the tracking ref is not a symbolic ref" + + git(seed, "checkout", "-q", "feature/HEAD") + (seed / "a.txt").write_text("one\nfeature\ntwo\n", encoding="utf-8") + git(seed, "commit", "-qam", "the remote moves on") + git(seed, "push", "-q", "origin", "feature/HEAD") + advanced = git(seed, "rev-parse", "HEAD") + git(seed, "checkout", "-q", "master") + + assert stale != advanced, "precondition: the clone's ref is behind the remote" + + monkeypatch.chdir(repo) + for base in ("origin/feature/HEAD", "refs/remotes/origin/feature/HEAD"): + git(repo, "update-ref", "refs/remotes/origin/feature/HEAD", stale) + assert git(repo, "rev-parse", "refs/remotes/origin/feature/HEAD") == stale, base + mod._refresh_base(base) + assert git(repo, "rev-parse", "refs/remotes/origin/feature/HEAD") == advanced, base + + def test_main_refreshes_the_base_before_measuring(mod, monkeypatch, capsys): """`main` must actually call `_refresh_base` - a correct helper nobody calls is dead.