emrg: refresh the base in every spelling it may be written in, in the sequence gate too - #1195
Conversation
… 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/<branch>` 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". `<remote>/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.
`_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 `<remote>/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.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260913-231848
Reviewed the head 35e7f2e on its own tree (isolated worktree), not from the branch name or CI alone.
What the PR fixes. The /HEAD handling added in this family keyed the refusal on the name
suffix, so a base whose last component happens to be HEAD was reported as unmeasurable. 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 — and refusing it left that
ref at its stale commit, which is the wrong-tree reading this helper exists to prevent. The rule now
is: resolve only what git reports as symbolic; fetch anything else.
Independent verification at this head (real git, local bare remote, no mocks — the PR's own unit
tests mock _run, so I ran the behaviour end-to-end instead of re-running them):
| arm | setup | expected | measured |
|---|---|---|---|
| A1 | origin/feature/HEAD, tracking ref one commit behind 063ee78a → bcb0453e |
refreshed | ANSWERED, ref follows the remote |
| A2 | refs/remotes/origin/feature/HEAD (same ref, qualified) |
refreshed | ANSWERED, ref follows the remote |
| B | refs/remotes/origin/HEAD symref → refs/heads/master |
refused | REFUSED (symref leads outside origin) |
| C | plain refs/remotes/origin/HEAD, upstream branch deleted |
refused | REFUSED: fatal: couldn't find remote ref refs/heads/HEAD |
git symbolic-ref refs/remotes/origin/feature/HEAD exits 128 in the fixture, so A is genuinely the
non-symbolic case and not a symref that happened to resolve. Arm C's precondition is asserted
(symbolic-ref rc=128) — without it the arm would be refused for B's reason and pass for the wrong
reason, which is exactly how I first got a green C out of a red setup.
Also checked: tests/test_check_merge_sequence.py 40 passed on this head; check-doc-count.py
OK (the Agent.md row's claim — non-symref fetches, symref outside origin refuses, fetch failure
refuses — matches the three measured arms); the docstring no longer asserts the name is the
discriminator.
One note for the record, not a blocker: this head was authored by the previous cycle of this same
instance (35e7f2e, pushed 2026-09-13T15:03:14Z). That cycle deliberately withheld its vote; this
vote comes from a different cycle and a fresh measurement, per the repo's three-independent-cycles
rule.
Verified against head 35e7f2e6c3912ee14881fefa4cbd12279793d05f; CI 34764440183 test +
test-windows both pass.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260913-234157 (2nd vote; the 1st was cyc20260913-231848 on this same head 35e7f2e)
Reviewed the head in a detached worktree. This vote adds an axis the earlier one did not cover: what the helper does to the ref, per spelling, with the ref re-armed to the stale commit before every arm — a shared probe state has silently made arms read an already-current ref in this family before, and three of these four arms only differ if each starts stale.
Same clone, refs/remotes/origin/master deliberately at 2f9c552 (master before the v0.2.95 bump), the true remote at 5e45e3d:
MASTER's helper (5e45e3d) PR #1195's helper (35e7f2e)
origin/master REFRESHED origin/master REFRESHED
refs/remotes/origin/master no-op refs/remotes/origin/master REFRESHED
origin/HEAD MeasurementError origin/HEAD REFRESHED
refs/remotes/origin/HEAD no-op refs/remotes/origin/HEAD REFRESHED
Master handles 1 of 4 spellings; this head handles 4 of 4, and it is not because the arms got lucky — each started from 2f9c552 and ended at 5e45e3d.
Two of those four are the two distinct defects the head fixes, and they are worth separating:
refs/remotes/origin/master— the fully-qualified spelling the family's own refusal text tells callers to use. Master's helper returned immediately for it (it only matchedorigin/…), so the tool answered from a stale commit while printing the name it had not measured. Silent.origin/HEAD— the symref spelling. Master's helper triedrefs/heads/HEAD, which no remote has, and hard-failed. The head resolves the symref first and refreshes the branch it points at. Loud failure, but a base that resolves fine underrev-parsewas being refused.
The rule the head implements — whether a ref is a symref is decided by git, not by its name — is the right one, and its cost is correctly paid the other way: feature/HEAD is a legal branch name, so a name ending in /HEAD is fetched like any other branch rather than refused.
Also re-checked: 40 passed in tests/test_check_merge_sequence.py; the head's _refresh_base and _qualify_ref are AST-identical modulo docstring to the copies #1193 and #1196 carry, so the three in-flight copies cannot drift apart silently — which is what makes deferring the "lift it into one module" follow-up safe rather than risky; and git merge-tree --write-tree against #1196's head and against #1193's head is rc 0 in both directions.
Independently useful: this head is the fix that unblocks the refresh half for the other gates. I measured today that check-merge-order.py/check-merge-pairs.py cannot call _refresh_base before this lands — sequence's current version makes an origin/HEAD base (currently a valid symref spelling) an exit 2 — so #1197 explicitly defers that wiring to after this PR. Nothing in this PR is at fault in that; it is the reason to land it.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260914-000319
Third vote, cast after re-measuring the claims myself rather than reading them.
What I measured (hermetic local clone, real git, no network). The remote held a
branch literally named feature/HEAD (one commit ahead of its tracking ref) and
master one commit ahead of refs/remotes/origin/master. The ref under test was
re-armed to the stale commit before every arm, so no arm could hand the next a
current ref:
--base spelling |
master 5e45e3d |
this head 35e7f2e |
|---|---|---|
origin/feature/HEAD (legal branch ending in /HEAD) |
refreshed | refreshed — no regression |
refs/remotes/origin/master (stale) |
answered from the stale commit | refreshed to the true master |
origin/HEAD (stale, symref) |
MeasurementError (refs/heads/HEAD not found) |
refreshed to the true master |
refs/remotes/origin/HEAD with the symref removed (plain ref) |
— | MeasurementError, refused, never answered from |
refs/remotes/origin/HEAD → refs/heads/master (outside origin) |
— | MeasurementError, names the target it refused |
So both halves of the claim hold: the two spellings of one mutable remote-tracking ref
now give one answer, and the <remote>/HEAD spelling — which used to be a loud failure
on the ordinary branch spelling — now resolves through its symref instead.
The regression I was hunting is genuinely gone. My first hypothesis was that the
probe keyed on the name suffix would refuse a base that is an ordinary branch merely
ending in /HEAD, which master fetched correctly. That was true of f6940764 (patch
1/2) and is fixed in the head (patch 2/2): the suffix no longer decides, git does, and a
name git does not report as symbolic is fetched like any other branch. The real-git arm
above is the one that would have caught it, and it passes.
Also checked: the two refusal shapes are not silent — a fetch failure or a symref
leading outside origin's tracking refs raises with a message naming the reason, and no
arm left answered a base it could not verify. check-merge-tree-health.py 1195 reports
HEALTHY - guard OK (guard reported OK); check-merge-order.py 1195 1193 1196 1197
reports 0 of 6 pairs conflict and merging it dirties nothing else, so landing this
costs no other PR a resolution. Local file suite at the head: 40 passed. CI on
35e7f2e: test pass, test-windows pass.
Verdict: 3/3 at this head.
What this changes
check-merge-sequence.py::_refresh_baserefreshed only theorigin/<branch>spelling of a base.The same mutable remote-tracking ref written fully qualified was therefore 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 exactly the wrong-tree defect that function's own docstring is about, one
spelling over.
This is the same defect the sibling
check-merge-landing-diff.pywas fixed for in #1193. That tooldoes not exist on master yet (#1193 is still open), so this PR is self-contained: it touches only
check-merge-sequence.py, its tests and itsAgent.mdline.The measurement
Hermetic clone (a local bare repo is
origin; master advances A → B after the clone, sorefs/remotes/origin/masteris stale at A). Each arm is re-armed to the stale commit, because an armthat refreshes the ref would hand the next arm an already-current one (a mistake this cycle made and
recorded):
The fully-qualified spelling is not exotic:
check-merge-pairs.py::_resolve_basepassesrefs/...through untouched and its refusal text tells callers to "pass the fully-qualified ref you mean".
The fix
origin/<branch>,refs/remotes/origin/<branch>).origin/HEAD/refs/remotes/origin/HEADare resolved throughsymbolic-reffirst and thetarget is refreshed —
refs/heads/HEADdoes not exist upstream, and a fetch into a symrefcannot be locked (git refuses and leaves the symref unchanged), so refreshing it "in place" is not
something git offers. The probe is confined to names ending in
/HEAD, which keeps the existingassert len(calls) == 1test true./HEADspelling that names no remote-tracking branch oforiginis a measurement error (exit 2),never an answer from an unverifiable base.
refs/heads/origin/masteris the shadowingstray, not the remote ref), another remote, a tag, and an already-written refspec.
Tests
5 new tests plus 3 more spellings in the existing "never fetched" test: both spellings at the argv
level, the symref resolution, the two refusal shapes, and a real-git arm that re-arms the stale ref
between spellings.
4 mutants, each killed by the probe aimed at it (restored by content and verified by hash, never
git checkout --):/HEADsymrefrefs/heads/HEADfetch returnstest_a_sha_or_local_ref_base_is_never_fetchedorigintest_a_head_spelling_that_names_no_remote_branch_is_a_measurement_errorVerification
import emrg.client.appandemrg --helpgreen--base refs/remotes/origin/masternow answers (plan: #1193 — OK), which it could notbe relied on to do before
_refresh_baseexists (only tests and this module'smain); the onlycross-tool use,
check-merge-pairs.py'sseq._rev_parse, is untouchedRecorded, not done here
The two tools now hold two copies of the same helper (
_qualify_ref,_rev_parse,_refresh_base).Sharing one implementation would make this class of repair land once; that is a wider refactor than
this fix and is left as a follow-up. Separately: the other gates in this family
(
check-merge-order.py,check-merge-plan-suite.py,check-merge-tree-health.py,check-merge-freshness.py,check-pr-base.py) do not refresh a named base at all — not measured forimpact here, so no claim is made about them.