@@ -240,12 +240,77 @@ mode_run() {
240240 main_edited=" $NL $( git diff --name-only " $merge_base " origin/main -- " ${regen_paths[@]} " ) $NL "
241241
242242 echo " → step 1: git merge origin/main"
243- if ! git merge --no-edit origin/main; then
244- echo " ✗ merge stopped on conflicts in NON-generated files — resolve those by hand" >&2
245- echo " (semantic merge, both intents stack), then rerun this script to redo the" >&2
246- echo " generated-artifact half. ⛔ Do not resolve generated files textually." >&2
243+ # Captured rather than left to inherit the terminal, so a conflict can be
244+ # classified below AND the driver's own notice (printed on stderr, for any
245+ # os-regen path it declined to defer) is still shown to the operator instead
246+ # of scrolling past unread.
247+ merge_log=" $( mktemp " ${TMPDIR:-/ tmp} /os-regen-merge-log.XXXXXX" ) "
248+ if ! git merge --no-edit origin/main > " $merge_log " 2>&1 ; then
249+ cat " $merge_log " >&2
250+ rm -f " $merge_log "
251+
252+ # Partition the conflicted set against the os-regen path list already read
253+ # above (:216-226) — reusing it as a `git diff` pathspec is the same trick
254+ # step 2 already relies on for `branch_edited`/`main_edited` (:239-240), so
255+ # this needs no glob-matching code of its own and no new inputs.
256+ #
257+ # Any conflict on a regen path is necessarily one the merge driver declined
258+ # to defer (see git-merge-regen.mjs: a non-`mixed` row always resolves with
259+ # exit 0, no markers — only a MIXED row whose deferral would be unsafe falls
260+ # through to a real text merge, which is what can conflict here). So a
261+ # regen-path conflict always means: hand-resolve the prose, never "take one
262+ # side" — the opposite of what a wholly-generated path would call for, and
263+ # the reason the blanket "do not resolve generated files textually" line
264+ # below is wrong, and suppressed, whenever a regen path shows up here.
265+ all_conflicts=()
266+ conflict_line=' '
267+ while IFS= read -r conflict_line; do
268+ if [[ -n " $conflict_line " ]]; then all_conflicts+=(" $conflict_line " ); fi
269+ done < <( git diff --name-only --diff-filter=U)
270+ regen_conflicts=()
271+ regen_conflict_line=' '
272+ while IFS= read -r regen_conflict_line; do
273+ if [[ -n " $regen_conflict_line " ]]; then regen_conflicts+=(" $regen_conflict_line " ); fi
274+ done < <( git diff --name-only --diff-filter=U -- " ${regen_paths[@]} " )
275+
276+ NL='
277+ '
278+ regen_set=" $NL "
279+ for c in " ${regen_conflicts[@]+" ${regen_conflicts[@]} " } " ; do regen_set=" $regen_set$c$NL " ; done
280+ non_regen_conflicts=()
281+ for c in " ${all_conflicts[@]+" ${all_conflicts[@]} " } " ; do
282+ case " $regen_set " in
283+ * " $NL$c$NL " * ) ;;
284+ * ) non_regen_conflicts+=(" $c " ) ;;
285+ esac
286+ done
287+
288+ if [ " ${# regen_conflicts[@]} " -eq 0 ]; then
289+ echo " ✗ merge stopped on conflicts in NON-generated files — resolve those by hand" >&2
290+ echo " (semantic merge, both intents stack), then rerun this script to redo the" >&2
291+ echo " generated-artifact half. ⛔ Do not resolve generated files textually." >&2
292+ elif [ " ${# non_regen_conflicts[@]} " -eq 0 ]; then
293+ echo " ✗ merge stopped on conflicts in GENERATED files the driver declined to defer" >&2
294+ echo " (MIXED — a generated half plus hand-written prose; see its notice above)." >&2
295+ echo " Hand-resolve the prose; the anchor numbers do not matter here — take" >&2
296+ echo " either side of them, then run the regeneration command the driver printed" >&2
297+ echo " above, then continue with step 4:" >&2
298+ printf ' %s\n' " ${regen_conflicts[@]} " >&2
299+ else
300+ echo " ✗ merge stopped on conflicts in BOTH non-generated and generated files:" >&2
301+ echo " non-generated (resolve by hand — semantic merge, both intents stack):" >&2
302+ printf ' %s\n' " ${non_regen_conflicts[@]} " >&2
303+ echo " generated, MIXED — the driver declined to defer these (see its notice" >&2
304+ echo " above). Hand-resolve the prose; the anchor numbers do not matter here —" >&2
305+ echo " take either side of them, then run the regeneration command the driver" >&2
306+ echo " printed above:" >&2
307+ printf ' %s\n' " ${regen_conflicts[@]} " >&2
308+ echo " Resolve both, then rerun this script to redo the generated-artifact half." >&2
309+ fi
247310 exit 1
248311 fi
312+ cat " $merge_log "
313+ rm -f " $merge_log "
249314
250315 echo " → step 2: taking origin/main's side of the generated artifacts main moved"
251316 # ⚠️ bash 3.2 expands `"${arr[@]}"` of an EMPTY array as an unbound variable
@@ -403,6 +468,96 @@ st_fixture() {
403468 git fetch -q origin main
404469}
405470
471+ # Build a fixture repo in $1 whose only conflict(s) are a REAL text-merge
472+ # conflict on a `merge=os-regen` path — the MIXED shape `git-merge-regen.mjs`
473+ # takes when a deferral would be unsafe (:14064 in the driver's own header),
474+ # not the clean silent-deferral shape `st_fixture` above exercises. $2, if
475+ # `both`, also gives the branch a conflicting NON-regen edit, for the "both
476+ # classes present" case.
477+ #
478+ # The driver here is a tiny fixture script, not `true`: it runs
479+ # `git merge-file` (a REAL 3-way text merge) and, on conflict, prints a
480+ # driver-shaped remedy naming a fixture regen command before exiting
481+ # non-zero — the same move the real driver makes for an unsafe MIXED row
482+ # (`git-merge-regen.mjs`'s `textMergeInPlace` + its `NOT deferred` notice).
483+ # Kept as a separate file (not inlined in .gitattributes config) so its exit
484+ # code — not `true`'s constant 0 — is what git sees for this path.
485+ st_fixture_regen_conflict () {
486+ fx=" $1 "
487+ both=" ${2:- } "
488+ rm -rf " $fx "
489+ mkdir -p " $fx "
490+
491+ cat > " $fx /driver.sh" << 'DRIVER '
492+ #!/usr/bin/env bash
493+ set -uo pipefail
494+ ancestor="$1"; ours="$2"; theirs="$3"; path="$4"
495+ git merge-file "$ours" "$ancestor" "$theirs"
496+ rc=$?
497+ if [ "$rc" -eq 0 ]; then
498+ exit 0
499+ fi
500+ {
501+ printf ' \xe2\x9a\xa0 %s\n' "$path"
502+ printf ' NOT deferred: the incoming side carries hand-written changes that no regeneration can restore.\n'
503+ printf ' This file is MIXED — a generated half plus hand-written prose — so keeping one\n'
504+ printf " side whole would delete the other side's prose with no conflict and no red gate.\n"
505+ printf ' Text-merged instead, and it CONFLICTS. Resolve the prose by hand; the anchor\n'
506+ printf ' numbers do not matter here — take either side and then run:\n'
507+ printf ' pnpm gen:fixture-mixed\n'
508+ printf ' which re-derives them from the merged tree.\n'
509+ } >&2
510+ exit "$rc"
511+ DRIVER
512+ chmod +x " $fx /driver.sh"
513+
514+ git init -q --bare -b main " $fx /origin.git"
515+ git clone -q " $fx /origin.git" " $fx /work" 2> /dev/null
516+ cd " $fx /work"
517+ git config user.email selftest@example.invalid
518+ git config user.name os-regen-merge-selftest
519+ git config commit.gpgsign false
520+ git config merge.os-regen.name ' os-regen (fixture: real text merge, conflicts on MIXED prose)'
521+ git config merge.os-regen.driver " bash $fx /driver.sh %O %A %B %P"
522+
523+ mkdir -p gen src
524+ printf ' gen/** merge=os-regen\n' > .gitattributes
525+ printf ' hand-written prose: original\n' > gen/mixed.txt
526+ printf ' source v1\n' > src/app.txt
527+ if [ " $both " = both ]; then
528+ printf ' prose v1\n' > src/prose.txt
529+ fi
530+ git add -A
531+ git commit -qm seed
532+ git push -q origin main
533+
534+ git checkout -q -b feature
535+ printf ' hand-written prose: BRANCH\n' > gen/mixed.txt
536+ if [ " $both " = both ]; then
537+ printf ' prose BRANCH\n' > src/prose.txt
538+ fi
539+ git add -A
540+ git commit -qm ' feature: hand-edit the MIXED prose'
541+
542+ git worktree add -q " $fx /mainwt" main
543+ (
544+ cd " $fx /mainwt"
545+ git config user.email selftest@example.invalid
546+ git config user.name os-regen-merge-selftest
547+ git config commit.gpgsign false
548+ printf ' hand-written prose: MAIN\n' > gen/mixed.txt
549+ if [ " $both " = both ]; then
550+ printf ' prose MAIN\n' > src/prose.txt
551+ fi
552+ git add -A
553+ git commit -qm ' main: also hand-edit the MIXED prose'
554+ git push -q origin main
555+ )
556+ cd " $fx /work"
557+ git worktree remove " $fx /mainwt"
558+ git fetch -q origin main
559+ }
560+
406561mode_self_test () {
407562 tmp=" $( mktemp -d " ${TMPDIR:-/ tmp} /os-regen-merge-selftest.XXXXXX" ) "
408563 trap ' rm -rf "$tmp"' EXIT INT TERM
@@ -509,6 +664,79 @@ mode_self_test() {
509664 " $( printf ' %s' " $out " | grep -c ' refusing to guess' || true) " 1
510665 cd " $here "
511666
667+ # --- 6. a MIXED-conflict on a regen path ONLY. The trap this closes: step 1
668+ # must not tell the operator these conflicts are "in NON-generated
669+ # files" (they are not), and must not tell them to leave the file
670+ # alone (⛔ "do not resolve generated files textually") — the driver
671+ # has just asked them to hand-resolve exactly this one.
672+ st_fixture_regen_conflict " $tmp /f"
673+ out=" $( bash " $SELF " 2>&1 ) " && rc=0 || rc=$?
674+ st_case ' a regen-only MIXED conflict fails the run' " $rc " 1
675+ st_case ' and does NOT call it a non-generated-file conflict' \
676+ " $( printf ' %s' " $out " | grep -c ' conflicts in NON-generated files' || true) " 0
677+ # THE ABSENCE ASSERTION THAT MATTERS (#14671): this exact line is correct
678+ # advice for a deferrable regen path and wrong, silently-destructive advice
679+ # for a MIXED one — see the driver's own notice, echoed a few lines above in
680+ # the same run's output, saying the opposite.
681+ st_case ' and does NOT forbid textual resolution of the generated file' \
682+ " $( printf ' %s' " $out " | grep -c ' Do not resolve generated files textually' || true) " 0
683+ st_case ' and DOES say the file needs hand-resolving' \
684+ " $( printf ' %s' " $out " | grep -c ' Hand-resolve the prose' || true) " 1
685+ st_case ' and names the conflicted regen path' \
686+ " $( printf ' %s' " $out " | grep -q ' gen/mixed.txt' && echo present || echo absent) " present
687+ st_case " and the driver's own notice is still shown" \
688+ " $( printf ' %s' " $out " | grep -c ' NOT deferred: the incoming side carries hand-written changes' || true) " 1
689+ st_case " and the driver's regeneration command is still shown" \
690+ " $( printf ' %s' " $out " | grep -c ' pnpm gen:fixture-mixed' || true) " 1
691+ cd " $here "
692+
693+ # --- 6b. THE DISCRIMINATING MUTATION. A self-test that can never fail is
694+ # worse than none: prove the absence assertion above actually
695+ # distinguishes the fixed script from the original bug by
696+ # reintroducing the old unconditional line and watching case 6 red.
697+ mutated=" $tmp /mutated-os-regen-merge.sh"
698+ # Literal (non-regex) replacement via perl's \Q..\E, keyed off the anchor
699+ # line so this stays robust to reflow — same dependency scripts/pm/ already
700+ # takes for something bash 3.2 cannot do (os-verify-lock.sh's Time::HiRes).
701+ MUT_ANCHOR=' echo "✗ merge stopped on conflicts in GENERATED files the driver declined to defer" >&2' \
702+ MUT_INSERT=' echo "✗ merge stopped on conflicts in NON-generated files — resolve those by hand" >&2
703+ echo " ⛔ Do not resolve generated files textually." >&2' \
704+ perl -0777 -pe ' s/\Q$ENV{MUT_ANCHOR}\E/$ENV{MUT_INSERT}/' " $SELF " > " $mutated "
705+ # Scanned only up to the `--- self-test` marker (the same trick the script's
706+ # own step-2-spelling pin uses above) — past that point the phrase also
707+ # appears inside THIS self-test's own assertion strings, which the mutation
708+ # never touches and which would otherwise inflate the count.
709+ st_case ' the mutation anchor was found and replaced (falsifiability check)' \
710+ " $( sed -n ' 1,/^# --- self-test/p' " $mutated " | grep -c ' Do not resolve generated files textually' || true) " 2
711+ st_case ' the mutation actually changed the script text' \
712+ " $( diff -q " $SELF " " $mutated " > /dev/null 2>&1 ; echo $? ) " 1
713+ st_case ' and the mutated script still parses' \
714+ " $( bash -n " $mutated " > /dev/null 2>&1 ; echo $? ) " 0
715+ st_fixture_regen_conflict " $tmp /f-mutated"
716+ mut_out=" $( bash " $mutated " 2>&1 ) " && mut_rc=0 || mut_rc=$?
717+ st_case ' mutated: the suppressed line is back (proves the assertion bites)' \
718+ " $( printf ' %s' " $mut_out " | grep -c ' Do not resolve generated files textually' || true) " 1
719+ cd " $here "
720+
721+ # --- 7. BOTH classes present: a non-regen conflict alongside the regen-path
722+ # MIXED conflict. Each file must be named under its own class, and
723+ # the suppressed line stays suppressed here too — some of the
724+ # generated conflicts in this run DO need hand-resolution, so the
725+ # blanket "do not resolve generated files textually" would be just as
726+ # wrong here as in the regen-only case.
727+ st_fixture_regen_conflict " $tmp /g" both
728+ out=" $( bash " $SELF " 2>&1 ) " && rc=0 || rc=$?
729+ st_case ' a mixed-classes conflict fails the run' " $rc " 1
730+ st_case ' and says BOTH classes are present' \
731+ " $( printf ' %s' " $out " | grep -c ' conflicts in BOTH non-generated and generated files' || true) " 1
732+ st_case ' and names the non-generated file' \
733+ " $( printf ' %s' " $out " | grep -q ' src/prose.txt' && echo present || echo absent) " present
734+ st_case ' and names the generated (regen) file' \
735+ " $( printf ' %s' " $out " | grep -q ' gen/mixed.txt' && echo present || echo absent) " present
736+ st_case ' and the suppressed line is absent here too' \
737+ " $( printf ' %s' " $out " | grep -c ' Do not resolve generated files textually' || true) " 0
738+ cd " $here "
739+
512740 if [ " $st_fail " -ne 0 ]; then
513741 printf ' ✗ os-regen-merge self-test: %d case(s) failed.\n' " $st_fail "
514742 return 1
0 commit comments