@@ -213,11 +213,51 @@ mode_run() {
213213 # the form #12142 standardised; its own note attributes the trap to the empty
214214 # list, which measures clean — an all-empty read leaves the body unexecuted
215215 # and the `while` at status 0.)
216+ #
217+ # The READER admits only what git itself routes, and .gitattributes' grammar
218+ # was MEASURED for this rather than assumed (git 2.43.0, scratch repos):
219+ #
220+ # a leading-`#` line is a COMMENT, and leading whitespace does not undo
221+ # that — ` # foo bar` assigns nothing to a file named `#`, while the
222+ # escaped-pattern control `\# foo bar` on that same file assigns `foo` and
223+ # `bar`. So comment lines must be dropped BEFORE the field split.
224+ #
225+ # there is NO inline trailing comment. `x merge=os-regen # note` does not
226+ # route x with a note on the end; git rejects the LINE — `# is not a valid
227+ # attribute name: .gitattributes:1` on stderr, and `git check-attr merge --
228+ # x` then answers `unspecified`. Nothing to strip, because no such row can
229+ # ever be live. Pinned in the self-test against real git, so the day git
230+ # grows one this decision reddens instead of rotting.
231+ #
232+ # attributes are WHITESPACE-delimited tokens, so `merge=os-regenX` is a
233+ # different driver and not this one — hence the token anchor below rather
234+ # than a bare substring.
235+ #
236+ # The spelling this replaced was `grep 'merge=os-regen' .gitattributes | awk
237+ # '{print $1}'`, which also matched the header comment that quotes the
238+ # literal in prose and took ITS first field, so the list carried a pathspec
239+ # that was literally `#`. Harmless by luck — `#` matches no tracked path, so
240+ # both `git diff`s below ignored it — but the COUNT printed just below is the
241+ # operator's only check that this script is looking at the right surface, and
242+ # it was off by one in the one place the design deliberately keeps no second
243+ # copy to compare against. Measured at the repair on origin/main: 19 entries
244+ # produced, 1 of them literally `#`, 18 real patterns. It had already been
245+ # wrong at two different values (18 then 19) across an intervening edit, so
246+ # the impurity is in the construction, not in one snapshot of the file.
247+ #
248+ # `scripts/git-merge-regen.mjs`'s own reader (`reconcileAttributes`) already
249+ # skipped comment lines; this is that same rule, in awk.
250+ #
251+ # `[ \t]` rather than `[[:space:]]`: the bash 3.2 floor above is a macOS
252+ # floor, and that host's awk is not gawk.
216253 regen_paths=()
217254 regen_line=' '
218255 while IFS= read -r regen_line; do
219256 if [[ -n " $regen_line " ]]; then regen_paths+=(" $regen_line " ); fi
220- done < <( grep ' merge=os-regen' .gitattributes | awk ' {print $1}' )
257+ done < <( awk '
258+ /^[ \t]*#/ { next }
259+ /(^|[ \t])merge=os-regen([ \t]|$)/ { print $1 }
260+ ' .gitattributes)
221261 if [ " ${# regen_paths[@]} " -eq 0 ]; then
222262 echo " ✗ no merge=os-regen entries found in .gitattributes — refusing to guess" >&2
223263 exit 1
@@ -558,6 +598,32 @@ DRIVER
558598 git fetch -q origin main
559599}
560600
601+ # Build the standard fixture in $1, then replace its .gitattributes with one
602+ # shaped like the REAL file: prose that quotes the literal `merge=os-regen`,
603+ # and exactly THREE real rows. Leaves the cwd in the clone, on `feature`, with
604+ # the new routing committed.
605+ #
606+ # Two prose shapes, on purpose, because they fail the reader differently:
607+ # the header line quotes the literal in BACKTICKS — caught by the token
608+ # anchor even with comment-skipping off;
609+ # the indented line mentions it whitespace-delimited — caught ONLY by
610+ # comment-skipping, which is what makes it the discriminator for 8b.
611+ # The real .gitattributes carries the backticked shape (its line 36); the
612+ # indented one is the near neighbour that a future edit can add for free.
613+ st_fixture_list_bait () {
614+ st_fixture " $1 " > /dev/null 2>&1
615+ cat > .gitattributes << 'ATTRS '
616+ # Routed generated artifacts. `merge=os-regen` hands these to the driver.
617+ #
618+ # an indented comment that also mentions merge=os-regen in prose
619+ gen/** merge=os-regen
620+ docs/generated-guide.md merge=os-regen
621+ data/*.json merge=os-regen
622+ ATTRS
623+ git add -A
624+ git commit -qm ' route three paths, with prose that quotes the literal'
625+ }
626+
561627mode_self_test () {
562628 tmp=" $( mktemp -d " ${TMPDIR:-/ tmp} /os-regen-merge-selftest.XXXXXX" ) "
563629 trap ' rm -rf "$tmp"' EXIT INT TERM
@@ -737,6 +803,71 @@ mode_self_test() {
737803 " $( printf ' %s' " $out " | grep -c ' Do not resolve generated files textually' || true) " 0
738804 cd " $here "
739805
806+ # --- 8. THE LIST ITSELF. The printed pattern count is the operator's ONLY
807+ # check that this script is reading the right surface — the header
808+ # says so, and says why there is deliberately no second copy to check
809+ # it against. So it is pinned here, against a fixture .gitattributes
810+ # carrying the prose shape that used to enter the list as a pathspec
811+ # literally `#`.
812+ #
813+ # The assertion is an EXACT COUNT, never "no entry equals `#`": a
814+ # count also reds the day the read silently matches ZERO lines, which
815+ # an absence assertion passes with flying colours.
816+ st_fixture_list_bait " $tmp /h"
817+ out=" $( bash " $SELF " 2>&1 ) " && rc=0 || rc=$?
818+ st_case ' a run over the bait .gitattributes exits 0' " $rc " 0
819+ st_case ' the pattern count counts ROWS, not prose that quotes the literal' \
820+ " $( printf ' %s' " $out " | sed -n ' s/^→ os-regen paths (from .gitattributes, \([0-9]*\) patterns):$/\1/p' ) " 3
821+ st_case ' and no phantom # pathspec is listed' \
822+ " $( printf ' %s' " $out " | grep -c ' ^ #$' || true) " 0
823+ st_case ' and the three real rows all are' \
824+ " $( printf ' %s' " $out " | grep -cE ' ^ (gen/[*][*]|docs/generated-guide[.]md|data/[*][.]json)$' || true) " 3
825+ # The fixture really is bait: the pre-repair spelling over-counts it by two.
826+ st_case ' the pre-repair spelling over-counts the same file (fixture is bait)' \
827+ " $( grep ' merge=os-regen' .gitattributes | awk ' {print $1}' | wc -l | tr -d ' ' ) " 5
828+ cd " $here "
829+
830+ # --- 8a. THE GRAMMAR the reader was decided against, pinned against REAL git
831+ # rather than assumed. .gitattributes has no inline trailing comment:
832+ # git rejects the whole row and routes nothing. That measurement is
833+ # why the reader strips comment LINES only and does not try to trim a
834+ # trailing `#`. If git ever grows one, this reddens and the decision
835+ # gets revisited instead of rotting.
836+ st_fixture " $tmp /i" > /dev/null 2>&1
837+ printf ' inline.txt merge=os-regen # trailing note\n' > .gitattributes
838+ : > inline.txt
839+ st_case ' git does NOT admit an inline trailing comment — the row routes nothing' \
840+ " $( git check-attr merge -- inline.txt 2> /dev/null) " ' inline.txt: merge: unspecified'
841+ st_case ' and names # as the invalid attribute name' \
842+ " $( git check-attr merge -- inline.txt 2>&1 > /dev/null | grep -c ' is not a valid attribute name' || true) " 1
843+ # Firing control for the two absences above: the same row WITHOUT the trailing
844+ # comment does route. Without it, a git that stopped reading .gitattributes at
845+ # all would pass both cases above.
846+ printf ' inline.txt merge=os-regen\n' > .gitattributes
847+ st_case ' control: the same row without the note DOES route' \
848+ " $( git check-attr merge -- inline.txt 2> /dev/null) " ' inline.txt: merge: os-regen'
849+ cd " $here "
850+
851+ # --- 8b. THE DISCRIMINATING MUTATION for case 8. Disable the reader's
852+ # comment-line skip and watch the count come back one too high — the
853+ # phantom is exactly what that rule removes. Same perl/\Q..\E literal
854+ # replacement 6b uses, keyed off the awk rule rather than a message.
855+ mutated_list=" $tmp /mutated-list-os-regen-merge.sh"
856+ MUT_ANCHOR=' /^[ \t]*#/ { next }' \
857+ MUT_INSERT=' /^[ \t]*#ZZ-NEVER-MATCHES-ZZ/ { next }' \
858+ perl -0777 -pe ' s/\Q$ENV{MUT_ANCHOR}\E/$ENV{MUT_INSERT}/' " $SELF " > " $mutated_list "
859+ st_case ' the list mutation actually changed the script text' \
860+ " $( diff -q " $SELF " " $mutated_list " > /dev/null 2>&1 ; echo $? ) " 1
861+ st_case ' and the mutated script still parses' \
862+ " $( bash -n " $mutated_list " > /dev/null 2>&1 ; echo $? ) " 0
863+ st_fixture_list_bait " $tmp /h-mutated"
864+ mut_out=" $( bash " $mutated_list " 2>&1 ) " && mut_rc=0 || mut_rc=$?
865+ st_case ' mutated: the phantom # is back in the list (proves case 8 bites)' \
866+ " $( printf ' %s' " $mut_out " | grep -c ' ^ #$' || true) " 1
867+ st_case ' mutated: and the count is one too high' \
868+ " $( printf ' %s' " $mut_out " | sed -n ' s/^→ os-regen paths (from .gitattributes, \([0-9]*\) patterns):$/\1/p' ) " 4
869+ cd " $here "
870+
740871 if [ " $st_fail " -ne 0 ]; then
741872 printf ' ✗ os-regen-merge self-test: %d case(s) failed.\n' " $st_fail "
742873 return 1
0 commit comments