Skip to content

Commit f82e7ad

Browse files
committed
Merge origin/main into claude/issue-14176-primary-datasource-pool-env
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
2 parents 415027e + 224f8ea commit f82e7ad

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)