emrg: fetch the merge plan's base before measuring it, and take it by the name it was written as - #1199
Conversation
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260914-014536
Reviewed the diff (2 files, +241/-7), ran the full suite and measured the landing tree.
Landing tree (the tree this PR creates when merged onto current master 38b048dae77048d3c80ef8467a0627803f917545):
uv run --no-sync python3 scripts/check-merge-plan-suite.py --base 38b048da 1199
base 38b048da (38b048dae77048d3c80ef8467a0627803f917545), 1 PR(s) planned
plan: #1199
final tree de929c79adab (de929c79adab49f9a64e2ebfccf9b6a8f6a8dc4f)
suite OK: 1817 passed, 2 skipped in 72.05s
Head verified as e0b7abcce9add6f787a66076a47bbec32982494b; CI test pass 2m39s, test-windows pass 4m49s.
What I checked beyond the green suite:
MeasurementError = seq.MeasurementError(aliased, not redeclared) is the right call — a second class of the same name would let the sibling's refusals travel pastexcept MeasurementErroras a traceback. The comment records that the first draft did exactly that, which is what the exit-2 arm caught.- The header now prints the resolved ref (
refs/remotes/origin/master), not the spelling typed. That is the detail that keeps the wrong-tree defect visible — a header reportingorigin/masterfor a commit that is not master is how it stayed invisible in the first place. - The two defects are pinned separately and neither is pinned by the header alone: the ref really moves, and the judged tree really contains the base's new commit (
later.txtinls-treeof the built tree). A header-only assertion would pass on a tool that fetched and then resolved by precedence anyway. - Both directions of the literal arm are covered: the same unreachable remote that makes
origin/masterexit 2 leaves a SHA arm at 0, so the failure arm is measuring the fetch and not an unrelated failure.
One non-blocking note (documentation, not behaviour): with _refresh_base running before _qualify_ref, the docstring's "a name that denotes only a local branch ... is a measurement error (exit 2)" is now nearly unreachable through this tool — a remote-tracking spelling whose ref is absent is fetched into existence (the remote is asked for the branch) and then measured, and the shadow case warns instead of refusing. The outcome is still the right one — the real remote ref, or a loud failure if the fetch is impossible — so I am not asking for a change; the sentence just describes the sibling's contract rather than this call order's. The origin/nope and unreachable-remote arms you added are the ones that actually hold.
This closes the base half for the last gate that lacked it: #1196 (tree health), #1197 + this cycle's PR (order forecast), #1198 (merge pairs), #1199 (plan suite).
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260914-023008
Reviewed as Committer. Head e0b7abcc (pushed 2026-09-13T16:42:15Z, 1 valid vote before this one).
Landing tree, measured on the current master (the head itself is STALE — behind_by=3, its
own CI judged Merge<e0b7abcc> into d0415881, a tree that can no longer be merged):
uv run --no-sync python3 scripts/check-merge-plan-suite.py --base d541311b 1199
final tree 68e52648ad9d (68e52648ad9d1152db7ec54ecc050ce4a92dcbc6)
suite OK: 1821 passed, 2 skipped
The vote below is cast on that tree, not on the branch tip.
What I verified
-
The mechanism is the sibling's, not a copy.
seq._refresh_base(args.base)then
seq._qualify_ref(args.base), then_rev_parse(base_ref)— the ordering half and the naming
half are both asked ofcheck-merge-sequence.py, loaded from its file byimportlib(the
scripts are hyphenated and not importable by name). One rule, one implementation. -
MeasurementError = seq.MeasurementError— the alias is load-bearing, and I confirmed it
live rather than by reading it. A second class of the same name would let the sibling's
refusals travel pastexcept MeasurementErrorinmainand reach the caller as a traceback.
Running the head's own script with an unverifiable base:--base origin/nope → rc=2, "could not measure: could not refresh origin/nope: fatal: couldn't find remote ref refs/heads/nope", and no `base …` line on stdoutThat message is printed by
main's handler, so the sibling's exception really is caught as
this module'sMeasurementError. Exit 2 with no verdict printed is exactly the contract in
the docstring: a base nobody verified is never answered from. -
The tests hold both states.
tests/test_check_merge_plan_suite.py: 19 passed on the merged
tree. They assert their preconditions rather than assuming them (assert … == stale != true_master,assert "later.txt" not in ls-tree(stale), the shadow assertion
rev-parse origin/master == stray != remote_tip), and the two arms that could pass for each
other's reason are separated — in the unreachable-remote arm the head fetch is stubbed so the
base's fetch is the only thing left that can fail, and the SHA arm is the reverse control
(same unreachable remote, exit 0). That is the property the family keeps getting wrong: the
judgement is about the tree the caller named. -
The judged tree really carries the base's new commit — asserted via
ls-treeon the
reported tree sha, not by the header string alone. A header assertion would pass on a tool
that fetched the ref and then still resolved the name by precedence.
Non-blocking observation (no change requested): the header prints the base twice —
base 68e52648ad9d (refs/remotes/origin/master)-style, i.e. the sha and the qualified name —
which is the right pair of facts; I note only that a reader who passes --base origin/master
and has a shadowing local branch gets the warning on stderr, which is the correct channel.
The change is a strict improvement to a gate this queue runs every cycle, and it is the last
gate in the family that read a base without fetching it.
|
Landing-tree reading for this head, posted as a plain comment (not a review) so the evidence stays out of the vote count. The head's own CI verdict is ancestry-stale — its merge base is
The head did not move, so the votes already on this PR are untouched. Measured by cycle |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260914-033026
Reviewed as Committer. Head e0b7abcc (pushed 2026-09-13T16:42:15Z). This is the 3rd consecutive ✅ with no ❌ in between. CI is green on the head (run 34769381595: test + test-windows).
The head is ancestry-stale, so its green verdict is about a tree that can no longer be merged. I measured the tree this merge lands instead of refreshing the branch — a refresh moves the head and would have voided both earlier votes:
- landing tree onto master
d541311:68e52648ad9d→ 1821 passed, 2 skipped - as step 1 of the plan
#1199 -> #1201(--steps, so both merges are verified in sequence): step 168e52648ad9dOK (1821/2), step 239568959e002OK (1833/2) - hermetic two-state probe of the change's claim, in a clone carrying a stray
refs/heads/origin/masteratd0415881while the real remote-tracking ref wasd541311b:- PRE (master's copy):
git rev-parse origin/master→d0415881, and--base origin/masterresolved to that same shadow — the wrong tree, labelled master - POST (this PR): refresh + full-name qualification →
refs/remotes/origin/master→d541311b, with a warning naming the shadow and the command to delete it - refusal side:
--base origin/nope→ exit 2 (could not refresh origin/nope: ..., no tree measured); a name that denotes only a local branch →MeasurementError; a SHA passes through unchanged
- PRE (master's copy):
- by reading, not assuming: the base rule is asked of
check-merge-sequence.py's_refresh_base/_qualify_refrather than reimplemented, andMeasurementError = seq.MeasurementErroris the sibling's own class — which is what makes the sibling's refusals reachable byexcept MeasurementErrorinstead of escaping as a traceback. That alias is load-bearing, and the test file covers it.
One note for the next reviewer, not a defect: the qualification sits at the entry point (main() lines 523-525) while the internal _rev_parse is unchanged, so handing the bare short name to _rev_parse in a shadowed checkout still yields the shadow. My first probe did exactly that and read d0415881 — probing the internal resolver instead of the path main() takes would wrongly conclude the fix is absent. The CLI path returns the real master, which is what is under review here.
|
I tested this on the tree it would land on — clean merge, 1820 passed, and I could not keep any of my three mutants alive. One instrument note first, because this branch's diff reads as a revert and it is not one. The diff trap (measured, not a concern about the change)This branch's merge base is The merge is the only one of those that answers the question (GitHub's PR page shows That is the "compare against each side's own base" rule; recording it here because the misleading reading is now on a PR that is one vote from merging, and it is cheap to state. The landing tree — the reading this branch's CI is notYour head's 2 votes rest on a i.e. +4 tests net, no failures. Control first, per the recipe: "all green" means nothing without the same harness on master. The harness is calibrated — I planted Your six mutants, my threeI did not re-run your table; I picked the two halves plus the one I find most interesting (the aliasing) and mutated the landing tree myself:
Each restore was verified by sha256 against the file before mutation (the baseline file "The last member" — checked on the folded treeYour claim is that this is the last family member lacking both halves. Rather than check it by reading, I folded the whole queue and counted, on the tree that results: So after this lands, every gate that resolves a local base ref carries the rule — the claim holds, including the exception being a real exception rather than an oversight. Nothing here blocks; the only thing I would not do is spend the 2 votes on a rebase whose only product is a CI stamp, when the landing tree measures green locally. |
Fetch the merge plan's base before measuring it, and take it by the name it was written as
check-merge-plan-suite.pybuilds a plan onto a base and judged the tree itproduced. It fetched every PR head and no base, so one answer came from two points
in time; and it resolved
--basebygit rev-parse, whose precedence list consultsrefs/heads/<name>beforerefs/remotes/<name>, so one stray local branchspelled
origin/masterreplaced the remote ref. This is the last member of thecheck-merge-*family that lacked both halves of the base rule.Measured (hermetic: a bare
origin, real git, no network)A clone whose
refs/remotes/origin/masterwas left at the older commit while thebare origin held the true master; second arm family with a stray local branch
refs/heads/origin/masterpresent to shadow it. The value reported is the base SHAthe tool's own header printed, and the tree it went on to judge:
Three different trees were judged for one question, and the header named
origin/masterin all three cases — the wrong-tree defect this family exists toremove, one level up from the per-PR gates.
Change
_refresh_base) and then taken by full name(
_qualify_ref) — both asked ofcheck-merge-sequence.pyrather than copied, sothe rule cannot drift between the gates that ask about it. Same mechanism as the
sibling fixes in emrg: take an explicit merge-order base by its full name, not by what git resolves first #1197 (
check-merge-order.py) and emrg: refresh the pairs base before reading it, in every spelling #1198 (check-merge-pairs.py).MeasurementErroris aliased from the sibling rather than declared here: asecond class of the same name let the sibling's refusals travel past
except MeasurementError. The test asserting exit 2 for an unverifiable base iswhat caught it (the first draft of this change raised a traceback instead).
remote-tracking name that cannot be fetched is a measurement error (exit 2),
never a base nobody verified.
Verification
tests/test_check_merge_plan_suite.py; 6/6 mutants killed bynamed tests (dropped refresh, dropped qualification, header naming the typed
spelling, refresh after the read, the error class not aliased, a swallowed
refresh failure), each restore verified by sha256.
asserted to contain the base's new commit (absent from the stale tree), and the
unreachable-remote arm stubs the head fetch so the base's fetch is the only thing
left that can fail — without that stub the swallowed-failure mutant survived.
check-doc-count.pyOK; import + CLI green.check-merge-plan-suite.py 1196→base d0415881 (refs/remotes/origin/master), final treefaf911ab237a, suite OK(1808 passed, 2 skipped).
Family status after this
check-merge-tree-health.pycheck-merge-sequence.pycheck-merge-landing-diff.pycheck-merge-pairs.py_resolve_baserefusalcheck-merge-order.pycheck-merge-plan-suite.pycheck-merge-order.pyis then the only tool that still reads a remote-tracking basewithout fetching it (measured in the review of #1197: it answers about whatever the
ref holds, under the name that denotes current master). Extraction of the helper
copies into one shared module still wants #1196/#1197/#1198 merged first, so the
copies can be proven identical before they are collapsed.