From bf7e00baaa0a6daba84dfe3bab12a59a31c91b5d Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:58:11 -0400 Subject: [PATCH 1/3] fix(ci): merge a stacked PR against the newest commit it holds of the landed PR below it Every time a PR of a stack landed, the train refused every PR above it: their merge base was an old main commit, so the train's own commits on the landed PR (its merges of main, the renumbered section, the measured baselines) conflicted with the stacked copies of the same lines. After #329 landed, #330 to #341 all refused on examples/probe_dim_assoc.rs, and a lane hand-merged each with `git merge-tree --merge-base=`. refresh.sh now finds that base itself (the head of each PR main squashed since the merge base, its merge base with the branch, the nearest one main does not hold), merges against it with git merge-recursive and the usual conflict handling, and writes it to merge-base.sha for the oracle comparison. A PR that is not stacked merges exactly as before. arch.py renumber also rewrites references in the PR's lines to a section main renumbered when the PR below landed, matched by title. --- tools/maintainer/train/arch.py | 22 +++++++++++----- tools/maintainer/train/refresh.sh | 44 ++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/tools/maintainer/train/arch.py b/tools/maintainer/train/arch.py index 8c6fe1da..24fd067f 100644 --- a/tools/maintainer/train/arch.py +++ b/tools/maintainer/train/arch.py @@ -2,8 +2,9 @@ renumber MB MAIN at the PR head: the `## 7X.` sections the PR adds (relative to merge-base MB, compared by heading text) take the suffixes right after MAIN's last one, and every - `§7X` in lines the PR added (any file) follows it. Edits files in place; prints - the mapping, or nothing when no section moves. + `§7X` in lines the PR added (any file) follows it, as it follows a section MB + has that MAIN gives another suffix (matched by title). Edits files in place; + prints the mapping, or nothing when no section moves. resolve PATH during a merge: each conflict hunk whose base side is empty (both sides appended at the same place) becomes theirs, a blank line, then ours, less every `## ` section of ours that main's file already holds word for word (a PR stacked on @@ -72,10 +73,19 @@ def title(h): new_heads = kept mine = sorted({suffix_ for suffix_, _ in new_heads}, key=ordinal) new_lines = {h for _, h in new_heads} - if not mine: - return last = max((ordinal(v) for v in on_main.values()), default=0) - mapping = {k: suffix(last + i + 1) for i, k in enumerate(mine) if k != suffix(last + i + 1)} + own = {k: suffix(last + i + 1) for i, k in enumerate(mine) if k != suffix(last + i + 1)} + # A PR stacked on one that has landed cites that PR's sections by the letters they had at the + # merge base; main gave them new ones when it landed. Those sections are matched by title, when + # the title is unique on both sides, and the PR's lines follow them too. + def by_title(text): + seen = {} + for m in heading.finditer(text): + seen.setdefault(title(m.group(0)), []).append(m.group(1)) + return {t: s[0] for t, s in seen.items() if len(s) == 1} + then, now = by_title(git('show', f'{mb}:ARCHITECTURE.md')), by_title(git('show', f'{main}:ARCHITECTURE.md')) + moved = {s: now[t] for t, s in then.items() if t in now and now[t] != s and s not in own} + mapping = {**moved, **own} if not mapping: return pattern = re.compile(r'(§7|^## 7)(' + '|'.join(sorted(mapping, key=len, reverse=True)) + r')(?![a-z])') @@ -90,7 +100,7 @@ def title(h): text = [pattern.sub(lambda m: m.group(1) + mapping[m.group(2)], l) if l in hits else l for l in text] with open(path, 'w', encoding='utf-8') as fh: fh.write('\n'.join(text)) - print(' '.join(f'7{k}->7{v}' for k, v in mapping.items())) + print(' '.join([f'7{k}->7{v}' for k, v in own.items()] + [f'§7{k}->§7{v}' for k, v in moved.items()])) def sections(lines): diff --git a/tools/maintainer/train/refresh.sh b/tools/maintainer/train/refresh.sh index b7649f8f..c276c730 100755 --- a/tools/maintainer/train/refresh.sh +++ b/tools/maintainer/train/refresh.sh @@ -4,9 +4,11 @@ # # tools/maintainer/train/refresh.sh # -# Merges origin/main into the PR branch (never a force push) in the train's worktree. Before the -# merge, an ARCHITECTURE.md `## 7x.` section the PR adds whose letter main has since taken is moved -# to the next free letter, with every §7x in lines the PR added (arch.py). Conflicts are resolved +# Merges origin/main into the PR branch (never a force push) in the train's worktree. A PR stacked +# on one that has since landed merges against the newest commit of that PR it holds rather than the +# usual merge base. Before the merge, an ARCHITECTURE.md `## 7x.` section the PR adds whose letter +# main has since taken is moved to the next free letter, with every §7x in lines the PR added, and +# those lines follow any section main renumbered when the PR below landed (arch.py). Conflicts are resolved # only in generated or tabular files: tools/corpus/baseline.tsv, tools/oracle/baseline.tsv and # docs/spec-coverage.md take main's, and so does the coverage readme_status.py renders into # README.md and STATUS.md when every conflicting line is rendered text (readme3.py); the PR's CI run @@ -36,15 +38,45 @@ git merge --abort > /dev/null 2>&1 git reset -q --hard git checkout -q --detach "origin/$B" || exit 2 base=$(git rev-parse HEAD) -echo "$base" > "$OUT/pr-head.sha"; git merge-base origin/main HEAD > "$OUT/merge-base.sha" -map=$(python3 -I "$TRAIN_HERE/arch.py" renumber "$(git merge-base origin/main HEAD)" origin/main 2> "$OUT/renumber.log") || { say "REFUSED: section renumber failed: $(cat "$OUT/renumber.log")"; exit 4; } +# A PR stacked on one that has since landed holds that PR's commits but not main's squash of it, and +# the train's own commits on the landed PR (its merges of main, a renumbered section, baselines) then +# conflict with the stacked copies of the same lines. Such a PR merges against the newest commit of +# the landed PR it holds, the base the lanes chose by hand: main's side is then exactly what main +# changed since, the train's commits included, and the PR's side only its own work. +mb=$(git merge-base origin/main HEAD); stack=""; under="" +for n in $(git log --format=%s "$mb..origin/main" | sed -n 's/.*(#\([0-9][0-9]*\))$/\1/p'); do + h=$(gh pr view "$n" -R "$TRAIN_REPO" --json headRefOid --jq .headRefOid 2> /dev/null) || continue + git cat-file -e "$h^{commit}" 2> /dev/null || git fetch -q origin "refs/pull/$n/head" 2> /dev/null || continue + c=$(git merge-base "$h" HEAD 2> /dev/null) || continue + git merge-base --is-ancestor "$c" origin/main && continue + if [ -z "$stack" ] || [ "$(git rev-list --count "$c..HEAD")" -lt "$(git rev-list --count "$stack..HEAD")" ]; then stack=$c; under=$n; fi +done +mb=${stack:-$mb} +echo "$base" > "$OUT/pr-head.sha"; echo "$mb" > "$OUT/merge-base.sha" +[ -n "$stack" ] && say "stacked on #$under: merging against ${stack:0:9}, the newest commit of #$under this branch holds" +map=$(python3 -I "$TRAIN_HERE/arch.py" renumber "$mb" origin/main 2> "$OUT/renumber.log") || { say "REFUSED: section renumber failed: $(cat "$OUT/renumber.log")"; exit 4; } if [ -n "$map" ]; then git commit -qam "docs(architecture): renumber the new section ($map) after the sections main added" || exit 1 say "renumbered ARCHITECTURE.md section $map" fi conflicted=0 -if ! git merge --no-edit -q origin/main > "$OUT/merge.log" 2>&1; then +merge_main() { + [ -z "$stack" ] && { git merge --no-edit -q origin/main; return; } + git merge-base --is-ancestor origin/main HEAD && return 0 + # git merge cannot take a base; merge-recursive can, and leaves the same index and conflict + # stages, so MERGE_HEAD makes the commit below a merge of origin/main as git merge would. + git rev-parse origin/main > "$(git rev-parse --git-path MERGE_HEAD)" + echo "Merge origin/main into $B against ${stack:0:9}, the newest commit of #$under it holds" > "$(git rev-parse --git-path MERGE_MSG)" + git merge-recursive "$stack" -- HEAD origin/main; rc=$? + [ $rc = 0 ] && { git commit -q --no-edit; return; } + [ $rc = 1 ] && return 1 + rm -f "$(git rev-parse --git-path MERGE_HEAD)" "$(git rev-parse --git-path MERGE_MSG)"; git reset -q --hard + echo "[refresh #$P] merge-recursive against ${stack:0:9} failed (exit $rc); see $OUT/merge.log" >&3 + exit 2 +} +exec 3>&1 +if ! merge_main > "$OUT/merge.log" 2>&1; then conflicted=1 for f in $(git diff --name-only --diff-filter=U); do case $f in From fd46cae4cabd6035cdded8f8e9e108e58495a37c Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:33:09 -0400 Subject: [PATCH 2/3] fix(ci): report a section move only when it rewrote a line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arch.py renumber printed every section main had renumbered under a stacked PR's base, even when none of the PR's lines cited one; refresh.sh then ran `git commit -qam` with nothing to commit and refused a mergeable PR. It now prints only the moves that rewrote a line, nothing when none did, and refresh.sh commits the renumber only when the tree changed. Checked at a47e1bf against main: the old script printed "§7bh->§7bi §7bi->§7bj" with no change, the new one prints nothing; at #331's head it prints only 7bj->7bk, at #332's 7bj->7bk 7bk->7bl §7bi->§7bj. --- tools/maintainer/train/arch.py | 11 +++++++++-- tools/maintainer/train/refresh.sh | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/maintainer/train/arch.py b/tools/maintainer/train/arch.py index 24fd067f..30cc81bc 100644 --- a/tools/maintainer/train/arch.py +++ b/tools/maintainer/train/arch.py @@ -89,6 +89,10 @@ def by_title(text): if not mapping: return pattern = re.compile(r'(§7|^## 7)(' + '|'.join(sorted(mapping, key=len, reverse=True)) + r')(?![a-z])') + used = set() + def swap(m): + used.add(m.group(2)) + return m.group(1) + mapping[m.group(2)] for path, lines in added.items(): # A heading line moves only if it is one of the PR's new sections; an edited heading keeps # its suffix even when it shares it with a new one. @@ -97,10 +101,13 @@ def by_title(text): continue with open(path, encoding='utf-8') as fh: text = fh.read().split('\n') - text = [pattern.sub(lambda m: m.group(1) + mapping[m.group(2)], l) if l in hits else l for l in text] + text = [pattern.sub(swap, l) if l in hits else l for l in text] with open(path, 'w', encoding='utf-8') as fh: fh.write('\n'.join(text)) - print(' '.join([f'7{k}->7{v}' for k, v in own.items()] + [f'§7{k}->§7{v}' for k, v in moved.items()])) + # Only the moves that rewrote a line are reported: an empty report means nothing changed. + done = [f'7{k}->7{v}' for k, v in own.items() if k in used] + [f'§7{k}->§7{v}' for k, v in moved.items() if k in used] + if done: + print(' '.join(done)) def sections(lines): diff --git a/tools/maintainer/train/refresh.sh b/tools/maintainer/train/refresh.sh index c276c730..6a203b2c 100755 --- a/tools/maintainer/train/refresh.sh +++ b/tools/maintainer/train/refresh.sh @@ -55,7 +55,7 @@ mb=${stack:-$mb} echo "$base" > "$OUT/pr-head.sha"; echo "$mb" > "$OUT/merge-base.sha" [ -n "$stack" ] && say "stacked on #$under: merging against ${stack:0:9}, the newest commit of #$under this branch holds" map=$(python3 -I "$TRAIN_HERE/arch.py" renumber "$mb" origin/main 2> "$OUT/renumber.log") || { say "REFUSED: section renumber failed: $(cat "$OUT/renumber.log")"; exit 4; } -if [ -n "$map" ]; then +if [ -n "$map" ] && ! git diff --quiet; then git commit -qam "docs(architecture): renumber the new section ($map) after the sections main added" || exit 1 say "renumbered ARCHITECTURE.md section $map" fi From fa8c1a7901173fc5a6586b928c12dcf1ee859047 Mon Sep 17 00:00:00 2001 From: Griffin Long <151978260+DrunkOnJava@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:55:00 -0400 Subject: [PATCH 3/3] fix(ci): use a stacked base only when it holds every main commit the branch holds The landed PR's head and the stacked branch can have several merge bases, a commit of the landed PR and a main commit, and `git merge-base` returns one. When the branch merged main after taking the landed PR's commits, merging against that PR's commit drops the main history the branch already holds, and a change main later reverted comes back. refresh.sh now considers every merge base, and takes one only when each of the branch's ordinary merge bases with main is its ancestor; otherwise the branch merges as it always has. Reproduced in a scratch repository: with the landed PR's commit as base the merge kept the reverted f=1, the new rule rejects that base and git merge gives f=0; a plain stack still takes the PR's commit and merges cleanly where git merge conflicts. On the real branches #331, #332 and #341 still take a47e1bf (#330), and #383 takes none. --- tools/maintainer/train/refresh.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tools/maintainer/train/refresh.sh b/tools/maintainer/train/refresh.sh index 6a203b2c..8d3fd704 100755 --- a/tools/maintainer/train/refresh.sh +++ b/tools/maintainer/train/refresh.sh @@ -43,13 +43,18 @@ base=$(git rev-parse HEAD) # conflict with the stacked copies of the same lines. Such a PR merges against the newest commit of # the landed PR it holds, the base the lanes chose by hand: main's side is then exactly what main # changed since, the train's commits included, and the PR's side only its own work. -mb=$(git merge-base origin/main HEAD); stack=""; under="" +# Such a base must already hold every main commit the branch holds: when the branch merged main +# after taking the landed PR's commits, that history would drop out of the merge and main's later +# changes to it could be undone, so the branch merges as it always has. +mb=$(git merge-base origin/main HEAD); mball=$(git merge-base --all origin/main HEAD); stack=""; under="" for n in $(git log --format=%s "$mb..origin/main" | sed -n 's/.*(#\([0-9][0-9]*\))$/\1/p'); do h=$(gh pr view "$n" -R "$TRAIN_REPO" --json headRefOid --jq .headRefOid 2> /dev/null) || continue git cat-file -e "$h^{commit}" 2> /dev/null || git fetch -q origin "refs/pull/$n/head" 2> /dev/null || continue - c=$(git merge-base "$h" HEAD 2> /dev/null) || continue - git merge-base --is-ancestor "$c" origin/main && continue - if [ -z "$stack" ] || [ "$(git rev-list --count "$c..HEAD")" -lt "$(git rev-list --count "$stack..HEAD")" ]; then stack=$c; under=$n; fi + for c in $(git merge-base --all "$h" HEAD 2> /dev/null); do + git merge-base --is-ancestor "$c" origin/main && continue + for m in $mball; do git merge-base --is-ancestor "$m" "$c" || continue 2; done + if [ -z "$stack" ] || [ "$(git rev-list --count "$c..HEAD")" -lt "$(git rev-list --count "$stack..HEAD")" ]; then stack=$c; under=$n; fi + done done mb=${stack:-$mb} echo "$base" > "$OUT/pr-head.sha"; echo "$mb" > "$OUT/merge-base.sha"