Skip to content

emrg: read the order forecast's conflicted paths through the sibling that owns the rule - #1216

Closed
argszero wants to merge 1 commit into
masterfrom
feature/order-reads-the-stage-block
Closed

argszero wants to merge 1 commit into
masterfrom
feature/order-reads-the-stage-block

Conversation

@argszero

Copy link
Copy Markdown
Owner

What

check-merge-order.py — the merge-cascade forecast — read the conflicted paths out of merge-tree's stage block with its own copy of the rule: a regex matching the shape, returning the text after the tab.

That text is git's spelling of the path, not the path. With the default core.quotePath=true, any name holding a non-ASCII byte, a quote, a backslash or a control byte arrives C-quoted. Measured 2026-09-14 (cyc20260914-104220, git 2.50.1, scratch repo):

100644 df967b96... 1\t"\\344\\270\\255\\346\\226\\207.txt"
100644 ba2906d0... 2\t"\\344\\270\\255\\346\\226\\207.txt"
100644 2299c379... 3\t"\\344\\270\\255\\346\\226\\207.txt"

old reading -> ['"\\344\\270\\255\\346\\226\\207.txt"']   exists? False
new reading -> ['中文.txt']                                exists? True

The names are not decoration: forecast prints them (CONFLICTS with the base on ..., dirties ... on ... (n)) and attributes the whole cascade through them, so the caller was told to go resolve a file that does not exist.

How

The reading is now the sibling's that owns it — check-merge-tree-health.py::_conflict_block_paths (shape match + _unquote_path decoding), the rule #1212 landed and #1215 delegated the sequence tool to. This was the fifth, and last, own copy of that rule.

  • _load_sibling() became generic (path, name) so this tool can ask two siblings; both dependencies are one-way, so the eager loads cannot recurse.
  • The two contract bits this tool keeps on top of the sibling's answer stay here, with their own tests: the paths are deduplicated (the sibling returns one entry per stage line — three for a content conflict), and an empty answer stays None ("not answered") rather than collapsing into the clean answer.

Why the tests separate the two readings

  • test_a_quoted_path_is_reported_as_the_real_name — real git, a conflicted 中文.txt; asserts the name that comes back can actually be opened. This is the arm that tells a decoding reading from a pass-through one; the two agree on every plain-ASCII path, so nothing else here can.
  • test_the_path_reading_is_the_siblings_not_a_copy_here — replaces the sibling's reader in-process with a sentinel: if the sentinel comes back, the delegation is live rather than a local regex kept beside a loaded-but-unused sibling.
  • test_the_dedupe_and_the_not_answered_answer_stay_this_tools — pins the two bits above, so delegating the reading does not quietly delegate them too.

Verification

  • Mutant battery, 4/4 killed, restore proven by sha256: (M1 shape-only reading — killed by the sentinel test and, checked alone, by the quoted-path test; M2 no dedupe; M3 return paths instead of paths or None; M4 loader ignoring its path argument).
  • tests/test_check_merge_order.py: 36 passed.
  • Full suite on the branch: 1915 passed, 1 skipped.
  • check-doc-count.py: OK (the count is measured, not stored).
  • emrg.client.app import + python -m emrg --help: ok.

…that owns the rule

check-merge-order.py matched merge-tree's stage-block shape itself and
returned the text after the tab, i.e. git's *spelling* of the path rather
than the path: with the default core.quotePath=true a non-ASCII name
arrives C-quoted, so the forecast named a file no resolver can open
(measured: '"\\344\\270\\255.txt"' vs the real name, exists? False).

The reading is now check-merge-tree-health.py's _conflict_block_paths
(#1212 landed it, #1215 delegated the sequence tool to it) - the fifth and
last own copy of this rule. The generic _load_sibling lets this tool ask
two siblings; the two contract bits it keeps on top (dedupe, empty answer
stays None) have their own tests.
@argszero

Copy link
Copy Markdown
Owner Author

Landing-tree measurement, cycle cyc20260914-104220 — recorded so the vetting cycles have it without re-running the suite:

$ uv run --no-sync python3 scripts/check-merge-plan-suite.py 1216
base 2dbabc9b (refs/remotes/origin/master), 1 PR(s) planned
plan: #1216
final tree 44d28321bcaa (44d28321bcaa602db1db4546c521a08cd3b40340)
suite OK: 1914 passed, 2 skipped in 85.57s

Independently, without the tool: git merge-tree --write-tree 2dbabc9bcd5c4ed1bfc3528c3bc9ce8861b91925 147ff1a6e049129ea2298793dec67855c4e0278f44d28321bcaa602db1db4546c521a08cd3b40340, rc 0 — the same tree git builds for this head, cleanly (no conflict against the base).

Same tree, same reading on the branch itself (1915 passed, 1 skipped in a full checkout — a worktree run reports one extra skip, as #1214 documents).

The changed code path was also exercised live rather than only under fixtures:

$ uv run --no-sync python3 scripts/check-merge-order.py 1215 1216
base 2dbabc9bcd5c4ed1bfc3528c3bc9ce8861b91925, 2 open PR(s), 0 of 1 pairs conflict

  #1215: mergeable, and merging it dirties nothing else
  #1216: mergeable, and merging it dirties nothing else

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

I tested this on real repositories in both directions, independently re-killed its guard, and swept the whole family by mechanism to check the "fifth and last" claim. The change does what it says; below are the numbers, one divergence between the two remaining copies, and one correction to my own earlier count.

1. The defect, both directions

Pre-PR file loaded from master 2dbabc9 beside this head, both asked the same real repos:

conflict git's stage block pre-PR (its own regex) this PR (delegated)
中文.txt, content 100644 de980441… 1\t"\344\270\255\346\226\207.txt" ['"\344\270\255\346\226\207.txt"']exists? False ['中文.txt']exists? True
plain.txt, content … 1\tplain.txt ['plain.txt'] ['plain.txt'] (control: both agree)

Exactly the arm in the PR body, reproduced against git rather than read from the description.

2. The delegation is live, and its guard is load-bearing

Mutating the sibling (check-merge-tree-health.py, return paths → sentinel) and re-importing this file: _conflict_paths answers ['SENTINEL-FROM-TREE-HEALTH']; sibling then restored byte-exactly. So the load is not inert.

Independently of your battery, I mutated this file the other way — inlining a local pass-through regex where the sibling call is, which is the shape a "loaded but unused sibling" regression would take:

mutant result: rc 1, killed by:
  test_the_path_reading_is_the_siblings_not_a_copy_here
  test_a_quoted_path_is_reported_as_the_real_name

Two distinct arms kill it, which is the right redundancy: one asserts the delegation, the other asserts the observable name.

3. Both contract bits hold, including the direction that matters

  • dedupe — the sibling returns one entry per stage line; on that conflict it answers ['中文.txt','中文.txt','中文.txt'] and _conflict_paths answers ['中文.txt'].
  • the empty answer is not the clean answer — with the sibling mutated to answer [] (i.e. "I recognise nothing here"), a conflicted pair returns None, not []; a genuinely clean pair still returns [], because that answer is evidenced by the named tree on line 1 rather than by the sibling. Both measured. That is the asymmetry the docstring is about, and it survives the mutation — a reader that recognised nothing cannot be reported as "conflicts with nothing".

4. The "fifth and last own copy" claim — checked by mechanism, not by helper names

I have been wrong about this family's size twice, both times by grepping the sibling's helper names instead of the mechanism, so this time I swept every path-yielding git invocation in scripts/, emrg/ and tests/ and classified each: merge-tree (plan-suite, sequence, order, tree-health, landing-diff), diff --name-status (landing-diff, -z since #1213), ls-files (doc-count), ls-tree (push-branch-from-api). Result on this head:

  • stage-block shape regexes: check-merge-plan-suite.py and check-merge-tree-health.pytwo own copies remain (this PR removed order's; sequence never had one).
  • decoding: those same two, with identical _C_ESCAPES tables (152 chars each) and _unquote_path bodies whose executable part is identical (AST-compared, docstrings excluded).
  • pass-through readers: none. That is the closure this PR completes.
  • prose readers: check-merge-sequence.py only, until emrg: read the sequence's conflicted paths from the stage block, not the prose #1215 lands (it is the one that answered a whole sentence for a modify/delete).
  • check-merge-pairs.py matches the word CONFLICT but names no paths (it asks seq._merge_commit); check-merge-freshness.py reads none.

So the class is closed in the sense that matters — no reader can now hand back a spelling — while "last own copy" glosses that two own copies of the rule still exist, in two files, with nothing enforcing their agreement. They agree on everything I threw at them (nine git spellings — C-quoted non-ASCII, tab, quote, backslash, newline, octal ESC, space, plain, and a malformed unterminated quote — plus the four "nothing recognised" reports). One input does diverge:

[tree, "100644 … 1\tfirst.txt", "", "100644 … 1\tsecond.txt"]
  plan-suite  -> ['first.txt']
  tree-health -> ['first.txt', 'second.txt']

because the plan-suite copy stops at the blank line and the tree-health copy matches by shape alone. Harmless in practice (prose does not carry the 100644 <40-hex> <stage> shape) and I am not asking for a change — but it is the kind of drift that made this family necessary, and it is now the only unenforced agreement left in it.

5. Suite line

tests/test_check_merge_order.py: 36 passed here. Full suite on this branch: 1913 passed, 3 skipped where your body reports 1915/1 — same two node probes again (npm is not on PATH here, and a fresh git worktree add has no emrg/gui/renderer/node_modules); 1913+2 = 1915, 3−2 = 1. Same verdict either way, as established on #1215 — naming the environment in the landing-suite report would end this recurring phantom difference.

@argszero

Copy link
Copy Markdown
Owner Author

Superseded by #1217, which contains this change.

This PR read the order forecast's conflicted paths through the sibling that owns
the rule. The holistic fix (host directive on the whole conflicted-path defect:
analyse it once, don't fix it one tool at a time) went further: the rule now lives
in one ownerscripts/merge_tree.py — and every gate asks it, including
this one. check-merge-order.py's _conflict_paths delegates to
merge_tree.fold, so the delegation this PR introduced is replaced by a delegation
to the owner.

The commit here is contained in #1217 (that branch was cut from master and this
commit is in its history), so nothing in it is lost; the reporting change and its
tests carry over, and tests/test_check_merge_order.py still holds the three arms
this PR added.

Closing because #1215, #1216 and #1217 all edit the same reader: the second to land
would conflict with the first, and that contention is the symptom this consolidation
removes. #1217 also adds the guard that refuses the next copy
(tests/test_merge_tree_is_the_only_reading.py).

Landing tree of the successor: e36fbb2a28f9, suite 1947 passed, 2 skipped
(posted on #1217, cross-checked with a bare git merge-tree).

@argszero argszero closed this Sep 14, 2026
argszero added a commit that referenced this pull request Sep 14, 2026
…er (#1217)

* emrg: give the merge-precheck family one owner for git's merge answer

`git merge-tree --write-tree`'s two facts - whether it answered at all, and which
paths conflict - are one rule each, and every gate in the family needs both. This
module is the one place they are written down, with the measured arm table that
says why: the report's prose names a real path only for the conflict kinds that
use the `Merge conflict in <path>` wording, the stage block always names the paths
but spells them the way git quotes paths, and neither reading alone is complete.

Measured 2026-09-14 (git 2.50.1, scratch repos), every row of the table read
against the paths the merged inputs actually contain - including the arm that
needs no quoting at all, where all three readings agree and a wrong reading hides.

No gate asks this module yet; the next commit makes them.

* emrg: every merge gate asks that owner, and a guard keeps it that way

Five gates carried their own reading of `merge-tree`'s report - three of them a
full copy, each copy blind to a different arm - and that is why one defect took
five PRs (#1210, #1212, #1213, #1215, #1216) to half-fix: every repair fixed the
arm its own instance could see. A rule with five implementations is five rules,
and repairing them one at a time cannot converge. So the family asks
`scripts/merge_tree.py` for all of it now:

* the reading, the OID shape, the path decoding, the pinned fold date and the
  error class are aliases or one-line delegations - or gone, where nothing called
  them any more (a wrapper nobody calls still tells a reader the rule lives there);
* the gates that reached into `commit-tree` themselves ask for the merge commit,
  and the owner now refuses an exit code it does not know *even when that merge
  named a tree*: wrapping that tree would turn "I do not understand this answer"
  into "here is the merge", the reassuring direction the named-tree rule exists to
  refuse. `check-merge-sequence.py` had that check privately, one gate deep.

`tests/test_merge_tree_is_the_only_reading.py` is what keeps the collapse from
un-collapsing: a re-declared rule name, a re-bound literal, a re-spelled regex or
an octal decode outside the owner fails there, and the file proves both directions
by planting the copies this repo actually shipped - plus the docstring that is
allowed to quote them, which is why the rule is read out of the AST rather than
out of the text.

`tests/test_check_merge_sequence.py` keeps #1215's arms (the modify/delete
fixture, the real-git modify/delete and non-ASCII names, the refusal arm), so
superseding that PR loses nothing it verified.

* emrg: skip the names Windows cannot hold, rather than weaken those arms

`test-windows` (run 34802892883) failed on the three real-git arms whose names
exist only on POSIX, exactly as the #1214 lesson says it would: macOS cannot
create them wrong. Measured there: `f<TAB>tab.txt` and `q"uote.txt` fail at
`Path.write_text` with `OSError [Errno 22]` (NTFS rejects a control byte and a
double quote in a filename), and `back\slash.txt` with `FileNotFoundError`
(backslash *is* the separator on Windows, so that spelling is a path, not a name).

The property under test is "the decoded name is openable", and on Windows the
file cannot be created for the reading to be asked about it, so those three arms
are skipped there with the reason - not weakened, not deleted:

* the rule keeps a real-git arm on every platform: the non-ASCII case, which NTFS
  accepts, and which CI confirms ran on Windows rather than skipping;
* it keeps a string-fixture arm on every platform, including the TAB arm that
  tells the block reading from the prose reading
  (`TestThePathsComeFromTheStageBlockDecoded`).

`pytest.mark.skipif(sys.platform == "win32", reason=...)` is applied per
parameter, following the idiom the sibling test in
`tests/test_check_merge_tree_health.py` already uses for the same reason - that
reason naming which test still covers the rule on the platform where it is
skipped, because a skip is a claim about coverage, not a way to stop asking.

* emrg: a conflicting merge names a tree too, so only a clean one is a tree

The family's own rule is "the exit code is not the signal, the named tree is". Two
callers then read "a tree was named" as the stronger claim "this is the merge":

* `merge_tree.merged_tree_sha` - documented as *the tree of the clean merge*, and it
  checks `answered`, not the verdict. A conflicting merge names a tree as well
  (measured 2026-09-14, git 2.50.1, scratch repo: exit 1, tree `740ed768...`), and
  the blob at the conflicted path is the two sides' text with git's conflict markers
  around it - so a caller asking for the clean merge's tree got one nobody can
  commit, and the guard that reads that tree never opens the file the markers are
  in.
* `check-merge-landing-diff.py::_merge_tree` - same mapping, and it also flattened
  an exit code the family does not know into "this is the landing".

Every other gate had already refused both shapes (plan-suite, order, sequence,
tree-health, and `merge_commit` from the day it was written), which is exactly the
drift the one-owner change exists to end: the mapping was written per caller, so two
callers disagreed with the other five.

The mapping now lives once, in `merge_tree.merged_tree`: `clean` is a tree, a
conflict is `None`, anything unmeasured raises. `merged_tree_sha` (a caller that has
no answer without a tree) and `merge_commit` build on it, and
`check-merge-landing-diff.py` delegates - so a conflict cannot reach a gate as a
tree again without the owner being changed first.

Verified in both directions, as this family requires:

* the measured arm is pinned on real git -
  `test_a_conflicting_merge_names_a_tree_full_of_markers` reads the marker file out
  of the tree a conflict names (it cannot be quoted in a docstring: the repo's
  `tests/test_conflict_markers.py` reads any literal marker line as a marker
  somebody committed);
* five mutants killed, files restored by sha256: "named a tree is the answer"
  re-planted, unmeasured flattened to `None`, `merged_tree_sha` answering from
  `fold` again, landing-diff re-reading it the old way, and a gate re-declaring
  `_merged_tree` (which the single-owner guard catches);
* full suite 1955 passed, 1 skipped.

---------

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants