From 777e44e77993c537dd26889bacef3bb7a12b52d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 22:44:31 +0000 Subject: [PATCH] fix(devx): partition os-regen-merge.sh's step-1 conflict message by class os-regen-merge.sh step 1 called every merge conflict "in NON-generated files" and told the operator "do not resolve generated files textually" -- correct for a plain non-regen conflict, but exactly backwards for a `merge=os-regen` path the driver text-merged and left conflicted (a MIXED row `git-merge-regen.mjs` declined to defer because the deferral would have silently dropped hand-written prose). Following that line's instruction on a MIXED conflict -- taking one side whole -- is precisely the outcome the driver refused to allow. Partition the conflicted set (`git diff --diff-filter=U`) against the `merge=os-regen` path list the script already reads at :220, reusing it as a pathspec the same way step 2 already does. Three messages: - non-regen only: today's message, unchanged. - regen-path only (MIXED, driver-declined): "hand-resolve the prose, take either side of the anchors" -- the "do not resolve generated files textually" line is suppressed, since it is about deferrable regen paths and is actively wrong advice for a MIXED one. - both classes: name each file's class; the suppressed line stays suppressed since some of the generated conflicts present DO need hand-resolution. The merge's own output (including the driver's remedy, e.g. its printed regeneration command) is now captured and echoed on both the success and failure paths, so the operator sees the driver's own notice rather than a generic pointer to "step 4". Pinned in --self-test: a MIXED-conflict fixture (a real driver that text-merges and conflicts, not the existing fixture's `true` no-op) for the regen-only and both-classes cases, whose key assertion is the ABSENCE of the "do not resolve generated files textually" line -- proven discriminating by mutating the script back to the old unconditional message and watching that assertion red. scripts/git-merge-regen.mjs (the driver) is untouched; it behaved correctly throughout. No relaxation of the deferral rules. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV --- scripts/pm/os-regen-merge.sh | 236 ++++++++++++++++++++++++++++++++++- 1 file changed, 232 insertions(+), 4 deletions(-) diff --git a/scripts/pm/os-regen-merge.sh b/scripts/pm/os-regen-merge.sh index d2940887b2..5d78dd31d8 100644 --- a/scripts/pm/os-regen-merge.sh +++ b/scripts/pm/os-regen-merge.sh @@ -240,12 +240,77 @@ mode_run() { main_edited="$NL$(git diff --name-only "$merge_base" origin/main -- "${regen_paths[@]}")$NL" echo "→ step 1: git merge origin/main" - if ! git merge --no-edit origin/main; then - echo "✗ merge stopped on conflicts in NON-generated files — resolve those by hand" >&2 - echo " (semantic merge, both intents stack), then rerun this script to redo the" >&2 - echo " generated-artifact half. ⛔ Do not resolve generated files textually." >&2 + # Captured rather than left to inherit the terminal, so a conflict can be + # classified below AND the driver's own notice (printed on stderr, for any + # os-regen path it declined to defer) is still shown to the operator instead + # of scrolling past unread. + merge_log="$(mktemp "${TMPDIR:-/tmp}/os-regen-merge-log.XXXXXX")" + if ! git merge --no-edit origin/main >"$merge_log" 2>&1; then + cat "$merge_log" >&2 + rm -f "$merge_log" + + # Partition the conflicted set against the os-regen path list already read + # above (:216-226) — reusing it as a `git diff` pathspec is the same trick + # step 2 already relies on for `branch_edited`/`main_edited` (:239-240), so + # this needs no glob-matching code of its own and no new inputs. + # + # Any conflict on a regen path is necessarily one the merge driver declined + # to defer (see git-merge-regen.mjs: a non-`mixed` row always resolves with + # exit 0, no markers — only a MIXED row whose deferral would be unsafe falls + # through to a real text merge, which is what can conflict here). So a + # regen-path conflict always means: hand-resolve the prose, never "take one + # side" — the opposite of what a wholly-generated path would call for, and + # the reason the blanket "do not resolve generated files textually" line + # below is wrong, and suppressed, whenever a regen path shows up here. + all_conflicts=() + conflict_line='' + while IFS= read -r conflict_line; do + if [[ -n "$conflict_line" ]]; then all_conflicts+=("$conflict_line"); fi + done < <(git diff --name-only --diff-filter=U) + regen_conflicts=() + regen_conflict_line='' + while IFS= read -r regen_conflict_line; do + if [[ -n "$regen_conflict_line" ]]; then regen_conflicts+=("$regen_conflict_line"); fi + done < <(git diff --name-only --diff-filter=U -- "${regen_paths[@]}") + + NL=' +' + regen_set="$NL" + for c in "${regen_conflicts[@]+"${regen_conflicts[@]}"}"; do regen_set="$regen_set$c$NL"; done + non_regen_conflicts=() + for c in "${all_conflicts[@]+"${all_conflicts[@]}"}"; do + case "$regen_set" in + *"$NL$c$NL"*) ;; + *) non_regen_conflicts+=("$c") ;; + esac + done + + if [ "${#regen_conflicts[@]}" -eq 0 ]; then + echo "✗ merge stopped on conflicts in NON-generated files — resolve those by hand" >&2 + echo " (semantic merge, both intents stack), then rerun this script to redo the" >&2 + echo " generated-artifact half. ⛔ Do not resolve generated files textually." >&2 + elif [ "${#non_regen_conflicts[@]}" -eq 0 ]; then + echo "✗ merge stopped on conflicts in GENERATED files the driver declined to defer" >&2 + echo " (MIXED — a generated half plus hand-written prose; see its notice above)." >&2 + echo " Hand-resolve the prose; the anchor numbers do not matter here — take" >&2 + echo " either side of them, then run the regeneration command the driver printed" >&2 + echo " above, then continue with step 4:" >&2 + printf ' %s\n' "${regen_conflicts[@]}" >&2 + else + echo "✗ merge stopped on conflicts in BOTH non-generated and generated files:" >&2 + echo " non-generated (resolve by hand — semantic merge, both intents stack):" >&2 + printf ' %s\n' "${non_regen_conflicts[@]}" >&2 + echo " generated, MIXED — the driver declined to defer these (see its notice" >&2 + echo " above). Hand-resolve the prose; the anchor numbers do not matter here —" >&2 + echo " take either side of them, then run the regeneration command the driver" >&2 + echo " printed above:" >&2 + printf ' %s\n' "${regen_conflicts[@]}" >&2 + echo " Resolve both, then rerun this script to redo the generated-artifact half." >&2 + fi exit 1 fi + cat "$merge_log" + rm -f "$merge_log" echo "→ step 2: taking origin/main's side of the generated artifacts main moved" # ⚠️ bash 3.2 expands `"${arr[@]}"` of an EMPTY array as an unbound variable @@ -403,6 +468,96 @@ st_fixture() { git fetch -q origin main } +# Build a fixture repo in $1 whose only conflict(s) are a REAL text-merge +# conflict on a `merge=os-regen` path — the MIXED shape `git-merge-regen.mjs` +# takes when a deferral would be unsafe (:14064 in the driver's own header), +# not the clean silent-deferral shape `st_fixture` above exercises. $2, if +# `both`, also gives the branch a conflicting NON-regen edit, for the "both +# classes present" case. +# +# The driver here is a tiny fixture script, not `true`: it runs +# `git merge-file` (a REAL 3-way text merge) and, on conflict, prints a +# driver-shaped remedy naming a fixture regen command before exiting +# non-zero — the same move the real driver makes for an unsafe MIXED row +# (`git-merge-regen.mjs`'s `textMergeInPlace` + its `NOT deferred` notice). +# Kept as a separate file (not inlined in .gitattributes config) so its exit +# code — not `true`'s constant 0 — is what git sees for this path. +st_fixture_regen_conflict() { + fx="$1" + both="${2:-}" + rm -rf "$fx" + mkdir -p "$fx" + + cat > "$fx/driver.sh" <<'DRIVER' +#!/usr/bin/env bash +set -uo pipefail +ancestor="$1"; ours="$2"; theirs="$3"; path="$4" +git merge-file "$ours" "$ancestor" "$theirs" +rc=$? +if [ "$rc" -eq 0 ]; then + exit 0 +fi +{ + printf ' \xe2\x9a\xa0 %s\n' "$path" + printf ' NOT deferred: the incoming side carries hand-written changes that no regeneration can restore.\n' + printf ' This file is MIXED — a generated half plus hand-written prose — so keeping one\n' + printf " side whole would delete the other side's prose with no conflict and no red gate.\n" + printf ' Text-merged instead, and it CONFLICTS. Resolve the prose by hand; the anchor\n' + printf ' numbers do not matter here — take either side and then run:\n' + printf ' pnpm gen:fixture-mixed\n' + printf ' which re-derives them from the merged tree.\n' +} >&2 +exit "$rc" +DRIVER + chmod +x "$fx/driver.sh" + + git init -q --bare -b main "$fx/origin.git" + git clone -q "$fx/origin.git" "$fx/work" 2>/dev/null + cd "$fx/work" + git config user.email selftest@example.invalid + git config user.name os-regen-merge-selftest + git config commit.gpgsign false + git config merge.os-regen.name 'os-regen (fixture: real text merge, conflicts on MIXED prose)' + git config merge.os-regen.driver "bash $fx/driver.sh %O %A %B %P" + + mkdir -p gen src + printf 'gen/** merge=os-regen\n' > .gitattributes + printf 'hand-written prose: original\n' > gen/mixed.txt + printf 'source v1\n' > src/app.txt + if [ "$both" = both ]; then + printf 'prose v1\n' > src/prose.txt + fi + git add -A + git commit -qm seed + git push -q origin main + + git checkout -q -b feature + printf 'hand-written prose: BRANCH\n' > gen/mixed.txt + if [ "$both" = both ]; then + printf 'prose BRANCH\n' > src/prose.txt + fi + git add -A + git commit -qm 'feature: hand-edit the MIXED prose' + + git worktree add -q "$fx/mainwt" main + ( + cd "$fx/mainwt" + git config user.email selftest@example.invalid + git config user.name os-regen-merge-selftest + git config commit.gpgsign false + printf 'hand-written prose: MAIN\n' > gen/mixed.txt + if [ "$both" = both ]; then + printf 'prose MAIN\n' > src/prose.txt + fi + git add -A + git commit -qm 'main: also hand-edit the MIXED prose' + git push -q origin main + ) + cd "$fx/work" + git worktree remove "$fx/mainwt" + git fetch -q origin main +} + mode_self_test() { tmp="$(mktemp -d "${TMPDIR:-/tmp}/os-regen-merge-selftest.XXXXXX")" trap 'rm -rf "$tmp"' EXIT INT TERM @@ -509,6 +664,79 @@ mode_self_test() { "$(printf '%s' "$out" | grep -c 'refusing to guess' || true)" 1 cd "$here" + # --- 6. a MIXED-conflict on a regen path ONLY. The trap this closes: step 1 + # must not tell the operator these conflicts are "in NON-generated + # files" (they are not), and must not tell them to leave the file + # alone (⛔ "do not resolve generated files textually") — the driver + # has just asked them to hand-resolve exactly this one. + st_fixture_regen_conflict "$tmp/f" + out="$(bash "$SELF" 2>&1)" && rc=0 || rc=$? + st_case 'a regen-only MIXED conflict fails the run' "$rc" 1 + st_case 'and does NOT call it a non-generated-file conflict' \ + "$(printf '%s' "$out" | grep -c 'conflicts in NON-generated files' || true)" 0 + # THE ABSENCE ASSERTION THAT MATTERS (#14671): this exact line is correct + # advice for a deferrable regen path and wrong, silently-destructive advice + # for a MIXED one — see the driver's own notice, echoed a few lines above in + # the same run's output, saying the opposite. + st_case 'and does NOT forbid textual resolution of the generated file' \ + "$(printf '%s' "$out" | grep -c 'Do not resolve generated files textually' || true)" 0 + st_case 'and DOES say the file needs hand-resolving' \ + "$(printf '%s' "$out" | grep -c 'Hand-resolve the prose' || true)" 1 + st_case 'and names the conflicted regen path' \ + "$(printf '%s' "$out" | grep -q 'gen/mixed.txt' && echo present || echo absent)" present + st_case "and the driver's own notice is still shown" \ + "$(printf '%s' "$out" | grep -c 'NOT deferred: the incoming side carries hand-written changes' || true)" 1 + st_case "and the driver's regeneration command is still shown" \ + "$(printf '%s' "$out" | grep -c 'pnpm gen:fixture-mixed' || true)" 1 + cd "$here" + + # --- 6b. THE DISCRIMINATING MUTATION. A self-test that can never fail is + # worse than none: prove the absence assertion above actually + # distinguishes the fixed script from the original bug by + # reintroducing the old unconditional line and watching case 6 red. + mutated="$tmp/mutated-os-regen-merge.sh" + # Literal (non-regex) replacement via perl's \Q..\E, keyed off the anchor + # line so this stays robust to reflow — same dependency scripts/pm/ already + # takes for something bash 3.2 cannot do (os-verify-lock.sh's Time::HiRes). + MUT_ANCHOR='echo "✗ merge stopped on conflicts in GENERATED files the driver declined to defer" >&2' \ + MUT_INSERT='echo "✗ merge stopped on conflicts in NON-generated files — resolve those by hand" >&2 + echo " ⛔ Do not resolve generated files textually." >&2' \ + perl -0777 -pe 's/\Q$ENV{MUT_ANCHOR}\E/$ENV{MUT_INSERT}/' "$SELF" > "$mutated" + # Scanned only up to the `--- self-test` marker (the same trick the script's + # own step-2-spelling pin uses above) — past that point the phrase also + # appears inside THIS self-test's own assertion strings, which the mutation + # never touches and which would otherwise inflate the count. + st_case 'the mutation anchor was found and replaced (falsifiability check)' \ + "$(sed -n '1,/^# --- self-test/p' "$mutated" | grep -c 'Do not resolve generated files textually' || true)" 2 + st_case 'the mutation actually changed the script text' \ + "$(diff -q "$SELF" "$mutated" >/dev/null 2>&1; echo $?)" 1 + st_case 'and the mutated script still parses' \ + "$(bash -n "$mutated" >/dev/null 2>&1; echo $?)" 0 + st_fixture_regen_conflict "$tmp/f-mutated" + mut_out="$(bash "$mutated" 2>&1)" && mut_rc=0 || mut_rc=$? + st_case 'mutated: the suppressed line is back (proves the assertion bites)' \ + "$(printf '%s' "$mut_out" | grep -c 'Do not resolve generated files textually' || true)" 1 + cd "$here" + + # --- 7. BOTH classes present: a non-regen conflict alongside the regen-path + # MIXED conflict. Each file must be named under its own class, and + # the suppressed line stays suppressed here too — some of the + # generated conflicts in this run DO need hand-resolution, so the + # blanket "do not resolve generated files textually" would be just as + # wrong here as in the regen-only case. + st_fixture_regen_conflict "$tmp/g" both + out="$(bash "$SELF" 2>&1)" && rc=0 || rc=$? + st_case 'a mixed-classes conflict fails the run' "$rc" 1 + st_case 'and says BOTH classes are present' \ + "$(printf '%s' "$out" | grep -c 'conflicts in BOTH non-generated and generated files' || true)" 1 + st_case 'and names the non-generated file' \ + "$(printf '%s' "$out" | grep -q 'src/prose.txt' && echo present || echo absent)" present + st_case 'and names the generated (regen) file' \ + "$(printf '%s' "$out" | grep -q 'gen/mixed.txt' && echo present || echo absent)" present + st_case 'and the suppressed line is absent here too' \ + "$(printf '%s' "$out" | grep -c 'Do not resolve generated files textually' || true)" 0 + cd "$here" + if [ "$st_fail" -ne 0 ]; then printf '✗ os-regen-merge self-test: %d case(s) failed.\n' "$st_fail" return 1