Skip to content

Commit 224f8ea

Browse files
claude[bot]claude
andauthored
fix(devx): partition os-regen-merge.sh's step-1 conflict message by class (#14733)
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. Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a17f3b commit 224f8ea

1 file changed

Lines changed: 232 additions & 4 deletions

File tree

scripts/pm/os-regen-merge.sh

Lines changed: 232 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
406561
mode_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

Comments
 (0)