Skip to content

update-shape.py, family.py and bump-leg.py: the branch HEAD is on is read as the bytes git wrote, so a branch ending in U+00A0 is no longer read as its neighbour, and a name that is not UTF-8 is refused by name before anything is written (#765) - #849

Merged
brettheap merged 4 commits into
mainfrom
fix/765-branch-read-bytes
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-1 (openRepoShape-1)
Claim: #765 (comment)

A v1.1.1 draft. It lands only after the v1.1.0 tag, in the order lane 3 posts.

What

The three tools that read the branch HEAD is on now read it as the bytes git wrote, in one helper each:

Tool The one read Its callers
update-shape.py _symbolic_head current_branch (commit_on_branch, _awaits_its_pull_request, cmd_apply), and Rollback.new_branch, _undo_branch and _head_is
scripts/family.py _holder_branch _checked_branch (the real run of add, bump, remove) and _branch_said (their dry runs)
scripts/bump-leg.py current_branch _branch_to_bump_from (both runs)

Each reads git symbolic-ref -q HEAD as bytes, takes off exactly one trailing newline and exactly refs/heads/, and decodes the rest with surrogateescape. None of the three reads has a strip() or an errors="replace" left.

  • A branch ending in U+00A0, U+2028 or U+0085 reads as itself, and the push line names it exactly.
  • A branch whose name is not UTF-8 is refused by name, before anything is committed or written:
    • update-branch-not-utf8, family-branch-not-utf8 and bump-leg-branch-not-utf8, one per tool, under the prefix each tool already uses.
    • Such a name is one that still holds a surrogate after the decode.
    • The refusal names the branch by its bytes, b'caf\xe9', as trailer-not-utf8 names a trailer (b'Jos\xe9').
    • Its remedy ends with git -C <root> branch -m <a new name>, quoted for a POSIX shell in all three tools. git branch -m renames the branch HEAD is on, so the remedy never has to spell the old name: a pasted b'caf\xe9' would reach git as the text bcaf\xe9 (measured with sh).
    • No push line is printed for a name the tool cannot spell.
  • A rollback hands git back the branch it started on as git's own bytes. update-shape.py's Rollback._undo_branch is the one place a branch returned by one of these three reads goes back to git, and it now goes through _as_git_argument, as bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 does for bootstrap.py. Two rollbacks outside this PR hand git a branch they read for themselves: a member's in family.py (_member_head, then _head_back) and a leg's in bump-leg.py (leg_placement, then restore_leg). Both are under "Not in this PR".

Fix round 1 (after r765's review, #849 issuecomment-6242841859)

  • Finding 1, CI red. tests/test_readme_windows_quoting_sentence.py failed: _holder_branch's remedy called shell_line for the host, which README's sentence on the lines quoted for PowerShell does not name. The remedy is shell_line([...], "posix") now; the file gave 9 passed then (it holds 40 tests at the head, all passing).
  • Finding 2. Under LC_ALL=C PYTHONUTF8=0, a rollback from feat<U+00A0> raised UnicodeEncodeError part way and left the copies written. _as_git_argument fixes it, and a rollback from <U+65E5><U+672C>, which raised on main too, now exits 2 with every byte git holds as it was.
  • Finding 3. r765's test_a_rollback_puts_head_back_on_the_branch_it_started_on, in both locales.
  • Finding 4. held_by_git also holds every git directory under .git/modules (refs, object count, FETCH_HEAD with its write time), so the two cases' "before the fetch" is asserted. A bump-leg dry run on a bump branch changes the spec entry, and a family bump on a branch changes members/IRRS, so the snapshot sees a fetch.
  • Optional point, taken. The refusals name the branch by its bytes.

Merge of main (after r765's PR-READY at d98df568)

The branch conflicted with main, so main d6071c7d is merged into it. The new head is the merge commit da3fe1c8, whose parents are d98df568 and d6071c7d. Nothing was squashed or rebased.

The conflicts. All of them come from main's 5fc049b7 (#766, #814), which names the root and the holder in bump-leg.py's and family.py's refusals as shown_path names a path:

File Hunk Resolved
tests/test_bump_leg_branch_and_push_line.py the module docstring: #849's AND THE BRANCH'S BYTES (#765) paragraph against #766's THE ROOT ABOVE THEM (#766) both, #765's first; #766's now ends by saying that #765's bump-leg-branch-not-utf8, raised in the same check, names the root the same way
the conftest imports: #849's NOT_UTF_8_BRANCH_NAMED, held_by_git and on_a_branch_that_is_not_utf_8 against #766's second import line both
tests/test_family_tracking_branch.py the conftest imports, the same way both; #766's line also takes SHOWN_NAMES and named_as, for the new row below
  • Every test body on both sides merged without a conflict.
  • scripts/bump-leg.py, scripts/family.py and tests/conftest.py merged by themselves.
  • Main changed neither update-shape.py nor tests/test_update_shape_apply_lines_and_headers.py. Against main, the +/- lines of those two files and of tests/conftest.py are the ones they had against fc867207.

One change in meaning. The two not-UTF-8 refusals now name the path as shown_path names it.

Two new rows hold it, each over SHOWN_NAMES (an ESC, a line break then a forged line, and a printable name). Each asserts exit 2, an empty stdout and the refusal's whole detail line:

  • tests/test_bump_leg_branch_and_push_line.py: test_a_root_on_a_branch_that_is_not_utf8_is_named_safe_to_show[ESC], [line-break] and [printable];
  • tests/test_family_tracking_branch.py: test_a_holder_on_a_branch_that_is_not_utf8_is_named_safe_to_show[ESC], [line-break] and [printable], with remove.

With {root} put back in either tool, in a scratch clone of da3fe1c8, that tool's [ESC] and [line-break] rows fail (2 failed, 4 passed). [printable] passes, as #766's printable rows pass by design.

Revert rows re-run at da3fe1c8, each alone in a scratch clone, against the 12 cases in the table below and the 6 new rows (18 passed with nothing reverted):

  • F1, .strip() back in family's _holder_branch: 1 failed, the family no-break space.
  • B3, .strip() back in bump-leg's current_branch: 2 failed, the bump-leg no-break space beside a branch and beside the tracking branch.
  • U1, .strip() back in update-shape's _symbolic_head: 3 failed, the update-shape no-break space and the rollback in both locales.

Each matches the revert table.

r765's nit is taken too: the 103-character docstring line in tests/test_family_tracking_branch.py is rewrapped.

Gate at da3fe1c8, dirty 0. Each run used /usr/bin/python3 -m pytest -p no:cacheprovider, a stub gh first on PATH, GH_TOKEN=bogus-w765m, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_SSH_COMMAND=false. Each mode ran in six shards, with each file in a pytest run of its own. The 66 files:

  • every tests/test_family*.py (31 files), tests/test_bump_leg*.py (8) and tests/test_update_shape*.py (18), which take in the PR's three test files;
  • tests/test_agents_md_codes.py, test_readme_windows_quoting_sentence.py, test_git_output_decoding.py, test_non_utf8_git_reads.py, test_bootstrap_non_utf8_branch.py and test_no_auto_maintenance.py;
  • tests/test_shape_doctor.py, test_scaffold_leg_path_collisions.py and test_cli_reference.py.

Results:

  • git-filter-repo on PATH: 3,026 passed, 32 skipped, 3,058 collected. The skips:
    • 19 test_family_init_values.py cases for a template that quotes the value;
    • 4 reftable cases, since git 2.43 cannot make a reftable repository;
    • 3 PowerShell lines that only a Windows host prints, and 3 NTFS junction cases;
    • 2 test_no_auto_maintenance.py cases, because the session began with GIT_CONFIG_GLOBAL set, as the gate sets it;
    • 1 test that needs a disk that ignores case.
  • git-filter-repo hidden (PATH=<stub gh>:/usr/local/bin:/usr/bin:/bin, where command -v git-filter-repo finds nothing): 3,007 passed, 51 skipped, 3,058 collected. The 19 extra skips are the tests that need git-filter-repo: 14 in test_update_shape_merged_away.py, 4 in test_shape_doctor.py and 1 in test_update_shape_apply_lines_and_headers.py.

Every shard log names the head, dirty 0, and ends EXIT=0, and every file's run exited 0. The log's filter missed one summary line, 14 skipped for test_update_shape_merged_away.py with git-filter-repo hidden. That line comes from a re-run of the file alone at the same head, which exited 0.

Main moved on to 33419925 after the fetch, and its 75d70502 (#715) touches scripts/bump-leg.py and update-shape.py, in the tracking-branch reads, not the reads this PR changes. A local merge of da3fe1c8 with it, which was not pushed, is clean (Auto-merging scripts/bump-leg.py, Auto-merging update-shape.py; tree c34d4048). On that merge, the PR's three test files, test_readme_windows_quoting_sentence.py and test_agents_md_codes.py give 201 passed, each file EXIT=0.

Why (repro on main)

Measured on main fc867207, where #746, #810 and #813 have all landed, and then on this branch's head before the merge of main. Each run used local bare remotes, a stub gh first on PATH, GH_TOKEN=bogus-w765, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and git 2.43.0.

Line 1: the branch read strips Unicode whitespace

Each helper, in process, over a repository on feat plus one character, with a branch feat beside it:

HEAD on update-shape.py current_branch family.py _holder_branch bump-leg.py current_branch
feat<U+00A0> main: feat. Branch: feat<U+00A0> main: feat. Branch: feat<U+00A0> main and branch: feat<U+00A0> (#746)
feat<U+2028> main: feat. Branch: feat<U+2028> main: feat. Branch: feat<U+2028> main and branch: feat<U+2028>
feat<U+0085> main: feat. Branch: feat<U+0085> main: feat. Branch: feat<U+0085> main and branch: feat<U+0085>

End to end, with feat<U+00A0> checked out beside feat:

  • family.py remove.
    • On main: it committed on feat<U+00A0> and ended NEXT git -C <holder> push -u origin refs/heads/feat:refs/heads/feat. Pasted, that pushed feat at main's commit, not the commit the act made.
    • On the branch: NEXT git -C <holder> push -u origin 'refs/heads/feat<U+00A0>:refs/heads/feat<U+00A0>'. Pasted, origin's feat<U+00A0> is the act's commit, and feat is not pushed.
  • update-shape.py apply --branch feat --push.
    • On main: exit 0, committed on feat: ... and pushed feat to origin. But the commit was on feat<U+00A0>, and origin's feat was the commit before the re-pin.
    • On the branch: exit 2, REFUSED update-branch-exists: --branch feat already exists and is not the current branch ('feat\xa0' is checked out), rolled back, with nothing pushed. That is the posture commit_on_branch already documents for a --branch that exists and is not checked out.
  • update-shape.py's rollback, from feat<U+00A0> with a pre-commit hook that exits 1:
    • On main: ROLLED BACK: every byte ..., then switched HEAD back to feat, the neighbour.
    • On the branch: HEAD goes back on feat<U+00A0>, in the default locale and under LC_ALL=C PYTHONUTF8=0.
  • bump-leg.py has read the name whole since bump-leg.py: the branch is read in full and pushed as refs/heads/<b>:refs/heads/<b>, dirty names are spelled, committed lists what git holds, and the dry run says it runs no validator (#672) #746 (rstrip("\n")). On main and on the branch, its NEXT names feat<U+00A0>, and pasted, it pushes the bump. The new case here is the same read beside the tracking branch main: strip() read main<U+00A0> as main and refused bump-leg-on-tracking-branch.

Line 2: a branch whose name is not UTF-8

HEAD on caf<0xE9>:

  • bump-leg.py.
    • On main: exit 0, the bump committed, and NEXT git -C <root> push -u origin 'refs/heads/caf<U+FFFD>:refs/heads/caf<U+FFFD>'. Pasted: exit 1, error: src refspec refs/heads/caf<U+FFFD> does not match any, and nothing was sent.
    • On the branch: exit 2, REFUSED bump-leg-branch-not-utf8, nothing fetched, written or committed. The dry run refuses alike, as it refuses a detached root.
  • family.py remove.
    • On main: exit 0, committed on caf<0xE9>. The NEXT line held whatever the stream made of the lone surrogate. Under this machine's POSIX locale (stdout surrogateescape) that was the raw byte 0xE9, which a UTF-8 terminal shows, and copies, as U+FFFD. Under PYTHONIOENCODING=utf-8:strict it was the six characters \udce9, which pasted name another branch.
    • On the branch: exit 2, REFUSED family-branch-not-utf8, for add, bump and remove alike, before anything is fetched or written.
  • update-shape.py apply --branch shape/update-partial.
    • On main, with nothing failing: exit 0, and the new branch was committed and pushed. The branch HEAD started on is named nowhere in that run.
    • On main, where a rollback ran (a pre-commit hook that exits 1): ROLLED BACK IN PART, then HEAD is not back on caf<U+FFFD>: git -C <root> checkout 'caf<U+FFFD>'. The branch the run created was left behind, with a by-hand line that names caf<U+FFFD> too. HEAD could not go back, because no branch is called caf<U+FFFD>.
    • On the branch: exit 2, REFUSED update-branch-not-utf8, before the first write, so there is nothing to roll back.
  • After git branch -m cafe, each tool runs again as on any branch (measured with bump-leg's dry run: exit 0).

How

  • scripts/bump-leg.py, current_branch. The subprocess call drops text=True, encoding and errors. The newline and refs/heads/ come off the bytes, and the rest is decoded with surrogateescape. branch.encode("utf-8") raising is the test for a name that is not UTF-8, and the refusal names the bytes, and the root as shown_path names it (see "Merge of main"). _branch_to_bump_from, the one caller, asks it before the fetch in both runs.
  • scripts/family.py, _holder_branch. The same, through the _git_bytes call it already made, and its refusal names the holder as shown_path names it (see "Merge of main"). Its .strip() is gone. Its remedy's branch -m line is quoted for a POSIX shell (shell_line(..., "posix")), as bump-leg's and update-shape's are, so README's sentence on the lines quoted for PowerShell stays true. This is the only family.py hunk.
  • update-shape.py.
    • _symbolic_head asks git itself, as bytes, in place of _git_answer's text. It still answers None for no git, or for a git that refuses, as _git_answer did, so nothing raises inside a rollback. refs/heads/ is ASCII, so _branch_of taking it off the decoded text takes off exactly its bytes.
    • current_branch takes the branch from _symbolic_head, so the tool has one read, and refuses a name that is not UTF-8.
    • cmd_apply calls current_branch(root) in its if args.branch: block, beside _refuse_what_head_does_not_commit, so the refusal comes before the first write. This is the one hunk outside a branch-read helper. Without it, commit_on_branch refused only after the copies were written, and rolled them back (revert U3 below).
    • Rollback._undo_branch hands git the branch HEAD started on through _as_git_argument, so subprocess writes the name's own UTF-8 bytes under any locale, as bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 does for bootstrap.py. Under LC_ALL=C PYTHONUTF8=0, a rollback from feat<U+00A0> raised UnicodeEncodeError part way and left the copies written; one from <U+65E5><U+672C> did so on main too. The by-hand lines it prints keep the name as it is.
  • Where a read branch goes back to git. family.py hands the branch _holder_branch reads only to printed lines (_checked_branch's refusal, _branch_said, _print_next), and bump-leg.py hands its current_branch only to printed lines (_branch_to_bump_from's refusals, the dry run's hint, the NEXT line). Neither hands it to git, so neither needs _as_git_argument.
  • scripts/repo_shape.py is not changed.

Tests

The cases, each in the file where its tool's fixture already lives:

File Case New
tests/test_bump_leg_branch_and_push_line.py test_a_branch_ending_in_a_no_break_space_is_pushed_as_itself[beside-a-branch] no: #746's case, now one of two parameters
...[beside-the-tracking-branch]: main<U+00A0> beside main yes
test_a_branch_named_with_a_byte_that_is_not_utf8_is_refused_by_name[bump], [dry-run] yes
tests/test_family_tracking_branch.py test_a_branch_ending_in_a_no_break_space_is_pushed_as_itself: remove, its NEXT run as printed yes
test_a_branch_named_with_a_byte_that_is_not_utf8_is_refused_by_name[add], [bump], [remove] yes
tests/test_update_shape_apply_lines_and_headers.py test_a_branch_ending_in_a_no_break_space_is_not_taken_for_its_neighbour yes
test_a_branch_named_with_a_byte_that_is_not_utf8_is_refused_first yes
test_a_rollback_puts_head_back_on_the_branch_it_started_on[locale], [c-locale] (r765's) yes
  • Every case in the table that is not UTF-8 asserts exit 2 and the code. It asserts the name as the refusal spells it, b'caf\xe9', and the branch -m <a new name> remedy. It also asserts that HEAD, the ref HEAD is attached to, every ref, the index, the status and every git directory under .git/modules are as they were (held_by_git).
  • tests/conftest.py gains NOT_UTF_8_BRANCH (caf + byte 0xE9), NOT_UTF_8_BRANCH_NAMED (how a refusal names it), git_answer_bytes, held_by_git and on_a_branch_that_is_not_utf_8. The last skips where skip_where_a_name_cannot_be_not_utf_8 says the name cannot be made: Windows' os.fsencode, and a filesystem that answers EILSEQ.
  • own_remote is split out of tagged in the update-shape file, so the new cases push to a bare remote of their own.

Revert table. Each change was reverted alone in a scratch clone of the head before the merge of main, against the 12 cases above and tests/test_readme_windows_quoting_sentence.py (9 tests):

Reverted What fails
all three tools as main fc867207 has them 10 of the 12 cases. The two [beside-...] bump-leg cases pass, as #746 already fixed that read
U1. update-shape.py _symbolic_head: .strip() back on the decoded ref 3: update-shape no-break space, and the rollback in both locales
U2. update-shape.py current_branch: no refusal 1: update-shape not UTF-8
U3. update-shape.py cmd_apply: no early current_branch(root) 1: update-shape not UTF-8, refused only after the writes, ROLLED BACK
U4. update-shape.py _symbolic_head: errors="replace" for surrogateescape 1: update-shape not UTF-8
UN. update-shape.py: the name as {branch!r}, not its bytes 1: update-shape not UTF-8
R1. Rollback.new_branch: .strip() back 2: the rollback, in both locales
R2. _undo_branch: no _as_git_argument 1: the rollback under LC_ALL=C PYTHONUTF8=0
F1. family.py _holder_branch: .strip() back 1: family no-break space
F2. family.py _holder_branch: no refusal 3: family not UTF-8, add, bump, remove
FA. family.py: the remedy quoted for the host (no "posix") 1: test_readme_windows_quoting_sentence.py [scripts/family.py]
FN. family.py: the name as {branch!r} 3: family not UTF-8
B1. bump-leg.py current_branch: errors="replace" for surrogateescape 2: bump-leg not UTF-8, real and dry run
B2. bump-leg.py current_branch: no refusal 2: bump-leg not UTF-8
B3. bump-leg.py current_branch: .strip() back (the issue's mutant) 2: bump-leg no-break space, beside a branch and beside the tracking branch
BN. bump-leg.py: the name as {branch!r} 2: bump-leg not UTF-8

Taking exactly one newline off, where rstrip("\n") took every trailing one, cannot be told apart by any test: git refuses a newline in a ref name.

Runs. Head d98df568, before the merge of main (the gate at the merge is under "Merge of main"), /usr/bin/python3 -m pytest, a stub gh first on PATH, GH_TOKEN=bogus-w765 and an empty GH_CONFIG_DIR, in six shards per mode. The 62 files:

  • the PR's three test files, tests/test_shape_doctor.py, tests/test_scaffold_leg_path_collisions.py and tests/test_cli_reference.py;
  • every tests/test_family*.py (27 files), every tests/test_bump_leg*.py (8 files), and tests/test_non_utf8_git_reads.py, test_update_shape_partial_rollback.py, test_update_shape.py, test_update_shape_landing.py, test_update_shape_paste_lines.py, test_update_shape_next_quoting.py, test_next_refspec_branch_length.py, test_tracking_branch_ref.py, test_branch_left_bump_leg_setup.py and test_non_utf8_name_helpers.py;
  • the repository-wide guards that read the tools' own source: tests/test_readme_windows_quoting_sentence.py (the one the first gate missed), test_agents_md_codes.py, test_git_output_decoding.py, test_validator_child_encoding.py, test_update_shape_kept_helper.py, test_doctor_next_is_a_command.py, test_openreposhape_package.py, test_setup_project_py.py, test_ruleset_hint_tracking_branch.py, test_template_values_single_pass.py, test_pinned_copy_text.py, test_refusal_default_remediation.py and test_repo_hygiene.py.

Results:

  • git-filter-repo on PATH: 3,534 passed, 23 skipped, 1 xfailed, 3,558 collected. The skips: 19 test_family_init_values.py cases for a template that quotes the value, three PowerShell lines that only a Windows host prints, and one test that needs a disk that ignores case.
  • git-filter-repo hidden (PATH=<stub gh>:/usr/local/bin:/usr/bin:/bin): 3,523 passed, 34 skipped, 1 xfailed, 3,558 collected. The 11 extra skips are the tests that need git-filter-repo.

Every log names the head, dirty 0, and ends EXIT=0.

On main, too, before the merge. At d98df568, tests/test_bootstrap_non_utf8_branch.py (#824) was not in this branch. On a local merge of that head with main d8b8eb33 (clean: Auto-merging scripts/family.py), that file, main's tests/test_readme_windows_quoting_sentence.py, the PR's three test files, test_update_shape_partial_rollback.py, test_git_output_decoding.py and test_agents_md_codes.py gave 293 passed, EXIT=0. The branch now holds main, and the gate under "Merge of main" runs each of those files.

Windows

CI is Linux only (#597), and none of this was run on Windows. What follows is judged from reading, not measured.

  • A branch ending in U+00A0, U+2028 or U+0085. Git for Windows keeps a loose ref as a file and converts its UTF-8 name to UTF-16 for NTFS and back. NTFS takes all three in a name, and Win32 trims only a trailing space or dot. So such a branch is a branch there as on Linux, symbolic-ref prints its UTF-8 bytes, and the three reads keep it.
    • What a console then shows of the push line is the console's business. cp1252 holds U+00A0. Under a redirected cp1252 stdout, U+2028 and U+0085 cannot be written, so escape_what_stdout_cannot_encode writes them as \u2028 and \x85, and so does a code page without U+00A0 for it (\xa0). A pasted escaped name names another branch: the push fails src refspec ... does not match any and sends nothing. POSIX's C locale escapes them the same way.
    • That is already how every name outside the stream's encoding prints, and this PR does not change it.
    • family.py's NEXT line is in PowerShell single quotes there, which take each character literally.
  • A branch named with a byte that is not UTF-8. HEAD's own file holds ref: refs/heads/caf<0xE9> as bytes, so symbolic-ref prints the byte however the ref store keeps it, and the three tools refuse it as on Linux.
    • By r765's reading of Git for Windows' path conversion (xutftowcsn), a byte from 0xA0 to 0xFF that is not UTF-8 maps to U+00A0 to U+00FF, so the loose ref is the file caf<U+00E9>. git branch -m then renames the branch.
    • No command line can hand Git for Windows the byte (CreateProcessW carries UTF-16), so such a branch most likely arrives only in a repository copied from another system.
    • _as_git_argument hands the name over as it is on Windows, where git takes UTF-16.
    • The new cases skip on Windows, where os.fsencode makes no byte of the surrogate, and the rollback case is WINDOWS_SKIP.

Overlaps

Not in this PR

Known limits

  • update-shape.py now refuses runs that main completed. On main, apply --branch <new> from HEAD on caf<0xE9> committed and pushed the new branch, and one with nothing to do said nothing to do, exit 0, since neither names the branch HEAD started on unless a rollback runs. Every apply --branch run on such a branch is refused now, exit 2, as the coordinator decided for all three tools: refuse a name the tool cannot spell, before anything is written. apply without --branch names no branch and runs as on main. The way out is one git branch -m.
  • family.py's dry runs refuse late. They ask _holder_branch last, at their branch line, so a dry run on such a branch prints its plan and then refuses, exit 2 (bump --dry-run has already fetched in the member, as it always does). Their real runs refuse before anything is fetched or written. Keeping the hunk inside the branch read is why the dry run does not report it as a branch line, as it does the tracking branch. No test holds the dry run's refusal.
  • U+2028 and U+0085 are printed raw on a UTF-8 stream. A push line that names feat<U+2028> or feat<U+0085> names it exactly, inside single quotes, so the character reaches the terminal as it is, and a viewer that breaks lines there shows the line broken. That was already true of bump-leg's line since bump-leg.py: the branch is read in full and pushed as refs/heads/<b>:refs/heads/<b>, dirty names are spelled, committed lists what git holds, and the dry run says it runs no validator (#672) #746.

Closes #765

🤖 Generated with Claude Code

Summary by Sourcery

Read branch HEAD references as Git-written bytes and refuse branch names that cannot be represented as UTF-8 before modifying repository state.

New Features:

  • Preserve Git branch names accurately across update-shape, family, and bump-leg operations, including names containing Unicode whitespace.

Bug Fixes:

  • Reject non-UTF-8 branch names before any fetch, write, commit, or push, and provide a safe rename remedy.
  • Restore rollback checkouts using the exact branch bytes across locales.

Enhancements:

  • Report invalid branch names by their original bytes and safely format affected repository paths.
  • Expand regression coverage for Unicode branch names, invalid UTF-8 names, rollback behavior, and refusal-state preservation.

Tests:

  • Add end-to-end tests covering branch preservation, invalid-name refusals, safe path rendering, and locale-independent rollback behavior.

…read as the bytes git wrote, so a branch ending in U+00A0 is no longer read as its neighbour, and one whose name is not UTF-8 is refused by name before anything is written, where a push line named a branch that is not there (#765)

Each of the three tools reads `git symbolic-ref -q HEAD` as bytes in one helper: exactly one newline and exactly `refs/heads/` come off, and the rest is decoded with `surrogateescape`. No `strip()`, no `errors="replace"`.

- update-shape.py: `_symbolic_head` is the one read; `current_branch` takes the branch from it, and `Rollback` reads HEAD with it too. `current_branch` refuses `update-branch-not-utf8`, and `cmd_apply` asks it before the first write of every `--branch` run.
- scripts/family.py: `_holder_branch` refuses `family-branch-not-utf8`; the real run of `add`, `bump` and `remove` asks it before the member is read.
- scripts/bump-leg.py: `current_branch` refuses `bump-leg-branch-not-utf8`, the dry run alike, before the fetch.

Each refusal names the branch as Python spells it and ends with `git -C <root> branch -m <a new name>`. No push line is printed for a name the tool cannot spell.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR makes all three tools derive the current branch from Git’s raw symbolic-ref bytes, preserving branch names exactly and refusing names containing undecodable UTF-8 before they can produce invalid push or rollback commands; it also adds early update-shape validation and comprehensive byte-level regression tests.

Sequence diagram for byte-preserving branch validation

sequenceDiagram
    participant Tool
    participant Git
    participant Validator
    participant Workspace

    Tool->>Git: symbolic-ref --quiet HEAD
    Git-->>Tool: raw ref bytes
    Tool->>Validator: decode with surrogateescape
    Validator->>Validator: branch.encode(utf-8)
    alt branch name is valid UTF-8
        Validator-->>Tool: current branch
        Tool->>Workspace: fetch, write, commit, or print push line
    else branch name is not UTF-8
        Validator-->>Tool: Refusal
        Tool-->>Workspace: no fetch, write, or commit
    end
Loading

File-Level Changes

Change Details Files
Read symbolic branch refs byte-for-byte and reject branch names that cannot be represented as UTF-8.
  • Replace text-mode symbolic-ref reads and whitespace stripping with byte reads that remove only Git’s trailing newline and the ASCII refs/heads/ prefix.
  • Decode branch bytes with surrogateescape so Unicode whitespace and other valid branch-name characters are preserved exactly.
  • Raise tool-specific refusals for surrogate-containing names, including a git branch-m rename remediation.
scripts/bump-leg.py
scripts/family.py
update-shape.py
Ensure update-shape validates the checked-out branch before any apply writes occur.
  • Validate the current branch at the start of --branch apply runs, preventing partial writes and rollback failures for unspellable names.
  • Preserve rollback-safe behavior when symbolic-ref cannot be read or Git reports a detached/refused HEAD.
update-shape.py
Add regression coverage for exact branch preservation and non-UTF-8 refusal behavior.
  • Test branches ending in U+00A0 and related whitespace characters through push and branch-selection paths.
  • Test refusal of non-UTF-8 branch names in real and dry-run modes, including exit codes, messages, remediation, and no-mutation guarantees.
  • Add shared fixtures for constructing non-UTF-8 branches and byte-level repository state snapshots; isolate update-shape remotes.
tests/conftest.py
tests/test_bump_leg_branch_and_push_line.py
tests/test_family_tracking_branch.py
tests/test_update_shape_apply_lines_and_headers.py

Assessment against linked issues

Issue Objective Addressed Explanation
#765 Read the current branch name in update-shape.py, family.py, and bump-leg.py without stripping Unicode whitespace or otherwise changing the branch’s bytes. ✅
#765 Refuse branch names that are not UTF-8, identifying the branch by its Python spelling and doing so before a commit or write can produce a push or recovery line naming a different branch. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T23:07:15Z) Lane 1's independent review of #849 at 25692a3: CHANGES NEEDED

Read-only review by r765 (lane openRepoShape-1), against main 677f9bfa.

  • The merge: the local-only merge of 25692a37 with that main is clean (Auto-merging scripts/family.py, tree 553730f7).
  • Probes: a stub gh first on PATH, GH_TOKEN=bogus-r765, an empty GH_CONFIG_DIR and GIT_CONFIG_GLOBAL=/dev/null, on git 2.43.0 and Python 3.12.3.

Findings, ranked

1. BLOCKING: CI tests is red at the head, because _holder_branch now quotes for the host and README's sentence does not name it

Run 39063562770, job tests: 1 failed, 12262 passed, 145 skipped, 1 xfailed.

FAILED tests/test_readme_windows_quoting_sentence.py::test_the_functions_that_quote_for_the_host_are_the_ones_the_sentence_names[scripts/family.py]
AssertionError: scripts/family.py's functions that call a host-dialect quoter are ['_add_command', '_checked_mount_path', '_holder_branch', '_print_next', '_print_next_steps', '_switch_command'], and the sentence names ['_add_command', '_checked_mount_path', '_print_next', '_print_next_steps', '_switch_command']
  • Cause: the family-branch-not-utf8 remedy uses shell_line([...]), which quotes for PowerShell on Windows. README's "Windows, natively" says only family.py's NEXT, switch -c, add and git rm -r --cached lines are quoted that way.
  • Why the writer missed it: the writer's 28-file gate did not include this file.
  • Repro: pytest tests/test_readme_windows_quoting_sentence.py
    • main: 40 passed;
    • head: 1 failed, 8 passed;
    • merge: 1 failed, 39 passed. Main's newer inventory counts uses and reports {'_holder_branch': 1, ...}.

Fix A (recommended). One line and no README change. All three tools then quote the branch -m remedy for a POSIX shell, as bump-leg's shlex.join and update-shape's _by_hand already do.

-            + shell_line(["git", "-C", root, "branch", "-m"])
+            + shell_line(["git", "-C", root, "branch", "-m"], "posix")

On the merge, the quoting test and test_family_tracking_branch.py then give 61 passed.

Fix B. Keep host quoting. Add branch -m to the sentence in README and in the handbook, and add "_holder_branch": 1 to NAMED. That waits for lane 3's README turn.

2. SHOULD FIX: under LC_ALL=C PYTHONUTF8=0, update-shape's rollback from feat<U+00A0> now raises part way through

Cause.

Repro. HEAD on feat<U+00A0> beside feat, with a .git/hooks/pre-commit that exits 1, then LC_ALL=C PYTHONUTF8=0 update-shape.py apply ... --branch shape/update-partial.

  • main: exit 2, every byte put back, status clean, but HEAD on feat.
  • merge: exit 1, UnicodeEncodeError: 'ascii' codec can't encode character '\xa0' in position 4 from _git_quietly.
    • HEAD is left on refs/heads/shape/update-partial, and that branch is left.
    • M AGENTS-shape.md, M contracts/shape-pin.yaml, M project.yaml and M scripts/validate-manifest.py are not put back.
  • A branch <U+65E5><U+672C> raises the same way on main and on the merge, so that case was already there.

_git_quietly's own contract says: "Never raises ... a rollback that raised part way would leave the tree half put back".

The fix:

def _as_git_argument(name: str) -> str:
    """`name` as `_symbolic_head` decoded it, as the argument that hands
    git the same bytes back under any locale (#710's rule). Windows hands
    git UTF-16, so the name goes as it is there."""
    if os.name == "nt":
        return name
    return os.fsdecode(name.encode("utf-8", "surrogateescape"))

In _undo_branch, the call becomes _git_quietly(["git", "checkout", "-q", _as_git_argument(start), "--"] if start else ...). The printed _by_hand lines keep start as it is.

Verified on the merge.

  • Both names exit 2, with HEAD and status as they were, in both locales.
  • test_update_shape_partial_rollback.py, test_update_shape_apply_lines_and_headers.py, test_git_output_decoding.py and test_update_shape_kept_helper.py give 237 passed.

If the coordinator rules this out of scope, it needs Known limit text 4 below.

3. TEST GAP: no test holds the Rollback caller of _symbolic_head, which this PR fixes

Same repro as finding 2, in the default locale:

  • main: ROLLED BACK: every byte this command wrote has been put back, and the tree is exactly as it was: then switched HEAD back to feat. HEAD went from refs/heads/feat\xc2\xa0 to refs/heads/feat, the neighbour.
  • merge: switched HEAD back to feat\xa0, with HEAD's bytes unchanged. U+2028 behaves the same.

Mutant R1 changes Rollback.new_branch to start = _branch_of((_symbolic_head(self.root) or "").strip()). With it, test_update_shape_apply_lines_and_headers.py and test_update_shape_partial_rollback.py give 62 passed: no test fails.

This case holds findings 2 and 3. It belongs in tests/test_update_shape_apply_lines_and_headers.py, which already imports every name it uses:

@WINDOWS_SKIP
@pytest.mark.parametrize("env", [{}, {"LC_ALL": "C", "PYTHONUTF8": "0"}],
                         ids=["locale", "c-locale"])
def test_a_rollback_puts_head_back_on_the_branch_it_started_on(
        standard, root, env):
    git("branch", "feat", cwd=root)
    git("switch", "-q", "-c", "feat" + chr(0xA0), cwd=root)
    hook = root / ".git" / "hooks" / "pre-commit"
    hook.parent.mkdir(exist_ok=True)
    hook.write_bytes(b"#!/bin/sh\nexit 1\n")
    hook.chmod(0o755)
    before = held_by_git(root)

    result = apply(standard, root, "b", "--branch", BRANCH, env=env)

    assert result.returncode == 2, result.stdout + result.stderr
    assert held_by_git(root) == before

Measured:

  • on the merge as it is, [c-locale] fails;
  • with Fix 2: 2 passed;
  • with Fix 2 and R1: 2 failed.

4. MINOR: two docstrings promise more than their asserts hold

  • bump-leg's new case says "before the fetch", and family's says "before the member is read".
  • But held_by_git snapshots only the root or the holder. A fetch lands under .git/modules/, which neither snapshot shows.
  • The code order is right.
  • Either snapshot the leg's or member's refs too, or drop those words.

The coordinator's three rulings

  1. The early current_branch(root) in cmd_apply stays.
    • It is present at update-shape.py:4612, and How states it ("the one hunk outside a branch-read helper").
    • Row U3, re-run, fails as claimed.
  2. update-shape refusing a run that main completed. Known limits states it, but incompletely: a --branch run with nothing to do is refused too.
    • main: exit 0, nothing to do: every copied file matches ...
    • merge: exit 2, REFUSED update-branch-not-utf8
    • apply without --branch: exit 0 on both.
    • Text 3 below covers it.
  3. Family's dry runs refuse late. Known limits states this, and it is true, so no change is needed. On the merge:
    • add, bump and remove --dry-run printed 20, 15 and 5 stdout lines;
    • none printed the closing --dry-run: nothing was changed paragraph;
    • each then printed REFUSED family-branch-not-utf8 and exited 2.

Attack points

Line 1

Each of U+00A0, U+2028 and U+0085 beside feat, with each NEXT line run as printed:

Line 2

  • caf<0xE9>: the six new cases hold it.
  • git branch -m cafe renames such a branch.

Every caller

  • update-shape current_branch: commit_on_branch, cmd_apply and _awaits_its_pull_request.
  • update-shape _symbolic_head: current_branch, Rollback.new_branch, _undo_branch and _head_is.
  • family _holder_branch: _checked_branch and _branch_said.
  • bump-leg current_branch: _branch_to_bump_from.
  • Nothing else imports them. Two callers are untested: Rollback (finding 3) and the family dry-run refusal.

Ordinary inputs

  • feat, shape/update-x, v1.0.0, a+b, <U+65E5><U+672C>, release/1.2 and a detached HEAD give byte-identical answers from all four reads on main and on the merge.
  • There is no new network call.

#824 (0dee22b) does not refuse a non-UTF-8 branch

#795 (6f64eea)

The merge is clean, and the 28 family test files pass on it.

AGENTS.md and docs/cli.md

  • No test requires the new codes in either.
  • test_agents_md_codes.py holds AGENTS.md's codes to the tools, not the reverse.
  • AGENTS.md names neither family-holder-detached, bump-leg-root-detached nor update-branch-exists.
  • --help is unchanged, and test_cli_reference.py passes.

CI

  • SonarCloud: 0 new issues, 0.0% duplication.
  • tests-git-bash and package are green.

Gate: local merge 8210508c, dirty 0, git-filter-repo on PATH

Files:

  • the PR's three test files;
  • test_shape_doctor.py, test_scaffold_leg_path_collisions.py and test_cli_reference.py;
  • all 28 test_family*.py;
  • test_agents_md_codes.py, test_git_output_decoding.py, test_non_utf8_git_reads.py, test_update_shape_partial_rollback.py and test_bootstrap_non_utf8_branch.py.

Result: 1,985 passed, 22 skipped, 1 xfailed, in six shards, each EXIT=0. Run separately, test_readme_windows_quoting_sentence.py has 1 failure (finding 1).

Revert row, at the head Result
U3 1 failed: ..._not_utf8_is_refused_first
F1 1 failed: family ..._no_break_space_is_pushed_as_itself
B1 2 failed: bump-leg [bump], [dry-run]
R1 (new) 62 passed (finding 3)

Windows

U+00A0, U+2028 and U+0085.

  • NTFS takes them, and Win32 trims only a trailing space or dot, so the loose ref works and the three reads keep it.
  • Family's NEXT is in PowerShell single quotes, which take the character literally.
  • A redirected cp1252 stdout cannot hold U+2028 or U+0085. escape_what_stdout_cannot_encode then writes \u2028, which, pasted, names another branch: the push fails src refspec ... does not match any and sends nothing. POSIX's C locale does the same.
  • The body says this only of U+00A0.

The byte 0xE9 (read, not run).

  • Git for Windows' path conversion (xutftowcsn) maps invalid bytes 0xA0-0xFF to U+00A0-U+00FF, so the loose ref is the file caf<U+00E9>.
  • HEAD's file keeps the raw 0xE9, which symbolic-ref prints.
  • So the tools refuse as on Linux, and branch -m renames the branch.
  • The new cases skip on Windows. Their names are NTFS-legal, and no test counts text-mode bytes.

Exact text replacements

  1. How, family bullet (with Fix A). Replace Its .strip() is gone. This is the only family.py hunk. with:

    Its .strip() is gone. Its remedy's branch -m line is quoted for a POSIX shell (shell_line(..., "posix")), as bump-leg's and update-shape's are, so README's sentence on the lines quoted for PowerShell stays true. This is the only family.py hunk.

  2. How, update-shape (with Fix 2). Add:
  3. Known limits, bullet 1. Replace with:
    • update-shape.py now refuses runs that main completed. On main, apply --branch <new> from HEAD on caf<0xE9> committed and pushed the new branch, and one with nothing to do said nothing to do, exit 0, since neither names the branch HEAD started on unless a rollback runs. Every apply --branch run on such a branch is refused now, exit 2, as the coordinator decided for all three tools: refuse a name the tool cannot spell, before anything is written. apply without --branch names no branch and runs as on main. The way out is one git branch -m.
  4. Known limits (only if Fix 2 is not taken). Add:
    • Under LC_ALL=C PYTHONUTF8=0, update-shape.py's rollback raises on a branch name that is not ASCII, which it hands back to git as UTF-8 text. From feat<U+00A0> it now raises UnicodeEncodeError part way, leaving the new branch and the copies; main read the name as feat and put HEAD back there. <U+65E5><U+672C> raised on main too.
  5. Overlaps, bullet 1. Replace with:
  6. Not in this PR, bullet 1. Replace with:
  7. Tests.
    • Add tests/test_readme_windows_quoting_sentence.py to the guards.
    • Add the rollback case to the case table, and R1 to the revert table.
    • Refresh both modes' counts.

For the checklist

  • family _member_head reads a member's HEAD for PinRollback through git_out's strict decode and strip(). Read from the source, not measured:
    • a member on feat<U+00A0> beside feat at the same commit is put back on feat;
    • a member on a non-UTF-8 branch raises UnicodeDecodeError as PinRollback starts, since only Refusal is caught.
  • bump-leg restore_leg passes the leg's branch through checked_value (SAFE_ARG_RE, ASCII only), so a leg on <U+65E5><U+672C> is refused unsafe-value inside the rollback. That is besides leg_placement's errors="replace" and strip(). Read from the source.
  • update-shape under LC_ALL=C PYTHONUTF8=0: any non-ASCII branch reaches finding 2's raise, on main too.
  • No test holds the family dry run's late refusal (_branch_said).

Lane 1 takes a fix round on the same branch now: Fix A for finding 1, Fix 2 with the rollback test for findings 2 and 3, and the text replacements. The same reviewer re-verifies.

Lane: openreposhape-1 (openRepoShape-1)

…branch it started on as git's own bytes, the `branch -m` remedy is quoted for a POSIX shell, and a branch that is not UTF-8 is named by its bytes, so the rollback no longer raises under a C locale and README's quoting sentence holds (#765)

Fix round 1 for #849, after r765's review (issuecomment-6242841859) and the coordinator's rulings.

- scripts/family.py: `_holder_branch`'s `branch -m` remedy is `shell_line([...], "posix")`, so `tests/test_readme_windows_quoting_sentence.py` passes again (finding 1, Fix A).
- update-shape.py: `_as_git_argument` spells a name read from git as the argument that hands git the same bytes under any locale, as #824 does for bootstrap.py; `Rollback._undo_branch`'s checkout uses it. Under `LC_ALL=C PYTHONUTF8=0` a rollback from `feat<U+00A0>` raised `UnicodeEncodeError` part way (finding 2).
- tests: r765's `test_a_rollback_puts_head_back_on_the_branch_it_started_on`, in both locales (finding 3); `held_by_git` also holds each `.git/modules` git directory's refs, objects and FETCH_HEAD, so "before the fetch" is asserted (finding 4).
- The three `*-branch-not-utf8` refusals name the branch by its bytes, `b'caf<0xE9>'`, as `trailer-not-utf8` names a trailer.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-18T00:48:37Z) Lane 1's re-verification of #849 at d98df56: PR-READY

Read-only re-verification by r765 (lane openRepoShape-1) of w765's fix round 1 (25692a37..d98df568). The local-only merge with main 112eeed2 is clean (Auto-merging scripts/family.py, Auto-merging tests/conftest.py; merge 1e6fecb6, tree 78e8ec83). Probes ran as before: a stub gh, GH_TOKEN=bogus-r765, an empty GH_CONFIG_DIR and GIT_CONFIG_GLOBAL=/dev/null. No code change is needed. It is PR-READY once the two false body sentences under "Texts" are corrected.

The four findings

  1. CI red: fixed (Fix A). _holder_branch's remedy is now shell_line([...], "posix").
    • test_readme_windows_quoting_sentence.py on the merge: 40 passed. Before the fix it was 1 failed, 39 passed.
    • CI at d98df568:
      • tests: pass, 12896 passed, 145 skipped, 2 xfailed.
      • SonarCloud: Quality Gate passed, 0 new issues, 0.0% duplication.
      • package and tests-git-bash: pass.
  2. C-locale rollback: fixed (Fix 2). Run on the merge under LC_ALL=C PYTHONUTF8=0, with a pre-commit hook that exits 1, as apply ... --branch shape/update-partial:
    • From feat<U+00A0>:
      • exit 2;
      • HEAD is refs/heads/feat\xc2\xa0 before and after, and held_by_git is equal;
      • the rollback prints switched HEAD back to feat\xa0;
      • before the fix it was exit 1 with UnicodeEncodeError, and the copies were left written.
    • From <U+65E5><U+672C>:
      • exit 2;
      • HEAD is refs/heads/\xe6\x97\xa5\xe6\x9c\xac before and after, and held_by_git is equal;
      • on main it was exit 1 with a traceback.
  3. Rollback test gap: closed. test_a_rollback_puts_head_back_on_the_branch_it_started_on[locale] and [c-locale] are in. Rows R1, R2 and U1 fail them (below).
  4. Snapshots: they now hold what the docstrings say. For each git directory under .git/modules, held_by_git now adds its refs, count-objects -v, and FETCH_HEAD's bytes and write time.
    • Measured on the merge, on an ordinary branch feat:
      • bump-leg --dry-run: keys attached, code, head, index, refs, spec, status; changed ['spec'];
      • family bump --dry-run: changed ['members/IRRS'].
    • So a refusal moved after the fetch would fail both cases.
    • Family's claim that "add prints not even the line that says the member is being read" holds by stdout == "": checking ... before mounting it is printed and flushed before _probe_member.

The bytes form

  • Consistency with trailer-not-utf8:
    • All three refusals now read ... is on the branch b'caf\xe9', whose name is not UTF-8 .... That is the same b'...' form as trailer-not-utf8 (b'Jos\xe9').
    • The bytes come from raw in bump-leg and family, and from branch.encode("utf-8", "surrogateescape") in update-shape. Both are the exact inverse of the decode.
    • trailer-not-utf8 uses os.fsencode, which gives the same bytes on POSIX.
  • The remedy, pasted, still works in all three:
    • each tool's remedy line was run through sh -c, with <a new name> as cafe: rc 0, and HEAD is refs/heads/cafe;
    • bump-leg --dry-run and family remove --dry-run then exit 0.

The writer's claim: only _undo_branch hands a branch it read back to git

This is true of the branches that this PR's three reads return.

  • What update-shape does with the answers of current_branch and _symbolic_head:
    • compares them (commit_on_branch, _awaits_its_pull_request, _head_is);
    • stores them (new_branch);
    • prints them (_by_hand, the refusals);
    • checks them out in _undo_branch, now through _as_git_argument.
  • update-shape's other ref read, for-each-ref --points-at HEAD in the landing failure, is only compared.
  • The answers of family's _holder_branch and bump-leg's current_branch are only printed.

It is not true of the tools as a whole, which is how the body's What section states it. Two rollbacks hand git a branch they read themselves:

  • scripts/family.py: _member_head reads through git_out (strict UTF-8 and strip()). The branch goes to PinRollback, then _head_back, then _offline_git(["checkout", "-q", *_head_target(...)]), which runs checkout <branch> -- in the member.
  • scripts/bump-leg.py: leg_placement reads with errors="replace" and strip(). The branch goes to restore_leg, which runs run(["git", "checkout", "-q", branch]) in the leg, after checked_value, which takes ASCII only.

Both read a member's or a leg's HEAD, not the root's, so both are outside #765. Both are already under "Not in this PR" and in the checklist. Only the What sentence needs fixing (text 1). I read this from the source and did not measure it.

Revert rows re-run at d98df56

Each change was reverted alone in a scratch clone.

Row Result
FA: host quoting back 1 failed: the quoting test, [scripts/family.py]
R2: no _as_git_argument 1 failed: rollback [c-locale]
U1: _symbolic_head .strip() back 3 failed: update-shape no-break space, rollback [locale] and [c-locale]
BN: bump-leg {branch!r} back 2 failed: bump-leg not UTF-8, [bump] and [dry-run]

With nothing reverted, the same selection gives 30 passed. All four rows match w765's table.

Gate: merge 1e6fecb6, dirty 0, git-filter-repo on PATH

The files:

  • the PR's three test files;
  • test_shape_doctor.py, test_scaffold_leg_path_collisions.py and test_cli_reference.py;
  • all 29 test_family*.py;
  • test_agents_md_codes.py, test_git_output_decoding.py, test_non_utf8_git_reads.py, test_update_shape_partial_rollback.py, test_bootstrap_non_utf8_branch.py and test_readme_windows_quoting_sentence.py;
  • w765's guards: test_validator_child_encoding.py, test_update_shape_kept_helper.py, test_doctor_next_is_a_command.py, test_openreposhape_package.py, test_setup_project_py.py, test_ruleset_hint_tracking_branch.py, test_template_values_single_pass.py, test_pinned_copy_text.py, test_refusal_default_remediation.py and test_repo_hygiene.py.

Result: 2,999 passed, 26 skipped, 1 xfailed, in seven shards, each EXIT=0.

Texts

The body:

  • is 21,095 characters by len();
  • has the Lane, Claim and v1.1.1 lines, Closes #765 and the robot line;
  • holds no non-ASCII character except the robot face.

Every other sentence I checked is true at the head, including the Known limits for the rulings, the revert table, and the Why and Windows sections. Two sentences are false:

  1. What, last bullet. Replace

    update-shape.py's Rollback._undo_branch is the one place any of the three tools hands a branch it READ back to git, and it now goes through _as_git_argument, as bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 does for bootstrap.py.

    with

    update-shape.py's Rollback._undo_branch is the one place a branch returned by one of these three reads goes back to git, and it now goes through _as_git_argument, as bootstrap.py: step (a) decodes git's output as the system decodes a name it is given, so a branch whose upstream is not UTF-8 is moved back, and a good branch this system's encoding cannot write gets a line of its own, not a traceback (#710) #824 does for bootstrap.py. Two rollbacks outside this PR hand git a branch they read for themselves: a member's in family.py (_member_head, then _head_back) and a leg's in bump-leg.py (leg_placement, then restore_leg). Both are under "Not in this PR".

  2. Overlaps, bullet 3. Replace gains four helpers and two constants with gains three helpers and two constants.

    • The helpers are git_answer_bytes, held_by_git and on_a_branch_that_is_not_utf_8.
    • The constants are NOT_UTF_8_BRANCH and NOT_UTF_8_BRANCH_NAMED.
  3. Optional, Overlaps bullet 1. merges with main d8b8eb33 without a conflict is still true. The merge with main 112eeed2 is clean too (measured), if you want the newer sha.

A nit that does not block: one docstring line in tests/test_family_tracking_branch.py is 103 characters long (escape, which name another branch. Refused by name now, by its bytes, before anything is fetched or). Rewrap it the next time the file is touched.

For the checklist

  • family _member_head -> _head_back. Besides the strip() and the strict decode, a member on a non-ASCII UTF-8 branch is handed back to git as text under LC_ALL=C PYTHONUTF8=0. That is the class Fix 2 closed in update-shape. Read from the source.
  • bump-leg restore_leg. checked_value takes ASCII only, so a non-ASCII leg branch is refused unsafe-value inside the rollback.
  • No test holds the family dry run's late refusal (_branch_said).

Lane 1 applies texts 1 and 2 to the body now, and names the newer main. The docstring nit is non-blocking. #849 joins lane 1's landing turns.

Lane: openreposhape-1 (openRepoShape-1)

This was referenced Oct 18, 2026
…kept, and the two `*-branch-not-utf8` refusals name the root as `shown_path` names it, as #766 (5fc049b) names it in the refusals beside them (#765)

The conflicts were in two test files, from main's 5fc049b (#766, #814):

- tests/test_bump_leg_branch_and_push_line.py, the module docstring: both
  paragraphs kept, #765's then #766's, and #766's says the
  `bump-leg-branch-not-utf8` refusal names the root as the others do.
- tests/test_bump_leg_branch_and_push_line.py, the conftest imports: #849's
  names and #766's import line, both kept.
- tests/test_family_tracking_branch.py, the conftest imports: the same,
  with `SHOWN_NAMES` and `named_as` added to #766's line for the new row.

One change in meaning. `bump-leg-branch-not-utf8` is raised in
`_branch_to_bump_from` and `family-branch-not-utf8` in `_checked_branch`,
the checks whose other refusals #766 made name the root and the holder as
`shown_path` names a path. Both named it raw, so a root under a folder
holding an ESC or a line break sent it to the terminal from this one
refusal. Both details use `shown_path(root)` now. A printable path is named
byte for byte, so #849's own cases are unchanged. The `branch -m` remedy is
a command to paste and holds the path raw inside its quotes, as the
`switch -c` beside it does.

New rows, one per tool, over `SHOWN_NAMES`:
`test_a_root_on_a_branch_that_is_not_utf8_is_named_safe_to_show` and
`test_a_holder_on_a_branch_that_is_not_utf8_is_named_safe_to_show`. Also
rewrapped: the 103-character docstring line r765 noted in
tests/test_family_tracking_branch.py.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-18T05:24:59Z) # r765m: re-check of the merge round of PR #849 (issue #765) at da3fe1c8

Verdict: PR-READY. No code change is needed.

  • Optional wording fix: one body sentence, in "Fix round 1", is now out of date. See below.
  • CI: the tests job is still running. It is the only check not green. The earlier run at d98df568 took 64 minutes and passed. This one started at 04:50:23Z, so it should finish around 05:55Z.

1. git show --remerge-diff da3fe1c8

2. The shown_path change

  • Applied in both tools:
    • bump-leg: current_branch raises bump-leg-branch-not-utf8, and _branch_to_bump_from is its only caller. The detail is now {shown_path(root)}, as the refusals beside it have it.
    • family: _holder_branch raises family-branch-not-utf8. It is called by _checked_branch (real run) and _branch_said (dry run), so both get the change.
    • shown_path is imported in both files (bump-leg.py:124, family.py:204).
  • update-shape.py needs no change:
  • The branch -m remedy is right for a POSIX shell:
    • It keeps the path raw inside shell quotes, as _switch_command does. Both docstrings say that is the design.
    • I put a temporary probe test in my scratch copy, for ESC, line break and printable roots.
    • Each remedy was cut from stderr, <a new name> replaced with cafe, and run with sh -c.
    • Each ran with exit 0 and HEAD became refs/heads/cafe. The refusal was gone on the next run.
    • Result: 78 passed for the two tools together. The probe files are removed.
  • Revert proof, in a scratch worktree at da3fe1c8. The two new tests are the ones named ..._on_a_branch_that_is_not_utf8_is_named_safe_to_show:
Reverted Result
nothing 6 passed
both tools back to {root} 4 failed, 2 passed: [ESC] and [line-break] fail in both
bump-leg only 2 failed, 4 passed ([ESC], [line-break])
family only 2 failed, 4 passed ([ESC], [line-break])
restored 6 passed; git diff empty, git status --porcelain empty
  • Bytes-level check of the new tests:
    • They assert exit 2, an empty stdout, and the whole detail line.
    • They compare against spelt, which is repr(str(path)), not against shown_path, so the oracle is independent.
  • Writer's three revert rows re-run at da3fe1c8 on the selection of 12 old cases plus 6 new rows:
Row Result Writer's table
none 18 passed 18
F1: .strip() back in _holder_branch 1 failed (family no-break space) 1
B3: .strip() back in bump-leg current_branch 2 failed (beside a branch, beside the tracking branch) 2
U1: .strip() back in _symbolic_head 3 failed (no-break space, rollback [locale], [c-locale]) 3

3. Targeted gate on a local-only merge

  • Merge: da3fe1c8 with origin/main 33419925, the current main when I fetched.
    • Merge commit 246eb9ed, tree c34d4048, the same tree the writer reported.
    • merge-tree --messages printed Auto-merging scripts/bump-leg.py and Auto-merging update-shape.py, with no conflict.
  • Worktree and files: the worktree was under _lane1-scratch/r765m/ and is removed. The 44 files:
    • every test_family*.py (31) and test_bump_leg*.py (8);
    • the PR's three test files, which are among those;
    • test_update_shape_apply_lines_and_headers.py, test_agents_md_codes.py, test_readme_windows_quoting_sentence.py, test_shape_doctor.py and test_cli_reference.py.
  • How: each file in its own pytest run, 5 shards, /usr/bin/python3 -m pytest -p no:cacheprovider, stub gh first on PATH, GH_TOKEN=bogus-r765m, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false. GIT_ALLOW_PROTOCOL was never set.
Mode Result Shard EXIT File exits
git-filter-repo on PATH (/home/brett/.local/bin/git-filter-repo) 2,048 passed, 27 skipped, 0 failed all 0 all 0
hidden (PATH=<stub gh>:/usr/local/bin:/usr/bin:/bin, command -v git-filter-repo rc 1) 2,043 passed, 32 skipped, 0 failed all 0 all 0
  • Hidden mode: the 5 extra skips are 4 in test_shape_doctor.py and 1 in test_update_shape_apply_lines_and_headers.py. These match the body's "14 + 4 + 1" split. The 14 is test_update_shape_merged_away.py, which is outside my 44 files.
  • Dirty state: every shard log records DIRTY=0 at HEAD=246eb9ed.
  • Match with the writer: the per-file counts of all 44 files are identical to the writer's logs at da3fe1c8 in both modes. So main 33419925 changed no result in these files.
  • The body's "201 passed": the five files named in the body give 44 + 28 + 42 + 40 + 47 = 201 passed. This agrees with the body.
  • Main moved again. It is now 3bf2d3c9 (5c54af20, 3bf2d3c9).
    • Neither commit touches a PR file.
    • merge-tree with it is clean (Auto-merging scripts/bump-leg.py, Auto-merging update-shape.py).
    • I did not re-run the gate on it.
    • The shape-doctor.py change in 3bf2d3c9 is main's own business.

4. CI at da3fe1c8

gh pr view 849 reads head da3fe1c8, draft, merge state UNKNOWN at the last read. The checks:

Check State
SonarCloud Code Analysis pass (1m11s)
package pass (32s)
tests-git-bash pass (2m14s)
tests pending
  • SonarCloud:
    • The issues API (pullRequest=849&resolved=false) returned "total":0,"issues":[].
    • The quality gate is OK: new_duplicated_lines_density is 0.0, the ratings are 1, and security hotspots reviewed are 100.0.
  • tests:
    • Run 39098477406 has headSha da3fe1c8. The job was in step 7, "tests", at 05:23:54Z.
    • It had run 33 minutes at my last read, and I did not wait for it.
    • The earlier run 39080013572 at d98df568 took 64m25s and passed.

5. The body

The posted body equals pr765-body.md: 28,135 characters, and the only non-ASCII character is the robot face. Everything in "Merge of main", and every line it dates "before the merge", checks out against the head:

One out-of-date sentence, optional. "Fix round 1", line 29, ends: "The remedy is shell_line([...], "posix") now; the file gives 9 passed."

  • That was true at d98df568. At the head tests/test_readme_windows_quoting_sentence.py gives 40 passed, which is its count in my gate.
  • The "(9 tests)" in the revert-table line is already dated "before the merge", so it is fine.
  • It is not a false claim about the PR, only a stale number in a history section. If you edit the body, replace the file gives 9 passed. with the file gave 9 passed then (it holds 40 tests at the head, all passing).

Checks I could not make

  • I did not run the full suite or any file outside the 44. Only tests in CI can speak for the rest.
  • I measured nothing on Windows.

Files:

  • /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r765m/logs/ holds the gate logs (m1-s*.log, m2-s*.log).
  • /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r765m/gate.sh and pt.sh are the scripts I used.
  • Both scratch worktrees are removed with git worktree remove --force. Nothing was pushed, posted or committed to a shared branch. The shared checkout still shows only ?? handoffs.

Next (lane 1's coordinator): the optional body fix is applied ("the file gave 9 passed then (it holds 40 tests at the head, all passing)."). #849 takes lane 1's next turn, after lane 4's #862, on condition that CI tests at da3fe1c8 finishes green. Its gate g849r is running on the predicted squash over main plus #771 and #862.

Lane: openreposhape-1 (openRepoShape-1)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 05:55
@sourcery-ai

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 3 days and 13 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit fc289dd into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/765-branch-read-bytes branch October 18, 2026 05:55
brettheap added a commit that referenced this pull request Oct 18, 2026
…w's path under `members/`, a link or checkout in a move's way, an unmerged entry and a staged or unmerged `.gitmodules` are named first, so each remedy types clean (#840) (#865)

# Title

family.py add: a section astray beside its gitlink, a variant of a row's path under `members/`, a link or checkout in a move's way, an unmerged entry and a staged or unmerged `.gitmodules` are named first, so each remedy types clean (#840)

# Body

Was stacked on #828, which landed on main as `9b0a7038` (its final head `3546118b`). GitHub retargeted this PR to main, and the branch is restacked onto it (`6ef6e4c1`).

**Goal (#840):** where `add` refuses a member recorded in another case, no printed command leaves a section of `.gitmodules` naming another spelling, moves a live checkout out from under its `.git` file, or fails through a link.

#840 lists six states, and a seventh came from lane 2's rv828 review (SHOULD 3). The coordinator added an eighth on the way: a link at a variant the index holds as the member's gitlink. Each state was first typed as printed at `ce0f2a5f`, on git 2.43, with the gate run in all three modes and the same `add` run after. The link states were also typed on git 2.47.1. Every change is in `scripts/family.py`, which is not a pinned copy, so no `upstream-changed` row follows. The validator is not touched.

The commit after the restack, `231c404c`, adds one test for rv865b's NIT 1, `test_a_second_paths_checkout_is_asked_in_its_own_spelling`. The state is a gitlink at `Members/IRRS`, committed, with its skip-worktree bit set, and `Members` a link to `members`, which makes `Members/IRRS` resolve to the member's own checkout, as a disk that folds case would.

The test asserts the whole text:
- the link is moved out first;
- then `git rm -r --cached --sparse -- Members/IRRS` and its commit;
- and no line says to delete a checkout.

It then types that text as printed, and the gate passes. Mutant C5, which asks for the checkout whatever the spelling on disk, is caught by it.

The sixth and seventh commits, `adb20242` and `4e92867a`, answer rv865b's findings. The round is two commits because the first was a pause's work in progress, and nothing is amended.
- **MUST 1.** The second path's `git rm -r --cached` now takes `-f` where the move back's would. That is where the index stages the gitlink at a commit HEAD does not record there and a checkout is on disk at it (`_forced`, #828's rule). It prints the move back's own words on what `-f` does (`FORCED_SAID`, now one constant for both), and `--sparse -f` where both apply. At `38e0d503`, plain `git rm --cached` was refused there (`staged content different from both the file and the HEAD`, exit 1), for (a) staged with an untracked file or a commit of its own, and for K2 reset with work in the checkout.
- **SHOULD 1.** The checkout a second path leaves on disk is named to delete by hand in the same round, with its `git -C <path> status`, before the update.
  - At `38e0d503`, K2 committed's update pointed the store that checkout shares with the member at `members/IRRS`, so the status printed nothing while `WIP.txt` was there. The next run then said to delete the checkout.
  - The update is not left to the next run instead, because nothing would print it there once the checkout is gone, and the member would be checked out nowhere.
  - (a), (b), K2 committed, reset and foreign, and the sparse state now each take one round.
- **NIT 1.** The count for `_members_spellings` is corrected and recounted (line 4).
- **NIT 2.** `typed_as_printed`'s `def` line is `f7072d08`'s again.
- **N2.** rv865b ruled it acceptable as is, so nothing changed for it.

The fifth commit, `38e0d503`, answers rv865b's findings:
- **MUST 1.** A gitlink at a case variant of a row's path, beside the member's own gitlink at that path, is now taken out of the index with `git rm -r --cached -- <variant>`, the gate's own line for a second path. It is no longer renamed with `git mv`. At `f7072d08`, `git mv` carried the section that gives its path along under the same name, and the take-out then stripped it, so `renamed-1` was a gitlink no section names. The gate passed, but a plain clone's `git submodule update --init --recursive` exited 128, and that is the holder's own CI path (`validate.yml`). The checkout left on disk is the next run's `CHECKOUT`, deleted by hand. See line 1.
- **K2.** The clause "never point back a section whose path is a gitlink in the index" is dropped, together with the change above, as rv865b rules. Its four states are tests now.
- **NIT 1.** `_variant_in_members` asks `os.path.islink` before `samefile`, so a link `members/irrs -> IRRS` is named again on Linux. See line 3.
- **NIT 2.** rv865b's surviving mutants X1, X2 and X6 are pinned.
- **NIT 3.** A take-out printed after the point-back's own commit is committed too: that commit takes a staged `.gitmodules` into HEAD.
- **NIT 4.** The closing guard takes #839's form, `[ ! -L V ] && { [ ! -e V ] || [ V -ef R ] || [ -e V/.git ]; } || mv -- V ../<V>`, with its words. That is the form lane 5's #839 prints in `validate-family.py` (`77f8a6c8`), so the two texts agree. A link at the variant is now moved in the first round. See the added state.
- **NIT 5.** This body: line 1's claim now carries its caveat, and the merge checks name main `3bf2d3c9`.

The third and fourth commits, `dde7e9cc` and `f7072d08`, answer rv865's findings:
- **MUST 1.** A section beside the member's own section is no longer pointed back. It is taken out with the gate's own `--unset-all` lines. Only the section named after the row's path is pointed back, and only where no section gives that path. See line 1.
- **SHOULD 1.** On a disk that folds case, the member's own checkout listed in another spelling is no longer refused. See line 3.
- **NIT 1.** R7, R8, R9, R10, R13, R23, R25, R25b, R26 and R42 are pinned by tests now. R20 and R21 are not, and "Not here" says why.
- **NIT 2.** This body's mutant count and merge checks are corrected.
- **NIT 3.** `_checked_gitmodules_as_staged` has base's `def` line again, and its complexity went down. See line 4.

A point-back printed after a commit is now committed too. That earlier commit (a pending change, or the index's rename or removal) takes a staged `.gitmodules` into HEAD. At `4268f804`, a staged section edit beside a committed `Members/README` left the point-back staged, and the gate found `member-gitmodules-absent` in every mode.

**Measured at `ce0f2a5f`.** The gitlink stays at `members/IRRS`, and the section is hand-edited to `Members/IRRS`.
- Committed or staged, `add` exits 0 and commits that `.gitmodules` beside the new member.
- The gate exits 1 in every mode: `gitmodules-path-unnamed`, plus `member-gitmodules-absent` once committed.
- With the edit not staged, `gitmodules-unstaged` comes first; typed, it leads to the staged state, and `add` goes through.
- A section giving `members/irrs` behaves the same way.

**What changed.** `_sections_astray` finds each section that gives a case variant of a row's path, where the index holds the member's gitlink at that path. `_row_sections_astray` then tells two cases apart, as the gate does:
- **No section gives the row's path.** This is the gate's `member-gitmodules-absent` case. The section named after the row's path, the name `add` gives a member's section, is pointed back with the gate's own line: `git config -f .gitmodules submodule.members/IRRS.path members/IRRS`, then `git add -- .gitmodules`. Its place is "in .gitmodules, its gitlink at its row's path". A gitlink at its path is taken out of the index first (below), which leaves the section where the point-back finds it, in the same round.
- **A section gives the row's path.** Each other one is a second section beside it, which the gate's `gitmodules-path-unnamed` calls `STRAY`. It is taken out, every key it holds, with the gate's own lines: `git config -f .gitmodules --unset-all submodule.Members/IRRS.path` and the same for `url`, each key quoted for a POSIX shell, then `git add -- .gitmodules`. Its place is "in .gitmodules, beside the section giving its row's path".
- **A gitlink at such a path, beside the member's own gitlink,** is a second path to the member's directory (fix round 2). It is taken out of the index alone, `git rm -r --cached -- Members/IRRS`, as the gate's own line does (`UNSTAGE_SECOND`). It takes `--sparse` where the sparse rules leave it out or its bit is set, and `-f` where the index stages it at a commit HEAD does not record there beside a checkout (fix round 3). That comes before the take-out, and `git rm --cached` leaves the section for it. A checkout there that `git submodule add` or `git mv` made, on disk in its own spelling, is named to delete by hand in the same round, with its `git -C <path> status`, before the update (fix round 3). A clone of its own is not named: the next run moves it out whole.
- Once the member's own section is pointed back, any other variant section is beside it, and is taken out the same way.
- A variant section that is neither is left to the gate, which names it and prints no command for it.

The code is `family-members-dir-variant`, as for any other path under another spelling of `members/`. Each part commits where HEAD's copy holds what it changes, or where a commit printed before it took a staged `.gitmodules` into HEAD: a pending change, the index's rename or removal, or, for a take-out, the point-back's own commit (fix round 2). After a staged edit alone there is nothing to commit, and `git commit` would fail. A change nobody has staged is staged first, as #685 does for the move back.

A point-back closes with the update, `git submodule update --init --recursive`, where nothing is checked out at the row's path. That is the state of a fresh clone: no section gives `members/IRRS`, so nothing is checked out there. Beside a move back of another member, the one closing update names both rows. A take-out needs no update: the member's own section gave its path all along.

**Why the take-out (rv865's MUST 1).** At `4268f804`, every variant section was pointed back, whatever submodule it belonged to. Measured again on that head with the text checks left out, on git 2.43: after `git submodule add <IRSS's remote> Members/IRRS` (kept, at `members/irrs`, or with its checkout deleted), or with the section `git rm --cached` leaves behind, the remedy typed as printed gave `members/IRRS` twice. The gate exited 1 in every mode, and a recursive clone of the pushed branch exited 128: `Fetched in submodule path 'members/IRRS', but it did not contain <commit>`.

**Caveat on fix round 1's claim (rv865b's MUST 1).** The first fix round said that the same four states and a fifth type clean, that the recursive clone checks `members/IRRS` out, and that the gate passes. That held for a recursive clone only. In (a) and (b), the take-out ran after `git mv` of the same gitlink. It stripped the section `git mv` had carried along, and left `renamed-1` a gitlink no section names. A plain clone's `git submodule update --init --recursive` then exited 128 (`No url found for submodule path 'renamed-1' in .gitmodules`), and so did `git submodule status`, in the holder and in the clone. The gate cannot see this, since it asks only gitlinks under `members/`.

**Since fix round 2,** that gitlink is taken out of the index and never renamed. Measured at `4e92867a` on git 2.43 and 2.47.1, the five states and two more type clean:
- the two more are a staged section taken out after the point-back's commit, and a staged one whose name needs quoting;
- each takes one round; in (a) and (b), the checkout `git rm --cached` leaves is deleted by hand where the text says (two rounds at `38e0d503`);
- a recursive clone checks `members/IRRS` out from IRRS's remote at its commit;
- a plain clone's `git submodule update --init --recursive` exits 0 with every member checked out;
- `git submodule status` exits 0 in the holder and in both clones;
- the gate passes in all three modes, and the same `add` goes through.

rv865b's four K2 states (the gitlink at `Members/IRRS` beside the member's own, with the member's section astray: committed, reset, checkout deleted, and beside a foreign section) do the same, in one round each.

**Work in the second path's checkout (fix round 3).** These states type clean in one round too:
- (a) staged with an untracked file;
- (a) staged with a commit of its own;
- (a) staged and left out by the sparse rules, which gives `--sparse -f`;
- K2 reset with work;
- K2 committed with work.

Where the text says to delete the checkout, its `git -C Members/IRRS status` still names the untracked file, and its commit is kept in its store.

**Measured at `ce0f2a5f`.** `Members/X/notes` is committed, and a checkout that `git submodule add` made at `Members/X/Y` is taken out of the index. #574's `git mv -- Members/X <new name>` typed as printed moved the checkout too. `git -C renamed-1/Y status` then said `fatal: not a git repository: .../renamed-1/Y/../../../.git/modules/Members/X/Y`.

A checkout the index still records under that directory is moved by `git mv` with its `.git` file mended: `git -C renamed-1/Y status` exits 0.

**What changed.** Each checkout git does not track, inside a directory that a `git mv` (`RENAME`) or a plain `mv` (`MOVE`) moves, is named first (`CHECKOUT_FIRST`, `_checkouts_in_the_way`). It is to be deleted by hand once nothing only it holds is needed, as #828's `CHECKOUT` says of one found on disk. Its commits stay in the holder's store. `git -C <checkout> status` shows what is not committed.
- The walk does not follow links, and does not enter a directory that holds a `.git` entry, so a checkout's own legs are not named again.
- A gitlink the index holds is not named, and neither is a clone of its own, whose `.git` is a directory: the move takes it whole.
- The moved directory itself is not entered when it holds a `.git` entry.
- The plain `mv` case is the same fault, so it is covered too.

The fix round pins the last three (rv865's R7, R8, R9, R10) with a clone of its own at `Members/X`, legs checked out, and with a plain `Members/X` holding such a clone and a link to a directory outside the holder. In both, only `mv -- Members/X <new name>` is printed. #828's `test_a_checkout_under_an_index_entry_is_the_indexs_to_name` did not pin R7: that directory has no subdirectories.

**Measured at `ce0f2a5f`,** beside the row `members/IRRS`. In each of these states `add` exits 0, so a person meets nothing:
- a file `members/irrs` in the index;
- the same file on disk;
- that file in HEAD alone, its removal staged;
- an untracked directory there;
- a second gitlink there;
- the member's own gitlink moved there, #646's state under `members/`.

The gate passes the first four. It refuses the second gitlink in every mode (`member-gitlink-unnamed`). For the member's own gitlink it exits 2 in members mode, and `member-gitlink-unnamed` under `--no-members`.

**What changed.** `_spelled_target` takes the rows' paths. Under `members/` itself, a case variant of a row's path that is no row's own path is now a target, as a variant of the mount path already was.
- It flows through the index, HEAD and disk checks, so each state gets #574's remedy.
- The member's own gitlink gets #646's move back.
- A section whose gitlink's removal is staged gets #828's `IN_GITMODULES` put-back.
- A second gitlink that `git submodule add` made there, with its own section, is taken out of the index, its section is taken out, and its checkout is named to delete by hand in the same round (line 1, fix rounds 2 and 3). A second gitlink with nothing on disk is taken out of the index as before.

The code is `family-members-dir-variant`. Its first sentence says what was found (`_lead`): "a case variant of the path of a `members:` row under members/, the same path as that row's on a case-insensitive disk". It is joined to the other-spelling sentence where both are found. A folding disk, asked in process with `disk_as`, gives the same text.

**On a disk that folds case (rv865's SHOULD 1),** `_variant_in_members` lets through an entry of `members/` that the disk takes for the row's own path (`os.path.samefile`, as `_members_dir_on_disk` asks of `members/`). macOS and Windows list a directory in the spelling it was made in. So after `git mv members/IRRS members/irrs` and line 3's move back, whose closing guard `[ members/irrs -ef members/IRRS ]` is true there, the member's only checkout is listed as `members/irrs`. At `4268f804` the next `add` refused it as on disk and untracked, every time. Now it goes through. On Linux the two are two paths, `members/IRRS` holds nothing, and `members/irrs` is still named.

**A link is never the row's own (rv865b's NIT 1).** `samefile` follows a link, so at `f7072d08` a link `members/irrs -> IRRS` was let through on Linux and `add` went through beside it; `4268f804` named it. `_variant_in_members` now asks `os.path.islink` first, as `_members_dir_on_disk` never takes a link for `members/`. The link is named on disk, `on disk and untracked: members/irrs (a symlink)`, and moved out of the holder, the link alone.

**Measured at `ce0f2a5f`.** `git mv members Members` is committed, then `.gitmodules` is left unmerged (stages 1, 2 and 3) and deleted from the work tree. `_checked_gitmodules_as_staged` leaves a missing copy to git for `add`, so the rename back was printed. Its commit failed: `Committing is not possible because you have unmerged files`. In #646's state the commit fails the same way, and the update says `No url found`.

**What changed.** `_gitmodules_first` asks first. For this state it raises `gitmodules-missing` (`_gitmodules_missing`) with the restore git takes there, as `remove` prints it: `git checkout --merge -- .gitmodules`, resolve it, `git add .gitmodules`.

The detail says why for `add`. `remove`'s text is unchanged, byte for byte. An unmerged copy is now asked first wherever the spelling refusal is raised, not only beside a gitlink, since any printed commit fails beside it.

**NIT 3.** `_checked_gitmodules_as_staged` has the `def` line base had, `def _checked_gitmodules_as_staged(root: Path, verb: str) -> None:`, and no `spelled` parameter. The missing-file refusal moved into `_gitmodules_missing`, which it raises for `remove` and `_gitmodules_first` raises for `add`. By hand, its cognitive complexity went from 35 at `4268f804` (34 at base) to about 18, so Sonar keeps that issue as the old one, lower. The new functions are under 15 by hand: `_gitmodules_missing` about 12, `_row_sections_astray` about 8 (9 before fix round 2 dropped K2's clause), `_variant_in_members` about 9 (8 before NIT 1's `islink`).

Fix rounds 2 and 3, measured with rv865b's estimator (`cogc.py`, comprehensions not counted):
- **New functions:** `_second_gitlink` 2, `_second_path_part` 5, `_second_checkout_part` 2, `_forced` 1, `_removed` 2.
- **`_point_back_part`:** up 1, to 3.
- **`_spellings_refusal`:** down 2, from 38 at `f7072d08` to 36, since the removal's condition moved into `_removed`.
- **`_move_back_line`:** unchanged at 9.
- **`_members_spellings`:** from 41 at `f7072d08` to 45 at `38e0d503`, then 46 now. Round 2 added 4: its `if`, nesting, its `and`, and `out = … or …`. Round 3 adds 1, the `and` in `checkout = present and …`. The fix round 2 body said 3. Its `def` line is unchanged, and it was over 15 before this PR.
- **`typed_as_printed`:** 31, as at `f7072d08`, and its `def` line is that commit's again.

**Measured at `ce0f2a5f`.** `git rm .gitmodules`, with HEAD holding the file. The printed rename back, or move back, and its commit took `.gitmodules` out of HEAD with every member's section. The gate then found `member-gitmodules-absent` for each member.

With `git rm --cached .gitmodules`, a copy stays on disk, untracked:
- beside the rename back, `git mv` staged that copy again, and the result passed;
- beside the move back, the commit took the deletion as well, and left the copy untracked.

**What changed.** `_gitmodules_beside` tells the two apart: `GITMODULES_DELETED` where the work tree has no copy, and `GITMODULES_UNTRACKED` where it has one.
- Deleted: the refusal's first part puts HEAD's copy back, `git checkout HEAD -- .gitmodules`, as `remove` prints it and as #767 does for a copy deleted from disk.
- Untracked copy: `gitmodules-unstaged` comes first ("its deletion is staged"), since which copy to keep is the person's.

Both apply wherever anything is found, so a lone `Members/README` rename is covered too: its commit took the deletion as well.

**Measured at `ce0f2a5f`, which does not answer it.** #828's set-aside part does not reach this state. `git rm -r --cached --ignore-unmatch -- Members/IRRS Members/IRSS` exits 128: `fatal: not a git repository: Members/IRRS/../../.git/modules/members/IRRS`.

The rest of the line then ran, but the gitlinks stayed under `Members/`, and the next `add` refused again. Through the same link, plain `git mv -- Members/README x` moves the file in from where the link points.

**What changed.** A symbolic link that the index holds entries under is named on disk and moved out of the way first (`LINK_OUT`, `_disk_found`). The words are those `member-path-unaddable` has for `members` itself: "must be a plain directory of the holder's own".

`_on_disk_as_named` now takes nothing beyond a link as on disk. So a file under it is taken out of the index (`git rm -r --cached`), not given a `git mv` that would fail once the link is gone (`bad source`).

Typed as printed:
- the link moves;
- the move back runs with both sections;
- the guarded `mv` moves nothing;
- the update checks both members out at their rows' paths.

The directory the link named is left where it is. A checkout there was unusable already, since its `.git` file is relative.

**Measured at `ce0f2a5f`.** After `git mv members/IRRS Members/IRRS`, an unmerged file at or under `members/IRRS` left the row neither blocked nor a stray. So the gitlink kept #574's `git mv -- Members/IRRS <new name>`, which took it away from its row, and the commit failed.

An unmerged file at `members` itself, off disk: each `git update-index` ran, and the commit failed. On disk, `member-path-unaddable` printed `git mv -- members <new name>`, which exits 128 (`fatal: conflicted`).

**What changed.**
- `_rows_held` counts a file or link at any stage as in the way.
- `_set_aside` takes an unmerged entry out of the index, every stage, with `git rm -r --cached`, which git allows there (`ASIDE_UNMERGED`). Where the entry is on disk as the index holds it, it is then moved out of the way with `mv` (`ASIDE_UNMERGED_MV`).
- Measured why the `mv` is needed: a file left at the row's path made the update fail (`could not create directories for members/IRRS/.git`). One left under it stayed in the member's checkout, which `git status` then lists as modified.
- The removal is a change to commit only where HEAD holds the entry. With the move staged, no commit is printed.
- An entry held at stages 1 and 3 alone, which this side deleted, is unmerged too.
- `_members_is_a_file` gives an unmerged `members` the same two steps, plus the commit on its own where HEAD holds the file.

**Already answered at `ce0f2a5f`.** Measured on git 2.43 and 2.47.1, with #646's state and a link `Members/IRRS` on disk, pointing to the member's checkout or to nowhere. git 2.47.1 refuses `git status` itself: `error: expected submodule path 'Members/IRRS' not to be a symbolic link`. `add` reads no `git status` on this path, and gives its spelling refusal first: no git `fatal:` and no traceback, for `add --dry-run` and the real run alike.

At `f7072d08`, typed as printed on 2.47.1:
- the move back ran;
- the closing guard kept a link to the row's own directory (`[ Members/IRRS -ef members/IRRS ]` follows it);
- the next `add` named it on disk, with its `mv`;
- typed, the gate passed and `add` went through: three rounds.

**Since fix round 2 (rv865b's NIT 4),** the closing guard takes #839's form, which asks `[ ! -L Members/IRRS ]` first, so a link there is always moved, the link and never what it points at. The guard now moves the link in the first round, and the next `add` goes through: two rounds, typed under dash and under bash, on git 2.43 and 2.47.1. A link to nowhere was moved in the first round before, and still is.

`test_a_link_at_a_variant_the_index_holds_is_refused_first` pins this. Its refusal check is `refused_untouched(..., status=False)`, so `snapshot` skips `git status`, which git 2.45.1 and later refuse here, and keeps every other key.

The guard holds `; ` inside its braces, so the tests' `typed_as_printed` now splits the remedy into parts at `; ` outside backticks only (`remedy_parts`).

**New file `tests/test_family_add_spelling_leftovers.py`, 70 cases.** Each typing test types every printed command as printed, with `typed_as_printed`, in the state it describes. Each one then checks that every member is where its row says, that the gate passes in all three modes, and that the same `add` goes through.

| Line | Cases |
|---|---|
| 1 | committed, staged, not staged, under `members/` itself, a fresh clone, beside a move back; a staged edit after a pending change, a rename and a removal; rv865's four states, the point-back beside a take-out, a staged take-out after the point-back's commit, and a staged one whose name needs quoting; rv865b's four K2 states; the second gitlink left out by the sparse rules or marked by hand; five states with work in the second path's checkout; a staged second gitlink with nothing on disk, which takes no `-f`; a clone of its own at a second path, moved whole on the next run; a second gitlink marked at `Members/IRRS` with `Members` a link to `members`, which names no checkout (fix round 4); the section named after the row beside one giving its path; a variant section left to the gate; an unreadable `.gitmodules` |
| 2 | under a `git mv`, under an `mv`, a tracked gitlink's control, and a clone of its own moved whole, alone and beside a link |
| 3 | six states, a section whose gitlink's removal is staged, both disks in process for two of them, the member's checkout a folding disk lists in another case, on both disks, and a link `members/irrs -> IRRS` |
| 4 | one case |
| 5 | beside the rename back, the move back and a file, and the untracked copy |
| 6 | the gitlinks alone, and a file under the link |
| added state | two cases, typed under dash and under bash |
| 7 | five unmerged states, two with the move staged, one at stages 1 and 3 alone; `members` off disk, and `members` on disk with and without HEAD's file |

Every case of line 1 that a section beside the member's own or a second path takes part in (rv865's four, the two staged take-outs, the K2 states, the sparse ones and the work states) ends the same way (`typed_through`):
- HEAD is pushed to a branch of its own;
- a recursive clone checks `members/IRRS` out from IRRS's remote at its commit;
- a plain clone, as the holder's CI makes it, runs `git submodule update --init --recursive`, which must exit 0 with every member checked out;
- `git submodule status` must exit 0 in the holder and in both clones.

Where the text says to delete a checkout by hand, it is deleted there, after the parts before it and before the update. In the work states, the hand step first asserts that `git -C <checkout> status` still names its untracked file.

**Changed:** `tests/test_family_add_move_back_sections.py`. Its `set_aside` builds the unmerged part (`unmerged=`, `moved=`). In fix round 2, one docstring names the guard's new form.

**Changed, in the second commit:** `tests/test_family_members_case_variants.py`. `refused_untouched` takes a keyword `status=True` and passes it to both of its snapshots. The link test calls it with `status=False`, and the narrower helper it had (`refused_beside_a_link`) is gone. In the third commit, its one literal `Spelled` tuple gains the new `keys` field, and in fix round 3 the new `checkout` field.

**Changed, in fix round 2:** `tests/test_family_add_member_variant.py`.
- `guarded` and the closing words take #839's form.
- `typed_in_sh` types into the module's `SHELL`, `sh` unless a test sets `dash` or `bash` with `monkeypatch` (fix round 3). Its `def` line and `typed_as_printed`'s are `f7072d08`'s, so the remedy's split is their only change (rv865b's NIT 2).
- The remedy is split into parts at `; ` outside backticks (`remedy_parts`).

No test needs a git newer than 2.34, but for the sparse case. The second gitlink left out by cone-mode rules needs git 2.38 or later (`sparse_cone`, as #828's tests). So does the work state left out by those rules. The sparse cases skip where git has no `git sparse-checkout check-rules`, as does the staged second gitlink marked by hand. Symlink cases skip on Windows, the added state skips where its shell is missing, and states that need two names differing only in case skip on a disk that folds case, as in #828's files.

**Fails on the base.** The first 35 cases, run against `ce0f2a5f`'s `family.py`: **33 failed and 2 passed** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/base.log`). The two passes pin what the base already does: the added state and line 2's control.

**Fails on `4268f804`.** The fix round's 16 new cases, run against `4268f804`'s `family.py`: **9 failed and 7 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/oldfinal243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/oldfinal247.log`).
- The 9 that fail are the fixes: rv865's four states and the point-back beside a take-out, the section named after the row, the variant section left to the gate, the staged edit after a rename, and the folding disk's checkout.
- The 7 that pass pin code that was already right, as rv865 found it: the staged edit after a pending change (R26), the unreadable `.gitmodules` (R13), the two clones (R7 to R10), the two new unmerged states (R23, R42), and the Linux side of the folding-disk test.

**Fails on `f7072d08`.** Fix round 2's 17 new or changed cases, run against `f7072d08`'s `family.py`: **16 failed and 1 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/old243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/old247.log`). The one that passes is the staged edit after a removal (X2), which pins code that was already right.

A copy with no check on the text was also run on both heads, git 2.43: each state is typed as printed, round after round, then put through the same checks (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/test_w865f_r2_behaviour.py`).
- At `f7072d08`: 11 of 16 failed (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-old243.log`).
  - The plain clone's update failed for (a), (b) and both sparse states.
  - HEAD's `.gitmodules` was not the holder's for the K2 states committed, reset and foreign, and for the staged take-out after the point-back's commit, which left `stale` in HEAD.
  - The link `members/irrs` was let through.
  - The link at the variant took 2 rounds of typing before `add` went through, in dash and in bash.
- At `38e0d503`: 16 passed (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/bh-head243.log`).
- The 5 that pass at `f7072d08` are (c), (d), the point-back beside a committed take-out, the staged name that needs quoting (X1, X6: `f7072d08` already quoted the key and printed no commit), and K2 with its checkout deleted, which took two rounds there and ended right.

**Fails on `38e0d503`.** Fix round 3's new and changed cases, run against `38e0d503`'s `family.py`:
- **Of the 20 cases with the round's changes, 12 failed and 8 passed**, on git 2.43 and on 2.47.1 (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243.log`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old247.log`).
  - The 12 that fail are (a), (b), K2 committed, reset and foreign, both sparse states, and the five work states.
  - The 8 that pass are the BESIDE states with no second path, K2 with its checkout deleted, and the link test under dash and bash, none of whose text or typing changed.
- **The two cases added last both fail** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log`).
- **A copy of the five work states with no check on the text** (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/test_w865f_r3_behaviour.py`) types round after round, and its hand step asserts that the status names the untracked file:
  - At `38e0d503`, all five fail (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/old243b.log`). Four stop at `git rm -r --cached` (`staged content different from both the file and the HEAD`): (a) staged with an untracked file, with a commit of its own, left out by the rules, and K2 reset with work. K2 committed with work fails the hand step: `WIP.txt on disk but not in its status`.
  - At `4e92867a`, all five pass (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/bh-head243.log`).

Every run went through `run9.sh` or `run9s.sh`, with the real `gh` unreachable and basetemps under `v110/bt/`.

| Log | Git | Head | Files | Result |
|---|---|---|---|---|
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final243.log` | 2.43 | `4e92867a` | the same 15 files, 3 shards | 721 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r3/final247.log` | 2.47.1 | `4e92867a` | the same 15 files, 3 shards | 721 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final243.log` | 2.43 | `38e0d503` | the same 15 files, 3 shards | 714 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/final247.log` | 2.47.1 | `38e0d503` | the same 15 files, 3 shards | 714 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.log` | 2.43 | `38e0d503` | rv865b's probe, which records and never asserts (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/r2/probe-head-243.jsonl`) | 29 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final243.log` | 2.43 | `f7072d08` | 15 files, 3 shards (below) | 703 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/final247.log` | 2.47.1 | `f7072d08` | the same 15 files, 3 shards | 703 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full243.log` | 2.43 | `dde7e9cc` | 15 files, 3 shards: the 3 touched test files, the other 5 `test_family_add_*` files, the trio, `test_family.py`, `test_family_remove_gitmodules.py`, `test_family_head_lost_gitmodules.py`, `test_family_holder_states.py` | 702 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/full247.log` | 2.47.1 | `dde7e9cc` | the same 15 files, 3 shards | 702 passed, rc 0 |
| `va.log` | 2.43 | `2c97e759` | the new file, the issue's Verify files, `test_family_holder_states.py`, the trio | 471 passed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb1.log` | 2.43 | `2c97e759` | the first 33 of the 65 other files that run or import `family.py` | 2070 passed, 24 skipped, 1 xfailed, rc 0 |
| `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w840f/vb2.log` | 2.43 | `2c97e759` | the other 32 of those 65 files | 1739 passed, rc 0 |

The 15 files are the 3 that fix round 1 touched, rv865's other five `test_family_add_*` files (`excludes`, `member_id`, `member_variant`, `refusals`, `spellings_remedies`; fix round 2 touches `member_variant` too), the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), `test_family.py`, and the three files that drive `.gitmodules` refusals for `add` and `remove`. CI's git 2.55 was not run here. macOS and Windows disks are emulated in process (`disk_as`) for lines 1 and 3.

**rv865b's probe at `38e0d503`, git 2.43.** Its typing stops after three rounds and never deletes a checkout by hand, so a state that leaves one shows the checkout named in rounds 2 and 3. Each state ends with the gate passing in all three modes.
- **P1 (a) and (b) as printed, and P2:** round 1 takes the gitlink out of the index. HEAD's gitlinks are then the two members alone, and `git submodule status` exits 0 in the holder and in the recursive clone. Rounds 2 and 3 name the checkout left at the variant.
- **P3, K2:** committed, reset and foreign end the same way. "Deleted" ends in one round, and `add` goes through.
- **P4 and P4b:** the take-out commits after the point-back's commit. HEAD no longer holds `stale` (`head_has_stale: false`).
- **P5:** `members/irrs -> IRRS` is refused (code 2), and so is `members/irrs -> IRSS`.
- **P6:** all four link layouts take two rounds, and the link is gone.
- **L7:** all six states are clean.
- **P7 (a), (b) and (c) as printed:** a plain clone's update exits 0, with every member checked out.
- **P7's control,** the rename typed by hand followed by the gate's own `--unset-all` lines, still gives a plain update that exits 128 while the gate passes. That is the validator's gap, which rv865b named; this PR does not print that rename.

**First round: 30 mutants,** M1 to M27 plus M11b, M13b and M15b, at least one per new hunk, each run `-x` on the test that names its state. All 30 are caught. (The earlier text said 31.)

| Mutant | What it undoes | Caught by |
|---|---|---|
| M1 | `_spelled_target`: no row variant under `members/` | line 3 (`add` exits 0) |
| M2 | `_on_disk_as_named`: on disk beyond a link | line 6, a file under the link |
| M3 | `_disk_spellings`: no row variant on disk | line 3, a directory on disk |
| M4 | `_sections_restored`: no row variant for a staged removal | line 3, the section state |
| M5 | `_rows_held`: an unmerged file not in the way | line 7 |
| M6 | `_in_the_way`: nothing unmerged | line 7 |
| M7 | `_aside_remedy`: an unmerged file on disk not moved | line 7 |
| M8 | no section astray | line 1 (`add` exits 0) |
| M9 | `_disk_found`: no `LINK_OUT` | line 6 |
| M10 | no checkout inside a moved directory | line 2 |
| M11 | `_checkouts_under`: a tracked gitlink named | line 2's control |
| M11b | `_checkouts_under`: a checkout entered (its legs named) | line 2 |
| M12 | `_gitmodules_beside`: no staged deletion | line 5 |
| M13 | `_gitmodules_first`: unmerged not asked first | line 4 |
| M13b | `_gitmodules_first`: an untracked copy not asked first | line 5, the untracked copy |
| M14 | `add` leaves a missing unmerged copy to git | line 4 |
| M15 | `_members_is_a_file`: unmerged taken for merged | line 7, `members` on disk |
| M15b | `_members_is_a_file`: HEAD's file not committed out | line 7, HEAD holds the file |
| M16 | `_lead`: a row variant said as another spelling | line 3 |
| M17 | `_point_back_part`: the commit always printed | line 1, staged |
| M18 | no update for a section alone | line 1, a fresh clone |
| M19 | the update names the moved rows alone | line 1 beside a move back |
| M20 | `_gitmodules_part`: no put-back from HEAD | line 5 |
| M21 | `_set_aside_part`: unmerged not said | line 7 |
| M22 | `_link_part`: never said | line 6 |
| M23 | `_checkout_first_part`: never said | line 2 |
| M24 | an unmerged removal always committed | line 7, the move staged |
| M25 | HEAD's row variants not asked | line 3, in HEAD alone |
| M26 | the index's row variants not asked | line 3, a file in the index |
| M27 | `refused_untouched` ignores `status` | the link test, on git 2.47.1 only |

**Fix round:** 19 mutants of this round's new hunks in `scripts/family.py` (K1 to K18 and K3a), and the 8 of rv865's survivors pinned here run again, 27 in all, on `dde7e9cc`'s `family.py`, which `f7072d08` keeps, each `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut/summary.txt`). **All caught but K2.**

| Mutant | What it undoes | Caught by |
|---|---|---|
| K1 | point back even where a section gives the row's path | the section named after the row |
| K2 | point back a section whose path is a gitlink in the index | survived; fix round 2 drops the clause, and N14, which puts it back, is caught |
| K3 | any variant section is the member's own, not only the one named after the row | the point-back beside a take-out |
| K3a | the take-out set off by any variant section | the variant section left to the gate (it survived until `f7072d08` added that test) |
| K4 | nothing taken out once the member's own is pointed back | the point-back beside a take-out |
| K5 | nothing taken out beside a section giving the row's path | (a) |
| K6 | a take-out never a change to commit by itself | (d) |
| K7 | a point-back never a change to commit by itself | line 1, committed |
| K8 | `_committed` ignores the earlier commit | the staged edit after a pending change |
| K9 | only the `path` key taken out | (a) |
| K10 | the earlier commit is the pending change alone | the staged edit after a rename |
| K11 | the earlier commit leaves out the pending change (rv865's R26) | the staged edit after a pending change |
| K12 | `_variant_in_members`: no `samefile` | the folding disk's checkout |
| K13 | `_variant_in_members`: a row variant on disk never named | the same test on Linux |
| K14 | `_gitmodules_first`: no `gitmodules-missing` for an unmerged copy off disk | line 4 |
| K15 | `_gitmodules_missing`: a staged copy taken for unmerged | `test_family_remove_gitmodules.py` |
| K16 | `_checked_gitmodules_as_staged`: `add` no longer leaves a missing file to git | `test_family_dry_run.py` |
| K17 | `_sections_astray`: an unreadable `.gitmodules` raises there (rv865's R13) | the unreadable `.gitmodules` |
| K18 | the place "beside the section giving its row's path" not listed | (a) |
| R7 | `_checkouts_under`: a moved directory that is a repository entered | the clone moved whole |
| R8 | a clone (`.git` a directory) named too | the clone beside a link |
| R9 | the walk follows links | the clone beside a link |
| R10 | a directory holding a `.git` directory entered | the clone beside a link |
| R23 | an unmerged entry moved with `mv` always a change to commit | line 7, on disk with the move staged |
| R25 | `_lead`: the row sentence alone | the point-back beside a take-out |
| R25b | `_lead`: the joined sentence without the row clause | the point-back beside a take-out |
| R42 | unmerged only where stage 2 is held | line 7, stages 1 and 3 alone |

**Fix round 2: 19 mutants** of the round's new hunks in `scripts/family.py` (N1 to N18 and N7b), on `38e0d503`, each run `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate2.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut2/summary2.txt`). **18 are caught and N2 survives.** N2 was run over all 200 cases of the leftovers, `member_variant`, `move_back_sections` and `case_variants` files, which all passed: see "Not here".

| Mutant | What it undoes | Caught by |
|---|---|---|
| N1 | `_second_gitlink`: any entry, not only a gitlink | line 3, a file on disk |
| N2 | `_second_gitlink`: the row's path need not hold a gitlink | **survives** (200 passed): see "Not here" |
| N3 | `_second_gitlink`: any gitlink while a row's gitlink is held, whatever its path | `test_a_gitlink_the_sparse_rules_leave_out_is_renamed_with_sparse` |
| N4 | the second path renamed with `git mv` again | (a) |
| N5 | a second path with nothing on disk rerouted too | (c) |
| N6 | the second path's removal never takes `--sparse` | the second gitlink left out by the rules |
| N7 | `--sparse` only where the rules leave it out | the second gitlink marked by hand |
| N7b | `--sparse` only where the bit is set | the second gitlink left out by the rules |
| N8 | the second path's part never printed | (a) |
| N9 | `_second_path_part`: `--sparse` never printed | the sparse cases |
| N10 | `_second_path_part`: the git `--sparse` needs never said | the sparse cases |
| N11 | `_removed`: the second path's removal never a change to commit | (a) |
| N12 | `_removed`: a pending change committed first no longer makes a removal one | `test_every_place_is_named_and_the_pending_change_is_committed_first` |
| N13 | `_removed`: a removal said wherever anything is committed | line 3, in HEAD alone |
| N14 | K2's gitlink clause put back | K2 committed |
| N15 | the point-back's own commit not counted for the take-out (NIT 3) | the staged take-out after the point-back's commit |
| N16 | `_variant_in_members`: no `islink` (NIT 1) | the link `members/irrs` |
| N17 | `_closing_sentences`: #828's guard again | the added state |
| N18 | #839's form without its `-L` test | the added state |

**Fix round 3: 13 mutants** of the round's new hunks in `scripts/family.py`, on `4e92867a`, each run `-x` on the tests named (`/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mutate3.py`, `/tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/w865f/mut3/summary3.txt`). **12 were caught, and C5 survived** until fix round 4's test `test_a_second_paths_checkout_is_asked_in_its_own_spelling` caught it (on `231c404c`).

| Mutant | What it undoes | Caught by |
|---|---|---|
| F1 | the second path's `-f` never printed | (a) staged with an untracked file |
| F2 | `_forced` always true | (a), which takes no `-f` |
| F3 | the clause on `-f` never said | (a) staged with an untracked file |
| F4 | `_forced` without the question whether a checkout is on disk | the staged second gitlink with nothing on disk |
| F5 | `_forced` without the question whether HEAD records it at that commit | (a) |
| F6 | a second path never forced | (a) staged with an untracked file |
| F7 | the move back's clause dropped, now the shared constant | `test_family_add_move_back_sections.py` |
| C1 | the checkout a second path leaves never named | (a) |
| C2 | that checkout named after the update, not before | K2 committed with work |
| C3 | every second path has a checkout | the second gitlink marked by hand |
| C4 | a second path's checkout never noticed | (a) |
| C5 | the checkout asked whatever the spelling on disk | the second gitlink marked with `Members` a link to `members` (fix round 4) |
| C6 | any directory, a clone of its own too, is a checkout | the clone of its own at a second path |

`git merge-tree --write-tree` of `4e92867a`, after a fetch of main at 08:38Z on 2026-10-18:
- **Clean:** #828's final head `3546118b` (tree `923bb23b`), the restack's first merge.
- **For information:** main `def45de6`, which holds #828's squash `9b0a7038`, conflicts in #828's own files: `scripts/family.py`, `tests/test_family_add_member_variant.py`, `tests/test_family_add_move_back_sections.py` and `tests/test_family_members_case_variants.py`. This PR still holds #828's commits from before the squash, and the restack resolves those files.

Fix round 2's check of `38e0d503` was clean against main `3bf2d3c9` and `5c54af20`, and against #828's `90649e53` and `54b7699e`.

Fix round 1's check of `f7072d08`, at about 02:55Z, was clean against main `4dea85ee`, `90649e53`, and the open PRs that change `scripts/family.py`: #814 `4f6e7f83`, #846 `2d0e884f`, #849 `d98df568`. Those three were not checked again in fix round 2.

The restack onto #828's squash comes next. The first commit's wider check, against every open branch of 2026-10-16 and 2026-10-17 that touches these files, found no conflict of its own.

No line of `README.md`, `docs/handbook.html`, `docs/cli.md`, `AGENTS.md` or `templates/family-root/*.md` states the changed text or any of these codes. `--help` is unchanged, and no finding code is new. The docstrings of the changed functions say what each now does.

- **The validator's own text.** `_wrong_case_move` is lane 5's #839 (`fix/839-holder-bootstrap-texts`), whose guard this PR's text now matches. The validator is a pinned file and is not touched here. rv865b also found that typing the gate's own `--unset-all` lines after a hand rename of a second gitlink leaves the same orphan gitlink while the gate passes, since `_check_gitlinks_named` asks only gitlinks under `members/`. That gap is the validator's, and this PR no longer prints that rename.
- **A harmless `-f` on a folding disk.** On a disk that folds case, a second gitlink marked off disk at `Members/IRRS` resolves to the member's own checkout. So `_forced` prints a harmless `-f` there, as #828's move back does. The checkout question is asked in the path's own spelling (`present`), so no line says to delete that checkout. Fix round 4's test pins this with `Members` a link to `members`, the folding emulation `tests/test_validate_family_remedies_typed.py` uses (rv865b's round-3 NIT 1, mutant C5).
- **A second gitlink whose row's path holds index entries under it** (a gitlink below `members/IRRS`, say) rather than a gitlink at it. No rule here decides whose gitlink the variant is there, so it keeps #574's remedy. Mutant N2, which drops `_second_gitlink`'s question whether the row's path holds a gitlink, survives for that reason. It differs only there, or where the row's path is an unmerged entry whose first stage is no gitlink, which is R21's state. Each of those is a state no rule rules on, and pinning either remedy would decide it.
- **A checkout that is itself the moved directory**, beside an index entry inside it, keeps the index's remedy, as #828's test pins.
- **An unmerged entry outside the sparse definition.** `git rm --cached` of it may need `--sparse`. This was not measured, and no `--sparse` is printed for one.
- **The "stage the change" part's reason** names `git mv` and `git rm` of a gitlink (#685's text). For line 1 alone, the reason is the `git add -- .gitmodules` that follows. The text is unchanged.
- **R20 and R21 are not pinned.** R20 (`_members_is_a_file` takes HEAD's `members` of any kind for the file) differs only where HEAD holds `members` as a tree, a link or a gitlink while the index holds an unmerged file `members`: a directory/file or link/file conflict that a merge may not even leave, and whether a link in HEAD should ask for the commit too is a question of its own. R21 (`_rows_held` takes an entry with any non-gitlink stage as in the way) differs only for an entry at the row's path unmerged with a gitlink at one stage and a file at another, made only by `update-index`; whose gitlink that is, and so which remedy is right, is not ruled.

- **The closing guard kept a link to the row's own directory.** Done in fix round 2, in #839's form (rv865b's NIT 4).
- **`_sections_restored` (#828) points back every variant section of a member it moves back.** It has the fault MUST 1 found in `_sections_astray`, for a member whose gitlink is at a variant: a second variant section, another submodule's, would be pointed back too, and the path given twice. Read from the code, not measured.
- **Found in the checklist pass (t114 row 5), and done in the second commit.** `refused_untouched` and `snapshot` in the tests ask `git status`, which git 2.45.1 and later refuse while a gitlink the index holds is a symbolic link on disk. `snapshot` already took `status=False`. `refused_untouched` now takes it too, and the link test passes it, so the full snapshot is kept except `git status`. Mutant M27 fails the link test on git 2.47.1.

Review: rv865, lane 5's independent review, and rv865b's re-checks of each fix round: round 3 and its restack onto #828's squash READY at `6ef6e4c1` (issuecomment-6248254729), and round 4 (`231c404c`, the C5 pin rv865b asked for, tests only) READY with no findings (issuecomment-6248465765). CI (Brett Heap's ruling 27): H0 38e0d503 (fix round 2, the last head before the restack that CI ran in full: GitHub started no tests run on round 3's 4e92867a) passed tests, tests-git-bash and SonarCloud; the restack 6ef6e4c1 merges #828's final head, then #828's squash 9b0a703 (the branch's own side for #828's files), then main; the head 231c404c, 1 commit on the restack, has tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 629aec2b over main c9ca9a45: the family_add_member_variant, family_add_move_back_sections, family_add_spelling_leftovers, family_members_case_variants, repo_hygiene, agents_md_codes, cli_reference sets, 356 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #840

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant