Skip to content

family.py, scaffold-project.py and bump-leg.py: init's plan rows, seven refusals and bump-leg's root name each path as shown_path names it, so an ESC or a line break in a holder, folder, lock, local remote or root no longer reaches the terminal from those plan rows or those refusals' details - #814

Merged
brettheap merged 23 commits into
mainfrom
fix/766-spelled-paths
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #766 (comment)

Lands after lane 5's #731 (#692) and #746 (#672), both v1.1.0. Both have landed: #731 as d66a9054 and #746 as fc867207. This is a v1.1.1 change: it stays a draft until the v1.1.0 tag.

Stacked on

Restacked after both landed, by merging their final heads and then main. Nothing was rebased or forced.

Review the commits after 4b45c123's parent merges: 0e516bba, ef6db40d and b050dc71, plus the conflict resolutions in b1aef06c and 92aa00b9.

What changed

A path in a holder, folder, lock, local remote or root, holding an ESC or a line break, no longer reaches the terminal from those plan rows or those refusals' details. The commands a remedy prints to paste still carry it raw, inside shell quotes (see "Not changed").

#731 spelled the folder refusals with shown_source, and #746 spelled each dirty file's name. The paths beside them still printed as they were. I measured it with a stub gh (exit 97) on the stacked base 4b45c123:

  • A --work-dir or --into holding a line break and then WARNING forged-line: pwned printed that line as a line of init's plan. It did so on a dry run and on a run refused after the plan.
  • Each refusal below printed that line as a line of its own in its detail.
  • An ESC reached the terminal raw.

Across the plan and the eleven refusal sites, 43 output lines held a raw ESC or began WARNING forged-line. On this head no plan row and no refusal detail does. The 16 lines still flagged are all a remedy's shell-quoted command. The logs are probe-before.log and probe-after.log from round 1.

Each path below is now named as repo_shape.shown_path names it: as it is, and as Python's repr spells it where a character in it is not printable. That is the spelling #731 gives its sites, because shown_source returns shown_path. A path str.isprintable accepts is named byte for byte, so no existing test row changed.

  • scripts/family.py:
    • the plan's folder and landing rows (the folder and the holder);
    • family-holder-detached and family-on-tracking-branch (the holder);
    • family-landing-in-standard: the holder, the checkout, and where --into .. lands. --into {parent} is ..s, or on Windows a path no control character can be in, and is named as it is;
    • family-holder-already-here (--into and the holder in it);
    • the detail of family-index-locked (the lock);
    • family-remote-exists, for a local remote.
  • scaffold-project.py: scaffold-remote-exists, for a local remote.
  • scripts/bump-leg.py: bump-leg-root-dirty, bump-leg-root-detached, bump-leg-on-tracking-branch, and the dry run's the real run needs a branch line (the root, before its switch -c).

A GitHub <org>/<name> is named as it is. shown_path joins each file's existing from repo_shape import (...) block. No --help text changed: scripts/render-cli-reference.py --check says [ok].

The new text, measured. <P> is the probe's scratch folder. Before, each of these paths was printed raw:

landing      '<P>/A-ESC/wd-\x1b[31mRED/Orbit'   (--work-dir: no family folder)
folder       '<P>/B-FORGED/wd-\nWARNING forged-line: pwned/Orbit'   a plain folder, NOT a repository
landing      '<P>/B-FORGED/wd-\nWARNING forged-line: pwned/Orbit/Orbit'   the holder clone, inside it
REFUSED family-landing-in-standard: with neither --into nor --work-dir the holder lands where you are standing, at '<P>/C-ESC/wd-\x1b[31mRED/std/Orbit/Orbit', which is inside '<P>/C-ESC/wd-\x1b[31mRED/std', a checkout of openRepoShape itself (...)
Remediation: pass `--into ..` to land it beside this checkout, at '<P>/C-ESC/wd-\x1b[31mRED/Orbit/Orbit', or `--into <dir>` for any other parent directory; ...
REFUSED family-holder-already-here: '<P>/D-ESC/wd-\x1b[31mRED/Orbit' is already the Orbit family folder and its holder is at '<P>/D-ESC/wd-\x1b[31mRED/Orbit/Orbit', so there is nothing to init
REFUSED family-on-tracking-branch: '<P>/F-ESC/wd-\x1b[31mRED/Orbit' is on 'main', which family.yaml declares as the holder's tracking branch, and `remove` will not write a commit ...
REFUSED family-holder-detached: '<P>/F-FORGED/wd-\nWARNING forged-line: pwned/Orbit' is not on a branch, so `remove` would write a commit no branch holds, ...
REFUSED family-index-locked: the holder's index is locked: '<P>/F-ESC/wd-\x1b[31mRED/Orbit/.git/index.lock' exists. `git submodule deinit` takes no such lock, ...
REFUSED family-remote-exists: '<tmp>/wd-\x1b[31mRED/Contoso.git' already exists and has commits      (asked in process)
REFUSED scaffold-remote-exists: '<tmp>/wd-\x1b[31mRED/Atlas.git' already exists and has commits      (asked in process)
REFUSED bump-leg-root-dirty: '<P>/H-FORGED/wd-\nWARNING forged-line: pwned/Atlas' has uncommitted changes:
REFUSED bump-leg-root-detached: '<P>/H-ESC/wd-\x1b[31mRED/Atlas' is not on a branch, so there is no branch to push and open a pull request from
REFUSED bump-leg-on-tracking-branch: '<P>/H-ESC/wd-\x1b[31mRED/Atlas' is on 'main', which project.yaml declares as this project's tracking branch
the real run needs a branch: '<P>/H-FORGED/wd-\nWARNING forged-line: pwned/Atlas' is on 'main', the tracking branch. Create one first with: git -C ...

Why two rows are asked in process

Neither family-remote-exists nor scaffold-remote-exists can be reached from the command line with an ESC or a line break in the path. The clone URL is refused first, as family-unwritable-value or scaffold-unwritable-value, and that refusal already names the path with repr. Only a character that is not printable and not refused (a zero-width space, say) reaches them from the command line. Their rows run that CLI refusal as a premise, then ask _checked_reuse and _reusable_remotes in process, as #731 does for make_folder.

Tests

The new rows name a path with an ESC (wd-\x1b[31mRED) and one with a line break and then WARNING forged-line: pwned. They are POSIX only (POSIX_NAMES), and they are skipped where the disk refuses the name (unsafe_folder). Since fix round 1, the rows for family-holder-already-here and the bump-leg branch refusals also hold a printable name, PRINTABLE_NAME (wd \u00e9 and a space), named unquoted, byte for byte.

tests/conftest.py keeps the names and the helpers the rows share:

  • UNSAFE_NAMES, PRINTABLE_NAME and SHOWN_NAMES;
  • spelt and named_as;
  • unsafe_folder and copied_under;
  • assert_shown_safe and refusal_detail.

#731's ESC_NAME, FORGED_NAME, POSIX_NAMES and spelt moved there from tests/test_dry_run_parity.py, which now imports them. None of its rows changed.

file test what it pins
test_family_init_landing.py test_the_plan_names_the_folder_and_the_holder_safe_to_show (--work-dir/--into × ESC/line break) The folder and landing rows are spelled, exactly, in a dry run (exit 0) and in a real run refused after the plan (family-target-exists, exit 2). Neither stream holds an ESC or a WARNING forged-line line.
test_family_init_landing.py test_the_plan_names_a_printable_folder_byte_for_byte A folder named PRINTABLE_NAME is named as it is, with no quotes.
test_family_init_landing.py test_landing_in_a_checkout_names_its_three_paths_safe_to_show (× 2) All three paths are spelled, in the dry and the real run, exit 2. The whole output is safe to show, and nothing is created.
test_family_init_landing.py test_a_family_folder_already_here_is_named_safe_to_show (ESC, line break, printable) The detail is exactly one line, exit 2. Both paths are spelled, or, when printable, unquoted byte for byte.
test_family_tracking_branch.py test_a_holder_refused_for_its_branch_is_named_safe_to_show (tracking branch/detached × 2) remove's detail is exactly one line with the holder spelled, exit 2, and stdout is empty.
test_family_bump_remove_rollback.py test_a_held_index_lock_is_named_safe_to_show (× 2) The detail is exactly one line with the lock spelled, exit 2, and the lock is left.
test_dry_run_asks_remotes.py test_a_local_remote_that_exists_is_named_safe_to_show (family/scaffold × 2) The CLI premise is *-unwritable-value, exit 2, safe to show. In process, the detail is exactly '<remote>' already exists and has commits.
test_bump_leg_branch_and_push_line.py test_a_dirty_root_is_named_safe_to_show (ESC, line break and rv672's r<U+202E>o<U+009B>ot × bump/dry run) The detail is the spelled root plus M README.md, exit 2. No line holds a raw control from the name, and the output is safe to show.
test_bump_leg_branch_and_push_line.py test_a_root_refused_for_its_branch_is_named_safe_to_show (tracking branch/detached × ESC, line break, printable) The detail is exactly one line, exit 2. The root is spelled, or, when printable, unquoted byte for byte.
test_bump_leg_branch_and_push_line.py test_the_dry_runs_branch_line_names_the_root_safe_to_show (× 2) The dry run's line begins with the spelled root, exit 0.

34 new tests. Their results:

  • On the stacked base's code, with these tests laid over it: 30 failed, 4 passed. Every failure is at a spelling assertion. The 4 passes are the printable rows, by design.
  • On ef6db40d, and again on the restacked head 6a5dc557: 34 passed.

Mutants. I made 21 one-line mutants, and each fails at least one new test. Round 1 had 18; their run is in mutants.log, and fix round 1's in mutants-fix1.log.

Mutant New tests failed
M1 --work-dir landing raw 2
M2 folder raw 2
M3 --into landing raw 2
M4 family-holder-detached raw 2
M5 family-on-tracking-branch raw 2
M6 family-landing-in-standard holder raw 2
M7 its checkout raw 2
M8 its beside raw 2
M9 family-holder-already-here into raw 2
M10 its holder raw 2
M11 family-remote-exists raw 2
M12 family-index-locked raw 2
M13 scaffold-remote-exists raw 2
M14 bump-leg-root-dirty raw 6
M15 bump-leg-root-detached raw 2
M16 bump-leg-on-tracking-branch raw 2
M17 the dry run's branch line raw 2
M18 the plan's folder always repr 1 (the printable plan row)
P8 family-holder-already-here into always repr (rv766) 1 ([printable])
P9 its holder always repr (rv766) 1 ([printable])
P14 bump-leg-root-detached always repr (rv766) 1 ([printable-detached])

One mutant has no test: shown_path(target) if local else target made into shown_path(target). It is equivalent for every GitHub <org>/<name>, which is printable.

Targeted sets (Python 3.12, Linux):

batch files stacked base 4b45c123 c8abf44e (round 1) ef6db40d (fix round 1)
b1 test_repo_hygiene, test_cli_reference, test_agents_md_codes, test_dry_run_parity, test_dry_run_asks_remotes, test_family_refuse_up_front, test_family_init_landing, test_family_dry_run 427 passed 440 passed 441 passed
b2 test_bump_leg, test_bump_leg_branch_and_push_line, test_bump_leg_dry_run_tracking, test_bump_leg_dry_run_truth, test_bump_leg_paste_lines, test_bump_leg_unopenable, test_branch_left_bump_leg_setup, test_non_utf8_git_reads 238 passed, 1 skipped 250 passed, 1 skipped 252 passed, 1 skipped
b3 test_family, test_family_add_refusals, test_family_bump_remove_rollback, test_family_bump_to_checks, test_family_switch_branch_exists, test_family_tracking_branch, test_family_init_values, test_validate_family_branch_step, test_ruleset_hint_tracking_branch, test_tracking_branch_ref 561 passed, 19 skipped 567 passed, 19 skipped 567 passed, 19 skipped
b4 test_scaffold_leg_path_collisions, test_scaffold_e2e, test_scaffold_free_text_values, test_scaffold_mount_ignored, test_scaffold_pin_and_reuse, test_scaffold_pin_collisions, test_scaffold_reuse_seeded_legs, test_adopt_c_locale_bytes, test_chain_remedy_callers, test_pinned_copy_text, test_template_text_placeholders, test_reuse_visibility, test_update_shape_branch_any_directory, test_windows_paths 791 passed 791 passed not run again

Two notes on that table:

  • 0e516bba differs from c8abf44e only by one moved import line in tests/test_family_bump_remove_rollback.py. That file and test_repo_hygiene.py were run again on it: 108 passed.
  • Fix round 1 changes no scaffold code and adds names to conftest.py only, so b4 was not run again.

On the restacked head 6a5dc557, with b1 to b4 as above and two new batches:

batch files 6a5dc557
b1 as above 442 passed
b2 as above 257 passed, 1 skipped
b3 as above 567 passed, 19 skipped
b4 as above 791 passed
b5 test_adopt_entry_paths, test_adopt_pins, test_doctor_members_spelled, test_family_holder_states, test_manifest_validator, test_shape_doctor: the files on main that name a function, message or helper this PR touches, beyond b1 to b4 814 passed, 3 skipped, 1 xfailed
b6 test_family_bump_reachability_words, test_family_bump_to_local_only (#795) 20 passed

The skips are the same throughout. Not run: the full suite (the coordinator's 12-shard gate), macOS and Windows.

Merge checks. git merge-tree --write-tree of ef6db40d merges clean with:

In round 1, every ref that conflicted, but for one, already conflicted with the stacked base or with main alone. The exception was fix/661, which landed as dde7ef82 (#744) and so is a stale pre-squash head.

Since then, all eight refs above have landed:

The restacked head 6a5dc557 holds main 6f64eea2, and so all of them.

Fix round 1 (rv766: NOT READY)

One new commit, ef6db40d, on top of 0e516bba. Nothing was amended or forced.

  • MUST 1. The subject claimed more than the change does. It now ends "from those plan rows or those refusals' details", and "What changed" opens by saying the remedies' commands still carry the raw path. The pushed commit's message stays as it is; the squash message replaces it.
  • SHOULD 1. P8 and P9 (family.py:1528, :1529 at ef6db40d; :1895, :1896 at b050dc71) and P14 (bump-leg.py:1230 at both) survived. Each replaced shown_path(X) with repr(str(X)).
    • test_a_family_folder_already_here_is_named_safe_to_show and test_a_root_refused_for_its_branch_is_named_safe_to_show now take SHOWN_NAMES as their parameter, which adds a printable case and copies no test.
    • P8 and P9 fail [printable], and P14 fails [printable-detached]. The raw mutants of the same lines still fail the ESC and line-break cases.
    • test_the_plan_names_a_printable_folder_byte_for_byte uses PRINTABLE_NAME too. Its source had held a raw é.
  • NIT 1. The docstrings of _checked_branch and _checked_index_unlocked now say that the remedy's switch -c or rm -f still holds the path raw inside its shell quotes (Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459).
  • NIT 2. This body says "a path str.isprintable accepts is named byte for byte".

Re-verification (rv766): READY at ef6db40d. Its one new point, SHOULD 2, was text only, and is applied: the subject says "from those plan rows or those refusals' details", because family-root-missing and bump-leg-root-not-a-project still print a typed --family-root or --root raw. Both refusals are on the follow-up lines, #459's L2-104 and #458's L2-105.

Not changed

  • The commands a remedy prints to paste still hold such a name raw, quoted for a shell (shell_quoted/shlex):

    • _switch_command's git -C <holder|root> switch -c ... in family-holder-detached, family-on-tracking-branch, bump-leg-root-detached, bump-leg-on-tracking-branch, and after the dry run's branch line;
    • _add_command in family-holder-already-here;
    • the lock's rm -f;
    • _branch_said's switch -c line in add/bump/remove --dry-run.

    The rows for those refusals assert the detail line, which is exactly one line and spelled, and not the whole output. The follow-ups are on Tracking: setup, scaffold, family and the shared helpers, creating projects and families #459 and Tracking: update-shape.py and bump-leg.py, keeping a project's shape copies and leg pins current #458.

  • print(f" reuse {target} (zero commits)") in family.py and scaffold-project.py, the holder ... -> {url} plan row, and remote-unreadable's {bare}. Each names a local remote, which *-unwritable-value already guards for control characters.

  • _print_next_steps after a real init. No refusal reaches it.

  • docs/cli.md, README, AGENTS.md and the handbook. No help text or documented message changed.

Closes #766

🤖 Generated with Claude Code

Summary by Sourcery

Prevent control characters and line breaks in repository paths from reaching the terminal through initialization plans and refusal details while preserving raw paths in shell commands intended for pasting.

Bug Fixes:

  • Safely spell paths containing non-printable characters in family initialization plan rows and refusal details across family, scaffold, and bump-leg workflows, preventing terminal control sequences and forged lines from being emitted raw.

Enhancements:

  • Centralize path-safety test fixtures and helpers, and add coverage for unsafe, printable, and platform-specific path names across affected workflows.

Tests:

  • Add regression tests covering sanitized initialization plans, refusal details, local remote errors, locked indexes, branch states, dirty roots, and dry-run branch guidance.

Brett Heap and others added 10 commits October 16, 2026 14:05
… carry a name or branch that is not UTF-8, `os.fsencode` first and the disk's EILSEQ second, and two pins hold `bump-leg.py`'s UTF-8 decode and the leg finding's role, so a test no longer skips on the disk alone or passes under a decode as ASCII or a finding named by path (#663)

#461's five test lines, lane 2's L2-52 and lane 4's R423 N1 and N2 and R455 F1
and F3. Test only: no tool and no template changes, and `validate-manifest.py`,
a pinned copy, is read and not edited.

- `tests/conftest.py` holds `skip_where_a_name_cannot_be_not_utf_8`, which
  `tests/test_adopt_pins.py` and `tests/test_pin_source_unreadable.py` each
  had a copy of. It asks `skip_where_fsencode_gives_a_surrogate_no_byte`
  first (Windows' `os.fsencode` makes three bytes of U+DCFF, so no name that
  stands for a byte is carried there, though NTFS would take the file), then
  the disk (macOS answers EILSEQ); any other error is the test's own.
- `make_file` and the `--branch a\udcffb` test call it, so they skip on
  Windows too, where the disk takes the name and the child then refuses the
  branch. `test_a_branch_the_system_carries_has_no_fault` asks the same
  function instead of its own copy of the question.
- `test_a_name_that_is_utf8_under_quote_path_false_is_still_refused` asserts
  that the line naming `caf<e9>.yml` holds no literal `\x`, in any locale, so
  `bump-leg.py` decoding git's bytes as ASCII fails it.
- `_leg_branch_findings` is asked of a leg whose role is not its path, so
  naming the leg by `path` fails it.
- `tests/test_non_utf8_name_helpers.py` holds the helpers to this, with
  Windows' `os.fsencode` and macOS's EILSEQ emulated: CI is Linux only, and no
  Windows or macOS run was made.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…held by an emulation, so dropping the shared helper from it fails a test on Linux too (#663)

`test_a_branch_the_system_carries_has_no_fault` now asks
`skip_where_fsencode_gives_a_surrogate_no_byte` where it had its own copy of
the question. The new test calls it under Windows' `os.fsencode` (emulated):
a branch holding a byte-carrying surrogate is skipped, plain text and text
with an accent run. Removing the call fails the two skipped rows.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…efusals and `family-operation-in-progress` name a path as `shown_source` names it, so an ESC or a line break in a folder's name no longer reaches the terminal or begins a line of the person's choosing (#692)

#622's folder refusals printed a path as it was. `family.py init --into
<a file named wd-ESC[31mRED> --dry-run` gave `REFUSED
family-folder-cannot-be-made: the family folder .../wd-^[[31mRED/Orbit
cannot be made: ...` through `cat -v`, and a name holding a line break and
then `WARNING forged-line: pwned` printed that line as a line of its own. The
same two tools already spell a `--pin-source` that way
(`shape_materialize.shown_source`): as it is, and as Python's `repr` spells
it where a character in it is not printable.

Each path is spelled so now: both answers of `folder_cannot_be_made`, the
folder in `refuse_unmakeable_folder` and `make_folder`,
`scaffold-target-exists`, `family-target-exists`, and the mark's path in
`family-operation-in-progress`. A printable path is named byte for byte, so
no existing row changes and `why.endswith(CANNOT_WRITE_IN)` still holds.

`test_dry_run_parity.py` gains rows for an ESC and for a line break then
`WARNING forged-line: pwned` in `SCAFFOLD_CASES`, `FAMILY_CASES` and the
unwritable-folder rows, and `assert_refused` fails any forged line;
`test_family_refuse_up_front.py`'s newline-in-the-holder test now expects
the one-line `repr` form and covers an ESC and a forged line too.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ock each file already has for the folder questions, so the next import another change adds after the block does not meet it (#692)

The first commit imported `shown_source` on a line of its own after each
block. Lane 4's #660, #688 and #693 add an import of their own at the same
place, which `merge-tree` reports as add/add conflicts in
`scripts/family.py` and `scaffold-project.py`. The name goes into the
existing `from shape_materialize import (...)` block instead, in
alphabetical order. No line of behaviour changes.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…`refs/heads/<b>:refs/heads/<b>`, `bump-leg-root-dirty` spells each name, `committed` lists what git holds, and the dry run says it runs no validator, so a root on `+x` or beside a tag `main` is no longer told to force-push or push to the default branch (#672)

Five lines of #672, each measured on 35133f2 and gone on this head:

- The branch: `current_branch` read `git symbolic-ref --short HEAD`, which
  prints `heads/<b>` beside a tag of the same name. With a tag `main` the
  tracking-branch refusal was not given, the bump committed on `main` and
  printed `push -u origin heads/main`; a HEAD on `refs/tags/x` committed
  through the tag and moved it. It reads the full ref now, and only a ref
  under `refs/heads/` is a branch.
- The push: the NEXT line ended `push -u origin <branch>`, a refspec, so a
  root on `+x` was told `push -u origin +x`, which force-pushed a local `x`
  over origin's and never pushed `+x`; on `+main`, pasted, it rewound
  origin's `main`. It prints `refs/heads/<b>:refs/heads/<b>`, the form
  lane 4's #689 (`family.py`) and #674 (`update-shape.py`) print, which
  pushes the branch unforced, beside a tag of its name too, and `-u` still
  records `origin/<b>`.
- `bump-leg-root-dirty` printed C1 controls and bidi overrides raw under
  `core.quotePath=false`. Each line is `repo_shape.spelled` now, split on
  git's newlines only, so U+0085 and U+2028 stay on their line, named.
- `committed <sha>:` listed the paths staged; it lists the commit's paths
  from `git diff-tree --ignore-submodules=none`. Where the gitlink already
  records the new commit, the `commit` line and the message name the pin's
  own `commit:` as the old one, not `X -> X`, and say the gitlink does not
  move. A failure to read the paths after the commit is a sentence, never
  a refusal over a bump that landed.
- `--dry-run` runs no validator; its closing paragraph says so, and that
  the real run can still end `bump-leg-validators-red`, rolled back.

Also: the `BRANCH_LEFT` comment names its four copies (rv647's nit 3).
The ride-along (`run_validator`'s environment) is left: no verdict differs,
and the caller's `PYTHONPATH` gives the more precise finding.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… fails where a skip escapes it, `windows_fsencode` passes bytes through as `os.fsencode` does, and the no-`\x` check skips where the file name encoding is not UTF-8, so a leaked skip, an emulation that breaks on bytes and a Latin-1 host no longer pass silently or fail on a tool that is right (#663)

The review of #723 (0b1d1dd) found no MUST and three things to take.

SHOULD-1: `skip_message` had no test of its own. A skip that escapes it ends
the test SKIPPED, and no run fails on a skip, so the mutant where one escapes
gave `10 passed, 3 skipped` on the helpers file and seven emulation tests
silently skipped. `tests/test_non_utf8_name_helpers.py` now holds it with
three tests: it returns the reason a call skips with, it returns None where
the call does not skip, and it lets any other exception go on. The first
catches the escape and fails it with `pytest.fail`, because a bare assert on
an escaped skip would end that test SKIPPED too.

NIT-2: `windows_fsencode` took `str` only. `os.fsencode` passes bytes through
on every system, so the emulation now does, and two tests hold it (the
surrogate and text, and the bytes, compared with `os.fsencode` itself).

NIT-1: on a Latin-1 file name encoding `make_file` writes `caf\xe9.yml` as
the single byte 0xE9, the tool prints `caf\xe9.yml`, and the new
`"\\x" not in shown` assertion would fail although the tool is right. It is
now `assert_shown_without_hex_escapes`, which skips where
`os.fsencode(UTF8) != UTF8.encode("utf-8")` and is called after the test's
other assertions, so a skip loses none of them. Two tests hold it under an
emulated Latin-1 `os.fsencode` (it skips) and Windows' (it asserts, and fails
a line that holds a `\x`).

Tests only; no tool and no template changes.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ty list keeps git's leading ` M`, the fallback names a `git show` that lists the gitlink, and a gitlink moved by hand is not said to move, so rv672's S2 and N1-N3 no longer happen (#672)

rv672's review of 326de72, one fix round:

- S2: `current_branch` takes only git's newline off `symbolic-ref`'s
  answer. `strip()` also took a trailing U+00A0, U+2028 or U+0085, so a
  root on `feat<U+00A0>` beside a branch `feat` committed on the first and
  printed a push of the second.
- N1: `_ensure_root_clean` takes only the last newline off. `strip(" \n")`
  took the first line's leading space, so its ` M` read as `M` (staged)
  and stood a column left of the lines under it.
- N2: the `committed` fallback names `git show --name-only
  --ignore-submodules=none <sha>`, which lists the gitlink under
  `diff.ignoreSubmodules=all` as the line itself does.
- N3: where the gitlink already recorded the commit, the message's last
  sentence says the pin and the workflow references are brought to the
  gitlink in ONE commit, and no longer that the gitlink moves in it. The
  ordinary message is byte for byte what it was.

Tests: S1 parametrizes the HEAD-outside-`refs/heads/` test over
`refs/tags/release-1` and `refs/remotes/origin/main` (rv672's R1 survived
on `refs/tags/x`, whose name is as long as `refs/heads/`); N4 adds a pin
whose `commit:` is `not-a-sha` (rv672's R2); S2 and N1 each add a test,
and the dirty-list expectations in two files now read `     M ...`.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…even refusals and bump-leg's root name each path as `shown_path` names it, so an ESC or a line break in a holder, folder, lock, local remote or root no longer reaches the terminal (#766)

#692 (PR #731) spelled the folder refusals with `shown_source`, and #672
(PR #746) spelled each dirty file's name. The paths beside them were still
printed as they were. Measured with a stub `gh` on the stacked base: a
`--work-dir` or `--into` holding a line break and then `WARNING
forged-line: pwned` printed that line as a line of `init`'s plan, on a dry
run and on a run refused after it; each refusal below printed it as a line
of its own; and an ESC reached the terminal raw.

Each of these now names its path as `repo_shape.shown_path` does: as it is,
and as Python's `repr` spells it where a character in it is not printable.
A printable path is named byte for byte, so no existing test row changed.

- `scripts/family.py`: the plan's `folder` and `landing` rows;
  `family-holder-detached` and `family-on-tracking-branch` (the holder);
  `family-landing-in-standard` (the holder, the checkout, and where
  `--into ..` lands); `family-holder-already-here` (`--into` and the holder
  in it); the detail of `family-index-locked` (the lock); and
  `family-remote-exists` for a local remote.
- `scaffold-project.py`: `scaffold-remote-exists` for a local remote.
- `scripts/bump-leg.py`: `bump-leg-root-dirty`, `bump-leg-root-detached`,
  `bump-leg-on-tracking-branch` and the dry run's `the real run needs a
  branch` line (the root).

A GitHub `<org>/<name>` is named as it is. `shown_path` is imported from
`repo_shape` into each file's existing `from repo_shape import (...)`
block.

Not changed: the commands a remedy prints for a person to paste (the
`switch -c` of `_switch_command` in family.py and bump-leg.py, the `add`
of `_add_command`, the lock's `rm -f`, and the `switch -c` line of
`add`/`bump`/`remove --dry-run`). They are quoted for a shell as they were,
and still hold such a name as it is.

Tests: the new rows hold a path with an ESC and one with a line break then
`WARNING forged-line: pwned`, POSIX only, skipped where the disk refuses
the name. `tests/conftest.py` keeps the names and the helpers the rows
share; #731's `ESC_NAME`, `FORGED_NAME`, `POSIX_NAMES` and `spelt` moved
there from `tests/test_dry_run_parity.py`, which imports them. The two
remote-exists refusals cannot be reached from the command line with such a
name (the clone URL is refused first as `*-unwritable-value`, already
spelled), so their rows ask the question in process after that CLI run.

Lane: openreposhape-2 (openRepoShape-2)
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 prevents control characters and line breaks in paths from reaching terminal-facing plans and refusal details, while also hardening bump-leg’s branch/ref handling and output reporting; shared display helpers and broad regression tests cover unsafe names, unusual refs, dry runs, and commit results.

Sequence diagram for safely displaying unsafe paths

sequenceDiagram
    actor User
    participant CLI
    participant PathDisplay
    participant Terminal

    User->>CLI: Run init or bump-leg
    CLI->>PathDisplay: shown_path(path)
    PathDisplay-->>CLI: Printable path or repr-escaped path
    CLI->>Terminal: Print plan or refusal detail
    Terminal-->>User: No raw ESC or forged line break
Loading

Sequence diagram for bump-leg branch validation and reporting

sequenceDiagram
    actor User
    participant BumpLeg
    participant Git
    participant Terminal

    User->>BumpLeg: Run bump-leg or --dry-run
    BumpLeg->>Git: symbolic-ref --quiet HEAD
    Git-->>BumpLeg: Full refs/heads/<branch> or detached HEAD
    BumpLeg->>BumpLeg: Validate branch and root status
    BumpLeg->>Terminal: Print shown_path(root) in refusal or dry-run line
    BumpLeg->>Git: diff-tree --name-only -z after commit
    Git-->>BumpLeg: Committed paths
    BumpLeg->>Terminal: Print spelled committed paths and full ref push
Loading

Flow diagram for safe init planning and refusal

flowchart TD
    A[init request] --> B[Build folder and landing paths]
    B --> C[shown_path each terminal-facing path]
    C --> D[Print plan]
    D --> E{Preflight refusal?}
    E -->|Yes| F[Print escaped refusal detail]
    E -->|No| G[Create folder and holder]
Loading

File-Level Changes

Change Details Files
Sanitize user- and repository-derived paths before displaying them in plans, refusals, and folder-related errors.
  • Use shown_path or shown_source for family init plan rows, landing/refusal details, local remote errors, lock paths, and scaffold target errors.
  • Preserve printable paths byte-for-byte while escaping non-printable characters via the shared display helpers.
  • Add POSIX-focused regression coverage for ESC and line-break path names, including dry-run and refusal output.
scripts/family.py
scaffold-project.py
scripts/shape_materialize.py
tests/conftest.py
tests/test_family_init_landing.py
tests/test_family_refuse_up_front.py
tests/test_dry_run_asks_remotes.py
tests/test_dry_run_parity.py
tests/test_family_bump_remove_rollback.py
tests/test_family_tracking_branch.py
Harden bump-leg branch handling, path reporting, and generated push commands.
  • Read the full symbolic ref and accept only refs under refs/heads/, avoiding tag and remote-ref ambiguity.
  • Generate fully qualified source and destination refspecs so branch names such as +x cannot become force pushes or ambiguous refs.
  • Spell dirty-root paths and report committed paths from git diff-tree, while preserving Git status columns.
  • Correct the reported prior commit when the gitlink already points at the target and document validator behavior during dry runs.
  • Add extensive tests for unusual branch names, tag collisions, dirty filenames, commit path reporting, dry-run validation messaging, and unsafe root paths.
scripts/bump-leg.py
tests/test_bump_leg.py
tests/test_bump_leg_branch_and_push_line.py
tests/test_bump_leg_paste_lines.py
tests/test_non_utf8_git_reads.py

Assessment against linked issues

Issue Objective Addressed Explanation
#766 Spell the holder and folder paths in family.py init plan rows using shown_path, preventing control characters from being emitted raw while preserving printable paths. ✅
#766 Spell the paths named by the seven specified family and scaffold refusals, including local remote paths, using the safe path spelling. ✅
#766 Spell the root path in the specified bump-leg.py refusals and dry-run branch line using shown_path. ✅

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

brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T11:59:58Z) Independent review of #814 (#766) at 0e516bba: NOT READY

The code change is right. Every site the issue's Lines name is spelled. A printable path prints byte for byte, which I checked structurally across all three tools and not only by tests. The new rows fail on the base and pass on the head. Two things keep it NOT READY:

  • The subject says more than the change does. This is text only, with no code change.
  • Three sites still have nothing pinning the printable bytes. Three of my mutants survive there.

Ruling on the deviation (commands to paste still hold the raw path): acceptable for this PR, on two conditions.

Why it is acceptable:

  • The issue's Lines name the details, not the commands. At 66b230ed, scripts/family.py:774-784 is the two details; the remedy is at :767-771. Line 2 also calls the lock's rm -f "quoted". The commands do fall under the issue's Goal ("every path a … refusal … names") and its Verify ("no ESC and no second line starting WARNING"), so they cannot simply be dropped.
  • No single spelling is both safe to show and pasteable in every shell these tools print for. Measured here:
    • POSIX single quotes keep an ESC and a line break as they are, so the printed bytes run correctly: sh, dash and bash each exit 0 in the right folder.
    • dash 0.5.12, this host's /bin/sh, has no $'...' quoting: it prints $a\x1bb literally.
    • shell_quoted also writes for PowerShell.
    • shell_quoted is the quoter the holder's siblings.py uses too (scripts/repo_shape.py:3255-3266), so changing it is a decision for several files, not a line in this one.

The writer's reason needs one correction. "repr stops it from pasting" is only half true:

  • An ESC command does not paste today either. The terminal acts on the ESC and drops it from what a person copies. Pasted back without it, git -C exits 128 (measured).
  • The exposure is a forged line too, not only an ESC. With a line break in the path, WARNING forged-line: pwned/Orbit' switch -c family/remove-atlas prints as a line of its own. That is 8 of the 16 lines probe-after.log still flags.

The two conditions:

  1. The subject stops claiming the Goal is met (MUST 1 below).
  2. Before this lands, the two lines below are on the tracking parents. Closes #766 closes a Goal this PR does not fully meet.

For #459:

For #458:

Findings

Checked and true

Lane 2 is taking MUST 1, SHOULD 1, NIT 1 and NIT 2 in fix round 1 now, and is posting the two follow-up lines on #459 and #458. NIT 3 is handled at landing.

Lane: openreposhape-2 (openRepoShape-2)

…g-root-detached` are held to a printable path named unquoted, byte for byte, and two docstrings say the remedy's command still holds the path raw (#766)

rv766's review of PR #814, fix round 1.

SHOULD 1: three mutants survived. Each replaces `shown_path(X)` with
`repr(str(X))`, so a printable path gains quotes:
- P8 and P9: the two paths of `family-holder-already-here`;
- P14: the root of `bump-leg-root-detached`.
No row held a printable path there. `tests/conftest.py` now keeps
`PRINTABLE_NAME` (`wd é and a space`, a name `str.isprintable` accepts),
`SHOWN_NAMES` (the two unsafe names and that one) and `named_as`, which
spells a path as a line names it under each. The two tests take
`SHOWN_NAMES` as their parameter, so each gains a printable case that
asserts the path unquoted, byte for byte. P8 and P9 fail
`test_a_family_folder_already_here_is_named_safe_to_show[printable]`, and
P14 fails
`test_a_root_refused_for_its_branch_is_named_safe_to_show[printable-detached]`.
`test_the_plan_names_a_printable_folder_byte_for_byte` names its folder by
`PRINTABLE_NAME` too, where the source held a raw `é`.

NIT 1: two docstrings in `scripts/family.py` now also say that the
remedy's command still holds the path raw inside its shell quotes (#459).
They are `_checked_branch` (`switch -c`) and `_checked_index_unlocked`
(`rm -f`). No code changed.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap changed the title family.py, scaffold-project.py and bump-leg.py: init's plan rows, seven refusals and bump-leg's root name each path as shown_path names it, so an ESC or a line break in a holder, folder, lock, local remote or root no longer reaches the terminal family.py, scaffold-project.py and bump-leg.py: init's plan rows, seven refusals and bump-leg's root name each path as shown_path names it, so an ESC or a line break in a holder, folder, lock, local remote or root no longer reaches the terminal from a plan row or a refusal's detail Oct 17, 2026
@brettheap brettheap changed the title family.py, scaffold-project.py and bump-leg.py: init's plan rows, seven refusals and bump-leg's root name each path as shown_path names it, so an ESC or a line break in a holder, folder, lock, local remote or root no longer reaches the terminal from a plan row or a refusal's detail family.py, scaffold-project.py and bump-leg.py: init's plan rows, seven refusals and bump-leg's root name each path as shown_path names it, so an ESC or a line break in a holder, folder, lock, local remote or root no longer reaches the terminal from those plan rows or those refusals' details Oct 17, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T12:33:20Z) Re-verification of #814 at ef6db40d: READY

ef6db40d is one commit whose only parent is 0e516bba. 0e516bba..ef6db40d touches four files: scripts/family.py, tests/conftest.py, tests/test_family_init_landing.py and tests/test_bump_leg_branch_and_push_line.py. Nothing else changed.

Findings from the first review

New

Checked and true

Lane: openreposhape-2 (openRepoShape-2)

…-bump-leg-branch-and-push-line

#723 lands before #746, and the two conflicted in one assertion of
tests/test_non_utf8_git_reads.py::test_a_name_that_is_utf8_under_quote_path_false_is_still_refused.
This branch moved its prefix from four spaces and `M` to five (#672 keeps
the leading space of git's ` M`). #723 rewrote the same `any(...)` into
`[shown] = [...]`, `assert shown.endswith(".yml")` and a closing
`assert_shown_without_hex_escapes(shown, output)`, so a decode of git's
bytes as ASCII fails it.

The resolution takes #723's form with this branch's five-space prefix. Each
side alone fails here: #723's four-space line finds no line under #672's
output (`ValueError: not enough values to unpack`), and this branch's
`any(...)` drops the hex-escape check, so `bump-leg.py` decoding git's
bytes as ASCII passes it.

Every other hunk merged as git made it. Every test of each side stands:
test_non_utf8_git_reads.py has #746's 14 and #723's 19 test functions as
19 (#723's file is a superset of names), test_bootstrap_branch_bytes.py 18,
test_non_utf8_name_helpers.py 12, test_adopt_pins.py 44 and
test_pin_source_unreadable.py 13. The three synthetic lines #723's helper
tests feed `assert_shown_without_hex_escapes` keep their four spaces: they
are the helper's input, not the tool's output.

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

# Conflicts:
#	tests/test_non_utf8_git_reads.py
Brett Heap added 3 commits October 17, 2026 19:55
…ush-line), landed as fc86720

tests/test_dry_run_parity.py conflicted. The merge bases are 431c8cf
and a5d473e, and neither holds #731's rows, so ort's virtual base lacks
them: #731's `spelt` came back as a conflict, and its POSIX_NAMES,
ESC_NAME and FORGED_NAME block came back unflagged, beside #766's move
of all four to tests/conftest.py. The file is the same at ab55c29,
13f643d, a5139a3 and fc86720, so only #766 changed it, and it is
taken as ours, which is ef6db40's byte for byte.
scripts/bump-leg.py (two hunks) and tests/test_bump_leg_branch_and_push_line.py
(three, add/add) conflicted, each where #746's text meets #766's
addition beside it. Main holds both files exactly as #746's final head
a5139a3 does (blobs 226e7b0 and 11dc4bd at both), and this branch
already merged a5139a3, so each is taken as ours. fc86720's tree is
a5139a3's, so this merge leaves the tree as it was (f4bb551).
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…gives its two skips their own reasons, and the U+202E test fails where git fails, so a guard that skips the rest or a full disk after the `mkdir` no longer passes (#834)

Fix round on #845, after lane 4's review of b18b243 (READY, no MUST).

SHOULD 1. `REFUSALS` held ENOSPC, EROFS and EACCES, so a guard that
re-raised those three and skipped every other error passed all of the
tests. The emulated disk now answers every errno `errno.errorcode` holds
but EILSEQ (129 of them), and each caller must raise it, as Python makes it
(`PermissionError` for EACCES). The sweep stops at the first errno that
fails and names it, so a caller that goes on past a refused name runs its
test once and not 129 times. The explicit three-row families are gone, as
the sweeps cover them; the new file has 18 tests where it had 32.

SHOULD 2. The U+202E test's docstring claimed more than
`refused_then_moved` did: its `make_source_repo` call still skipped on an
`AssertionError`, so a disk that filled after the `mkdir` read as SKIPPED.
Made true and not narrowed: `refused_then_moved` takes
`skip_where_git_fails` (default True, as #757 left it for the name that is
not UTF-8, which git may not work under), and the U+202E test passes False,
as the name is valid text and git failing there is the disk. Two new tests
hold it, and a third holds the kept skip for the byte name, which stays a
known limit.

NIT 3. `make_file` gives the two skips their own reasons: "this system's
file name encoding cannot spell ..." for a `UnicodeError`, and "this
filesystem refuses a file name that is not UTF-8 (EILSEQ)" for EILSEQ. The
controls pin both.

NIT 5. The new own-directory test is renamed for its sites, so a `-k`
selection no longer picks up #757's test of the same name.

NIT 6. The module docstring keeps `skip_where_a_name_cannot_be_not_utf_8`
on one line.

NIT 4 stays: moving the emulated disk and the two fixtures into conftest
would add hunks to a file #814 and #818 also edit.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 17, 2026 20:26
…766's (#766)

#766 added `shown_path` to family.py's `from repo_shape import (...)`
block. #688, which landed on main as 42aed78, imports it on a line of
its own, with a comment that says what it is for. After the restack both
reached the branch, so the module imported the name twice.

This drops #766's addition, so the block is main's again, byte for
byte, and #688's line is the one import. Every use of `shown_path` is
inside a function, after that line. No other name in either list
changes.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T21:20:31Z) #814 is restacked after both parents landed. The head is b050dc71, and lane 2's change is unchanged.

Lane: openreposhape-2 (openRepoShape-2)

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 00:15
@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 31 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 5fc049b into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/766-spelled-paths branch October 18, 2026 00:15
brettheap pushed a commit that referenced this pull request Oct 18, 2026
Both parents have landed (#747 as 6626a3c, #738 as e62b5e1, each merged
above). This brings main up to 7b21059 (#842, #814 and #786's
validate.yml change, whose one sentence in templates/family-root/README.md
sits apart from this PR's). No conflict.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
…trap_after_bump.py` imports both `appended_to_command_scope` and `needs_a_byte_name` (#715)

Main moved from `0dee22b3` to `ca47b71d` while round 3 was paused (#846, #779, #772, #738, #842, #814, #790, #820, #774, #830 and #841). `git merge-tree` of `8b4dbcf6` with it stopped in one path:

- `tests/test_bootstrap_after_bump.py`, its imports. Main's `aec3b38f` (tests append what they tell git to the command scope) added `appended_to_command_scope` to the `from conftest import (...)` list. This branch added `from test_bootstrap_spelling import needs_a_byte_name` below it (#715, round 1). Both are kept.

`scaffold-project.py`, `scripts/bump-leg.py` and `scripts/shape_materialize.py` merged without a conflict.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request 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 added a commit that referenced this pull request Oct 18, 2026
… of `.gitmodules` back, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closing `mv` is guarded and leaves a checkout to delete by hand, and `--sparse` follows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) (#828)

Written on #767's head `bbb961f8`, as `deb83e2e`. #767 has since landed as `98f14976`, a squash holding the tree of its final head `bd6d2afe`. The branch then merged `bd6d2afe` (clean) and main `40e678e6`, taking its own whole file for #767's four files; main changed none of the five files after the squash. The head is `d4e8c65a`, and its diff against main is this PR's five files. Its file contents equal w761's predicted restack `e607c304`, which passed 63 and 207 tests; that scratch tree had lost `scripts/family.py`'s executable bit, which the head keeps as main has it (100755).

**Goal (#761): every command that the move back of a member's gitlink (#646) and the rename back of `members/` (#685) print can be typed as printed, in the state each describes, and leaves the holder passing its gate with each member where its row says.** The issue's four lines and rv685's three (issuecomment-6227887960) are all in `_members_spellings` and `_spellings_refusal` in `scripts/family.py`. Each state was first typed as printed through `add`, on the base and with each candidate direction, on git 2.43 on Linux. A disk that folds case is asked in process (`disk_as`), as #646 and #685 did; CI is Linux only.

Lane 2's independent review, rv828, read `6cf02edf` and said NOT READY. This round makes two commits:

- **`ef38aad0` merges main `bad05d21`.** It is clean as text (tree `75500249`, the tree the review tested).
- **`ce0f2a5f` is the fix.**

Main has since moved to `fc867207` (#746, `bump-leg.py` only). It merges cleanly, and it touches none of this PR's files.

### MUST 1: the merge with #684 broke two tests, and one state failed again when typed

**What failed.** #684 (`fa04d2cd`) added `_members_file_left_in_head`. `_checked_mount_path` asks it before `_members_spellings`. A `members` file that HEAD and the index hold, with nothing on disk, is now #684's `member-path-unaddable`, and this PR's set-aside of that file is never reached. On the merged tree, the four test files gave 2 failed and 173 passed (`m0-merged.log`). The two failures were `test_an_entry_at_members_itself_is_set_aside_first`, for the file deleted from disk and the file a sparse checkout keeps off disk.

For the sparse file, #684's printed `git rm --cached -- members` exits 1 on the merged code: `matched paths that exist outside of your sparse-checkout definition`. It fails the same way where the sparse checkout marks the file and where its rules leave it out (`p1-684-sparse-merged-base.log`). So the `members` half of Codex's P2 1 failed again once main was merged.

**The fix.** `_members_file_left_in_head` now prints `git rm --cached --sparse -- members`, which needs git 2.34 or later, wherever a sparse checkout marks the file or its rules leave it out. This is the review's own choice. Its first line then says "as the sparse checkout leaves it out" in place of "its deletion not staged". Everywhere else #684's text is unchanged, byte for byte (`tests/test_family_holder_states.py`: 42 passed).

Why `git rm --cached --sparse` and not the `git mv --sparse` that `_set_aside` gives an entry marked off disk: #684 allows a directory made by hand at `members`. `git rm --cached` touches no file, whatever is there.

**The tests.**

- New: `test_a_members_file_head_holds_is_taken_out_then_renamed_back`, three states: deleted from disk, kept off disk by a sparse checkout, and left out by the sparse rules. Each one types every step as printed: #684's removal and its commit, then the rename back that the next `add` prints. Then both members are where their rows say, the gate passes in every mode, and the same `add` goes through.
  - The rules state uses non-cone rules `/*`, `!/members` and `/members/`, which leave out the file `members` alone. `git sparse-checkout check-rules` confirms it: out, while `members/IRRS` stays in. This state carries `needs_check_rules`.
- Changed: `test_an_entry_at_members_itself_is_set_aside_first` keeps only the cases that still reach the set-aside: a link, a file staged new and deleted from disk, and a file staged new and kept off disk by a sparse checkout.

### SHOULD 2: a clone of its own, or a link, found on disk (taken)

New: `test_what_is_on_disk_and_no_checkout_keeps_its_mv`. It covers an untracked clone at `Members/X` (its `.git` a directory) and a link `Members/X -> ../members/IRRS`. Each gets #574's `mv -- Members/X <new name>`. Typed as printed, the `mv` moves it out whole, and the same `add` goes through. The review's surviving mutants R9 and R10 are now caught.

### SHOULD 3: an unmerged file at or under the row's path (stays)

**Not fixed.** Setting such a file aside is not cheap:

- `git mv` refuses a conflicted entry.
- An unmerged file on disk at the row's path would still stand where the update checks the member out.
- So it needs a remedy of its own, and possibly a refusal that comes first.

The review's mutant R2b still survives (`mut/R2b.log`, 78 passed). This is a checklist candidate below. I could not read #840 to say whether it is one of #840's six.

### SHOULD 4: S3776 (taken)

The parts this PR grew moved out of the two functions. The text is unchanged: every typed and compared test passes.

- **`_spellings_refusal`** lost:
  - the set-aside part, now `_set_aside_part`;
  - the move back, now `_move_back_parts` and `_move_back_line`;
  - its closing sentences, now `_closing_sentences`;
  - the `each` closure, now `_each`.
- **`_members_spellings`** lost:
  - the row bookkeeping, now `_rows_held`;
  - the cached sparse questions, now `_SparseAsked`;
  - the set-aside call, now `_in_the_way`.

These counts come from my own AST estimate (`f828r2/cogc.py`), not from Sonar:

| Function | main `bad05d21` | `6cf02edf` | `ce0f2a5f` |
|---|---|---|---|
| `_spellings_refusal` | 52 | 88 | 51 |
| `_members_spellings` | 48 | 71 | 49 |

Each new helper scores 13 or less: `_members_file_left_in_head` 13, `_set_aside` 12, `_move_back_line` 11, `_set_aside_part` 10, `_rows_held` 9, `_move_back_parts` 6, `_in_the_way` 5, `_closing_sentences` 0.

### NIT 5: surviving mutants R3 and R20 (taken)

New: `test_only_a_file_or_link_in_a_moves_way_is_set_aside`, run in process. A file at `members` with no gitlink moved back is not set aside (R20). A gitlink at `members` is never set aside, since taking it out of the index would un-register what it records (R3). Both mutants are now caught.

The unmerged file at `members` belongs to SHOULD 3's candidate.

### NIT 6: duplication (taken)

The three staged set-aside tests are now one table, `STAGED_ASIDE`, under the first test's name, `test_what_is_set_aside_is_committed_with_the_move`. Its states are on disk, kept off disk by a sparse checkout, and deleted from disk.

### NIT 7: the shell dialect (followed)

The guarded `mv` is now to be typed "in a POSIX shell (Git Bash on Windows)". This is the review's suggestion, and the same words `family.py` already uses for its other POSIX-only line (`rm -rf`). The text stays POSIX-only, per #532. No PowerShell form is printed, since the sentence before it tells a folding Windows disk to type nothing.

### Runs

Every run went through `run9.sh`, with basetemps `v110/bt/f828r2-*` and logs in `v116/f828r2/`. The git is 2.43 unless the table says otherwise.

| Log | Tree | Files | Result |
|---|---|---|---|
| `m0-merged.log` | the merge `ef38aad0` | the four test files this PR changes | 2 failed, 173 passed (MUST 1) |
| `t1.log` | `ce0f2a5f`'s contents | the four test files this PR changes, and `test_family_holder_states.py` | 224 passed |
| `g247.log` | `ce0f2a5f`, git 2.47.1 | `test_family_add_move_back_sections.py`, `test_family_add_member_variant.py`, `test_family_holder_states.py` | 120 passed |
| `w1.log` | `ce0f2a5f`'s contents | the files that call the changed functions (`test_family_add_excludes.py`, `test_family_add_refusals.py`, `test_family_dry_run.py`, `test_family_head_lost_gitmodules.py`, `test_family_member_path.py`, `test_family_refuse_up_front.py`, `test_readme_windows_quoting_sentence.py`, `test_shape_doctor.py`, `test_validate_family_remedies_typed.py`, `test_validate_family_unnamed_mounts.py`) and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 959 passed, 3 skipped, 1 xfailed |
| `w2a.log` and `w2b.log` | `ce0f2a5f` | the other 42 files that run `family.py` | 1173 passed, 20 skipped; 1364 passed, 1 skipped |

The skips are environmental:

- PowerShell lines run only on a Windows host.
- One test needs a disk that ignores case.
- Templates that quote a value skip some cases.

CI's git 2.55 was not run here.

### Fails on the merged base

These are the commit's two changed test files, run against `ef38aad0`'s `family.py` (`base-final-merged.log`): 53 failed, 25 passed.

- **Behaviour.** The two sparse states of the new #684 test fail when typed: `git rm --cached -- members` exits 1.
- **Text.** The other 51 failures are expected text: the closing sentence now says Git Bash, and #684's sparse line changed.
- **Passing on the merged base:** five of the new cases. They pin behaviour that already held there:
  - #684's own deleted-from-disk state;
  - the clone and the link;
  - the two nothing-aside states.

### Mutants

These ran through `mutate2.py` in a scratch clone of `ce0f2a5f`'s contents (`mut/summary.txt`, one log each). **19 of 20 are caught. R2b survives, as SHOULD 3 says.**

| Mutant | What it undoes | Result |
|---|---|---|
| N1 | #684's `--sparse` not asked of the mark | caught |
| N2 | #684's `--sparse` not asked of the rules | caught |
| N3c | #684's removal without `--sparse`, the test expecting it | caught: `git rm --cached -- members` refused, `exist outside of your sparse-checkout definition` |
| N4 | #684's first line unchanged for the sparse file | caught, 2 failed |
| N5 | the closing sentence without "(Git Bash on Windows)" | caught, 4 failed |
| R9 | a clone of its own taken for a checkout | caught: "Delete by hand" printed |
| R10 | a link to a checkout taken for a checkout | caught |
| R3 | a gitlink at `members` set aside | caught |
| R20 | the set-aside asked where no gitlink is moved back | caught |
| R2b | an unmerged file counted as in the way | **survives** (78 passed); SHOULD 3 |
| M1, M7, M8, M10, M11c, M12c, M13c, M15, M17 | round 1's mutants, at the code's new places | each caught |
| M18 | the move's commit blind to what is set aside (`_move_back_parts`) | caught |

### Merge checks

`git merge-tree --write-tree` of `ce0f2a5f`:

- **Clean:** main `fc867207`, `fix/684-e4-reftable-operation` `a3683127` and `fix/686-bump-reachability` `17bbbaba`.
- **`fix/766-spelled-paths` `ef6db40d` (#814) conflicts** only in `tests/test_dry_run_parity.py` and `tests/test_non_utf8_git_reads.py`. This PR does not touch either file. The same conflict appears between #814 and main, as the review found. `scripts/family.py` merges cleanly.

### Checklist candidates

- **An unmerged file at or under a row's path** (SHOULD 3). A member's gitlink at a variant then gets #574's remedy, which takes the gitlink away from its row. The same goes for an unmerged file at `members`.
- **`validate-family.py`'s `_wrong_case_move`** (#747, `6626a3c5`) still prints the guard without the "nothing there" test, so its `mv` exits 1 where nothing is at the variant. The candidate is carried from round 1.

Codex reviewed `d4e8c65a` (review 5558731894) and left two P2 findings. **Both hold.** Each was reproduced on `d4e8c65a` by typing the printed remedy, and each is fixed. A third defect in the same function turned up while testing the fix, and it is fixed too. Main is not merged; the branch's diff against main is still this PR's five files.

### P2 1: a sparse checkout decided whose the gitlink was (`scripts/family.py:3409`)

**What failed.** `_members_spellings` dropped a row from `blocked` before building `owners` if a sparse checkout marked any of the row's files (`skip-worktree`) or its rules left one out. The member's own gitlink at the variant then stopped counting as the member's own, and got #574's remedy instead. The state: `git mv members/IRRS Members/IRRS`, committed, plus a committed `members/IRRS/notes`. Typed as printed on `d4e8c65a` in four states (marked on disk, marked off disk, left out by the rules on disk, left out off disk; `r1-p2-1-base.log`):

- printed: `git mv -- Members/IRRS <new name>`, and commit the rename;
- after typing it, `.gitmodules` said `submodule.members/IRRS.path renamed-1`, and HEAD held `members/IRRS` as a tree with no gitlink. The member was un-registered.

The same filter applied to a file at `members` itself. After `git mv members Members`, with that file kept off disk by a sparse checkout, the file was not set aside. The move back's `git update-index` then failed with `error: 'members/IRRS' appears as both a file and as a directory`, after the `git rm` had already taken the variants out (`r1b-members-marked-base.log`).

**The fix.** Both filters are gone, so the sparse checkout no longer decides whose the gitlink is. `_set_aside` now names each entry in the way by the same rule the index's own remedies use:

| The entry in the way | Printed | Plain git, measured with 2.43 and 2.47.1 |
|---|---|---|
| marked, on disk | `git mv -- <entry> <new name>` | plain `git mv` takes it |
| marked, off disk | `git mv --sparse -- <entry> <new name>`, which needs git 2.38 or later | plain `git mv` and `git rm --cached` refuse it, exit 1 |
| left out by the rules, its bit cleared, on disk | `git mv --sparse -- <entry> <new name>` | plain `git mv` and `git rm --cached` refuse it, exit 1 |
| left out by the rules, its bit cleared, off disk | `git rm -r --cached --sparse -- <entry>`, which needs git 2.34 or later | plain `git rm --cached` refuses it, exit 1; `git mv`, with or without `--sparse`, says `bad source`, exit 128 |

Each refusal says `exist outside of your sparse-checkout definition`. The probe logs `probe1-243.log` and `probe1-247.log` match line for line. A new remedy, `ASIDE_SPARSE`, prints the `git mv --sparse` line. `ASIDE_OUT` takes `--sparse` where the rules leave its entry out, as `UNSTAGE` already did.

### P2 2: the closing `mv` failed where nothing was at the variant (`scripts/family.py:3931`)

**What failed.** The guard was `[ X -ef Y ] || [ -e X/.git ] || mv -- X ../X-flat`. Where nothing is at `X`, both tests are false, and `mv` exits 1 with `mv: cannot stat 'Members/IRRS': No such file or directory`, so a pasted remedy stopped before the update. `typed_as_printed` hid this, because it skipped any `mv` whose source was absent. With only that helper changed to type every `mv` as printed, `d4e8c65a` failed four tests on that line (`r2-helper-only-base.log`):

- `kept off disk by a sparse checkout` (Codex's `kept_off_disk`);
- `nothing on disk in that spelling`;
- `with nothing left to commit`;
- `unmerged, with nothing in progress`.

**The fix.** The guard now begins with a test for "nothing there":

`[ ! -e X ] && [ ! -L X ] || [ X -ef Y ] || [ -e X/.git ] || mv -- X ../X-flat`

Nothing there moves nothing and exits 0. A link there that points nowhere, which `-e` treats as nothing, is still moved, as the old guard moved it. Measured in dash with nothing there: the old guard exits 1 and the new one exits 0. With a link that points nowhere, the new guard exits 0 and moves the link. The sentence after the command now reads "which moves nothing where nothing is there, where the two names are one directory or where it holds a .git entry, a checkout".

`typed_as_printed` now types every `mv`, guarded or not, exactly as printed, and each must exit 0. 13 typed lists in the three test files now include the guarded `mv` that the helper used to skip.

### Found on the way: a commit printed with nothing to commit

Mutant M6 below first survived, and the reason was a defect already in `d4e8c65a`. `_set_aside` said a removal leaves a change to commit wherever HEAD holds anything at the entry's path. Take a staged `git mv members/IRRS Members/IRRS`, then a new file staged at `members/IRRS` and deleted from disk. HEAD holds the member's gitlink at that path, and the move puts the same gitlink back, so nothing is left to commit. The remedy still printed ", and commit that", and `git commit` failed with nothing to commit (`p2-base.log`, on `d4e8c65a`). Now HEAD's gitlink at a row the move puts back no longer counts. **This is outside Codex's two findings.** It is one expression in the function this round already changes.

### Tests

`tests/test_family_add_move_back_sections.py` now has 34 cases. The new ones:

- `test_a_blocker_a_sparse_checkout_marks_or_leaves_out_is_set_aside_too`, four states (P2 1). The two "left out" states use non-cone rules that leave out `members/IRRS/notes` alone, and carry `needs_check_rules`. The "marked, off disk" state skips below git 2.38.
- `test_an_entry_at_members_itself_is_set_aside_first[a file a sparse checkout keeps off disk]` (P2 1, the file at `members`).
- `test_what_a_sparse_checkout_keeps_off_disk_is_committed_with_the_move`: a staged new file, marked and off disk, is renamed with `git mv --sparse`, and the commit is printed.
- `test_a_removal_where_head_records_the_gitlink_leaves_no_commit`: the defect found on the way.
- `test_a_link_pointing_nowhere_at_the_variant_is_still_moved_out`, for the guard's `-L` test. It uses the layout where HEAD alone holds the variant. With the variant's gitlink still in the index, git 2.47.1's own `git status` refuses the link (`expected submodule path 'Members/IRRS' not to be a symbolic link`). The first version of this test failed that way on 2.47.1 (`g247.log`).

Changed:

- In `tests/test_family_add_member_variant.py`: `typed_as_printed`, `guarded()`, the closing sentence in `moved_back()`, and the typed lists.
- In `tests/test_family_add_move_back_sections.py`: `set_aside()`, which now gives the `--sparse` forms.
- In `tests/test_family_add_spellings_remedies.py`: one typed list.

### Runs

Every run went through `run9.sh`, with the real `gh` unreachable and basetemps under `v110/bt/f828-*`. The logs are in `v116/f828/`. The git is 2.43 unless the table says otherwise.

| Log | Tree | Files | Result |
|---|---|---|---|
| `r1.log` | the commit's contents, before the link test was reworked | the four test files this PR changes | 175 passed |
| `r3.log` | `6cf02edf` | `test_family_add_move_back_sections.py` | 34 passed |
| `g247.log` | as `r1.log`, git 2.47.1 | the sections file, `test_family_add_member_variant.py` | 70 passed, 1 failed (the link test's first layout, since reworked) |
| `g247-link.log`, `g243-link.log` | `6cf02edf`'s contents | the reworked link test, git 2.47.1 and git 2.43 | 1 passed each |
| `w1.log` | `6cf02edf`'s contents for every file it reads | the files that call the changed functions (`test_family_add_excludes.py`, `test_family_add_refusals.py`, `test_family_dry_run.py`, `test_family_head_lost_gitmodules.py`, `test_family_member_path.py`, `test_readme_windows_quoting_sentence.py`, `test_shape_doctor.py`, `test_validate_family_unnamed_mounts.py`) and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 862 passed, 3 skipped, 1 xfailed |
| `w2a.log` | `6cf02edf` | 21 more files that run `family.py` | 1218 passed, 20 skipped |
| `w2b.log` | `6cf02edf` | the other 21 files that run `family.py` | 1312 passed, 1 skipped |
| `refresh-predicted.log` | `6cf02edf` merged with main `d66a9054` (a scratch tree, not pushed) | the four test files this PR changes | 175 passed |

The skips are environmental: PowerShell lines on a Windows host only, a disk that ignores case, and templates that quote a value.

### Fails on d4e8c65

`base-final.log`: `d4e8c65a`'s `family.py` with this commit's tests gives 56 failed, 59 passed. All 8 new cases fail. The other 48 failures are tests whose expected text now includes the new guard; 4 of those also fail by behaviour with only the helper changed (`r2-helper-only-base.log`, above).

### Mutants

`mutate.py`, in a scratch clone. Each mutant undoes one new hunk, runs the test named, and restores the file. Mutants marked "c" change the test's expected text to match, so the failure shows behaviour, not just text. All 20 are caught (`mut/summary.txt`, one log each). M1-M14, M16 and M17 ran on a tree that differs from `6cf02edf` only in docstrings. M15 and M12c ran on `6cf02edf`'s contents.

| Mutant | What it undoes | Result |
|---|---|---|
| M1 | the sparse filter on `blocked` put back (P2 1) | caught, 4 failed: `git mv -- Members/IRRS <new name>` printed again |
| M2 | the sparse filter on a file at `members` put back | caught, 1 failed |
| M3, M3c | on disk, plain `git mv` though the rules leave it out | caught; M3c: `git mv` refused, `exist outside of your sparse-checkout definition` |
| M4, M4c | off disk, taken out of the index though marked | caught, 2 failed; M4c: `git rm --cached` refused |
| M5, M5c | no `--sparse` on the removal though the rules leave it out | caught; M5c: `git rm --cached` refused |
| M6 | a rename aside off disk leaves no change to commit | caught by `test_what_a_sparse_checkout_keeps_off_disk_is_committed_with_the_move` |
| M7 | the `git mv --sparse` part not printed | caught, 2 failed |
| M8 | the removal's `--sparse` not printed | caught |
| M9 | an entry set aside with `--sparse` listed among the variants | caught, 2 failed |
| M10 | the set-aside part blind to `ASIDE_SPARSE` | caught, 2 failed |
| M11c | the guard without `[ ! -e X ]` | caught: the empty directory is left at the variant, and the next `add` refuses it |
| M12c | the guard without `[ ! -L X ]` | caught: the link is left at the variant |
| M13c | the guard as `d4e8c65a` printed it | caught, 7 failed: `mv: cannot stat 'Members/IRRS'` |
| M14 | the sentence without "where nothing is there" | caught, 4 failed |
| M15 | `_set_aside` told nothing of the sparse checkout | caught, 3 failed |
| M16 | `ASIDE_SPARSE` left out of `REMEDY_ORDER` | caught, 4 failed |
| M17 | HEAD's gitlink at the row's path counted as a change to commit | caught by `test_a_removal_where_head_records_the_gitlink_leaves_no_commit` |

### Merge checks

`git merge-tree --write-tree` of `6cf02edf`: **clean** against main `d66a9054` and against every open branch that changes `scripts/family.py`:

- `fix/684-family-holder-states` `fdc90a6e`
- `fix/686-bump-reachability` `cf5e64b3`
- `fix/688-family-init-values` `6958dfbc`
- `fix/689-next-push-refspec` `15c3b5ab`
- `fix/766-spelled-paths` `ef6db40d`

It is also clean against the branches updated during this round: `fix/674`, `712`, `762` and `768`, and `l3-733`. No other open branch changes the four test files.

### Left out

- **`validate-family.py`'s `_wrong_case_move`** has the same guard and the same failure where nothing is at the variant: `mv` exits 1. It landed with #747 as `6626a3c5`. It is a pinned file, so the fix is for a follow-up, not this PR.
- **The two "left out" tests use non-cone sparse rules** (`git sparse-checkout set --no-cone`). CI's git 2.55 was not run here; git 2.43 and 2.47.1 were.

### What was wrong

`git mv members/IRRS Members/IRRS` moves the checkout, the gitlink and the section's `path`. The move back put the gitlink back at `members/IRRS` in the index alone, and left the section naming `Members/IRRS`. Typed as printed at bbb961f:

- committed: `git submodule update --init --recursive` fails with `fatal: No url found for submodule path 'members/IRRS' in .gitmodules`. The gate exits 1 with `gitmodules-path-unnamed` in every mode, and `add` goes through.
- staged: the same failure, and the gate refuses `member-uninitialized`, exit 2.
- a fresh recursive clone of the committed state: the update checks nothing out at `members/IRRS`, and the gate exits 1.

### What changed

- **Each section that gives a member another spelling of its row's path is pointed back.** `_sections_restored` names those sections on the member's move back (`Spelled.sections`). It reads them from the file that `_submodule_paths` reads, the work tree's, which is the one `git config -f` edits. The move's parenthesis gains, per section, `git config -f .gitmodules submodule.<name>.path <row path>`, then `git add -- .gitmodules`.
- **The commit is printed only where one is left to make.** The move is a change to commit where HEAD's own `.gitmodules` gives the section another path (`_head_section_paths`, read with `git config --blob`). After a staged move, nothing differs from HEAD once the lines are typed, so no commit is printed; `git commit` would fail there with nothing to commit.
- **`.gitmodules` is asked first wherever a section is pointed back**, not only beside a gitlink still in the index (`_checked_mount_path`). So `gitmodules-sparse` comes first where a sparse checkout marks the file, since `git config -f` would then write a new file holding one line.

### A defect found on the way

In the staged state, a commit or an untracked file of its own in the checkout at `Members/IRRS` makes the move's `git rm -r --cached --ignore-unmatch -- Members/IRRS` refuse, exit 1: `the following file has staged content different from both the file and the HEAD`. It takes the gitlink with `-f`, which with `--cached` touches no file. The removal is now printed with `-f` where the index stages the variant at a commit HEAD does not record there and a checkout is on disk at it, with a clause saying what `-f` does. Everywhere else the line is the validator's word for word, as before.

### What was wrong

After `git mv members/IRRS Members/IRRS`, a committed file at `members/IRRS`, or under it, made the gitlink at the variant look like a second gitlink, so it kept #574's remedy, `git mv -- Members/IRRS <new name>`. Typed as printed, that moves the member's gitlink and its section to `renamed-1`: the member is un-registered, the gate refuses, exit 2, and `add` goes through. In #646's own state (the gitlink moved in the index alone, the checkout still at `members/IRRS`), a file planted at `members/IRRS` got `git rm -r --cached -- Members/IRRS`, which takes the member's gitlink out.

### What changed

Where the index holds only files and links at or under a row's path, each at stage 0, and none marked or left out by a sparse checkout, a gitlink at a variant is still the member's own. Each of those entries is set aside first (`ASIDE`, `ASIDE_OUT`), then the gitlink is moved back, in the same commit. An entry on disk as the index holds it is renamed with `git mv`; any other is taken out of the index, which touches no file. So the history never holds the member's gitlink beside the file, nor the file gone and the gitlink not back.

The other order fails, measured. With a file at the row's path, the move back first replaced the file without a word, and the `git mv` of that path then took the member's gitlink away. With a file under it, the move back stopped at exit 128 (`appears as both a file and as a directory`).

### What was wrong

After `git mv members Members`, a file or link at `members` was never named: planted, committed and deleted from disk, or a committed link `members -> Members`. Each `git update-index` of the move back failed with `'members/IRRS' appears as both a file and as a directory`, exit 128, after the `git rm` had taken the variants out, and the commit printed after it removed both members' gitlinks.

### What changed

The same set-aside part names the entry at `members` wherever a gitlink is moved back. A link is renamed with `git mv`, as `_members_is_a_file` moves `members`; a file deleted from disk is taken out of the index.

### What was wrong

The closing `mv -- Members/IRRS ../Members-IRRS` moved out of the holder a checkout that `git mv` or `git submodule add` made. Its `.git` file names its store by a relative path (`../../.git/modules/members/IRRS`), which the move leaves pointing nowhere: #681's N-1, which #747 fixed in the validator.

### What changed

- **Each closing `mv` is #747's guarded form**, typed in a POSIX shell: ``[ Members/IRRS -ef members/IRRS ] || [ -e Members/IRRS/.git ] || mv -- Members/IRRS ../Members-IRRS``. It moves nothing where the disk takes the two names for one directory (macOS, Windows, WSL's /mnt/c), and it never moves a checkout. The text is the same on every disk, and the old pair of sentences ("on a case-sensitive disk ...", "on macOS and Windows ...") is gone.
- **The text says where a checkout's commits are kept**: in the holder's `.git/modules/`, whatever becomes of the checkout. It names `git -C <variant> status` to read what is not committed, and says to delete such a checkout by hand once nothing only it holds is needed. A clone of its own (`.git` a directory) is moved out whole, by hand, and `git -C <variant> log --oneline HEAD --branches --not --remotes` lists what it has not pushed.
- **The update comes last.** Measured: a checkout at the variant that `git mv` made shares the member's store, and `git submodule update --init --recursive`, checking the member out at the row's path, points that store's `core.worktree` there. `git -C Members/IRRS status` then printed the row's clean checkout, not the variant's file that was never committed. Read before the update, it names that file. This is the one order that differs from the validator's `_wrong_case_move`.
- **A checkout that `add` finds on disk and untracked later is never moved.** `_moved_or_kept` tells it apart (a directory, not a link, holding a `.git` FILE), and a new remedy, `CHECKOUT`, says to delete it by hand once nothing only it holds is needed. A clone of its own keeps the `mv`, which moves it whole.
- `MOVE_BACK`, for a `members/` renamed by hand, is unchanged. Measured, `mv -- Members members` keeps each checkout working, since each relative `.git` file then points where it did.

### What was wrong

`git mv members Members`, staged, then `git rm --cached Members/IRSS`. HEAD records `members/IRSS` and the index holds it nowhere, so nothing named it. The rename back was printed. Typed, it left IRSS's section at `Members/IRSS`, `add` committed that `.gitmodules`, and the gate refused `member-commit-unresolvable`, exit 2. With the rename committed instead, the move back from HEAD was printed, and it left both sections at `Members/` with (e)'s gap.

### What changed

A row is found `IN_GITMODULES` ("in .gitmodules, its gitlink's removal staged") where HEAD records its gitlink at the row's path, the index holds nothing at or under it, and a section gives another spelling of it. The line that moves the other gitlinks back puts it back too, at the commit HEAD records, and its section is pointed back. The rename back is not printed beside it (`_renamed_with_git`), since it would leave that section naming `Members/`. The staged state takes `-f`, since the checkouts are on disk, and no commit; the committed state takes the commit.

### What was wrong

`git mv members Members`, committed, and a committed `MEMBERS/notes`. The index held two spellings, so `_renamed_with_git` gave up and the move back was printed, with (e)'s section gap: the update failed, and the gate exited 1.

### What changed

The spelling to rename back is the one that holds the members' own gitlinks, and there must be exactly one. What the index holds under a third spelling keeps its own remedy, which runs first in `REMEDY_ORDER`. On Linux that is `git mv -- MEMBERS/notes <new name>`. On a folding disk, where the three spellings are one directory and `MEMBERS` is never on disk in its own spelling, it is `git rm -r --cached -- MEMBERS/notes`, which touches no file, and the file goes back with the rest. The rename back replaces only the entries under its own spelling.

### What was wrong

A cone-mode sparse checkout leaves `Members/` out, and an entry there has its skip-worktree bit cleared. `add` printed plain `git mv` for it on disk and plain `git rm --cached` off disk. git 2.43 asks the sparse rules, not the bit. Measured with plain git:

- plain `git mv` refuses such an entry on disk, file or directory (`exist outside of your sparse-checkout definition`, exit 1), and `git mv --sparse` takes it;
- plain `git rm --cached` refuses one off disk the same way, and `--sparse` takes it;
- the move back's `git rm --cached --ignore-unmatch` passes over such a gitlink with exit 0, as it does one marked;
- `git mv` of a directory holding one refuses, exit 1.

### What changed

Each `--sparse` is printed where the rules leave the entry out, as well as where the bit marks it. The rules are asked once, and only where they matter (`_sparse_rules_exclude`, which asks `git sparse-checkout check-rules`). So an entry on disk gets `git mv --sparse`, one off disk gets `git rm -r --cached --sparse` with the git it needs, the move back's removal gets `--sparse`, and no rename back is printed where an entry under the spelling is left out. A git that cannot answer `check-rules` leaves the bit alone to decide, as before. The `RENAME_SPARSE` wording now says "what the sparse checkout leaves out", in place of "keeps off disk".

**New file `tests/test_family_add_move_back_sections.py`, 26 cases.** Each typing test types every printed command as printed with `typed_as_printed`. That helper now runs each guarded `mv` through `sh -c`, types each `git config` line, and does each hand step the text names at its place. Each test then checks that every member is registered where its row says, that the gate passes in every mode, and that the same `add` goes through with the gate passing after.

- (e): committed and staged, with a commit and an uncommitted file in the checkout, each kept (the commit in the store, the file read by `git -C Members/IRRS status` before the checkout is deleted); a fresh recursive clone; both disks in process; and `gitmodules-sparse` named first.
- (g): a file at the row's path and one under it, renamed aside; and #646's state with the checkout at the row's path, the file taken out of the index.
- (h): a committed file `members` deleted from disk, and a committed link `members -> Members`.
- Line 4: the hand step skipped, which shows `git -C Members/IRRS status` printing nothing after the update though the uncommitted file is there. The next `add` names the checkout to delete by hand, with no `mv`.
- rv685's candidate 1, staged and committed; the third spelling, typed on Linux and asked in process on both disks; the sparse rules, for a file on disk, a file off disk and a member's own gitlink on disk, for a gitlink renamed with `--sparse`, and for the rename back withheld. These sparse tests need `check-rules` and skip on a git without it.
- What each rule turns on: a section that HEAD moved makes the commit; what is set aside makes the commit; both removals staged with no `.gitmodules` on disk; and a checkout under an index entry stays the index's to name.

**Changed tests:**

- `tests/test_family_add_member_variant.py`: `moved_back` builds the new text, with sections, staged removals, `-f`, and the five closing sentences. It gains `guarded`, `point_back` and `typed_in_sh`. Its layouts type the guarded `mv`, the hand step and the update last. The table of what is the member's own now expects the move back after the set-aside part for (g)'s two states.
- `tests/test_family_add_spellings_remedies.py`: the folding test's Linux branch expects both sections, and "another spelling in the index too" leaves the not-renamed-back table, since that state is renamed back now.
- `tests/test_family_members_case_variants.py`: the `RENAME_SPARSE` wording, and the two new fields in a full-tuple comparison.

Every run went through `run9.sh` (the real `gh` unreachable, the git identity blanked), with basetemps under `v110/bt/w761-*`, on git 2.43 and Python 3 on Linux.

| Run | Tree | Files | Result |
|---|---|---|---|
| `batchA.log` | before the last three small edits | 34 more of the files that import or run `family.py` (`test_family.py`, `test_family_refuse_up_front.py`, `test_non_utf8_git_reads.py` and the rest) | 1582 passed, 20 skipped |
| `batchB.log` | the same | `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_shape_doctor.py`, `test_setup_project_py.py`, `test_bump_leg.py`, the four `update_shape` files and 8 more | 1716 passed, 4 skipped |
| `r2.log` | the same | the issue's Verify files: `test_family_add_member_variant.py`, `test_family_add_spellings_remedies.py`, `test_family_members_case_variants.py` | 141 passed |
| `r3.log` | the same | `test_family_add_refusals.py`, `test_family_head_lost_gitmodules.py` | 103 passed |
| `f1b.log` | this commit, `PYTHONHASHSEED=0` | the new file, `test_family_add_member_variant.py` | 63 passed |
| `f2b.log` | this commit | `test_family_add_spellings_remedies.py`, `test_family_members_case_variants.py`, `test_repo_hygiene.py` | 181 passed |
| `s1.log` | this commit, `PYTHONHASHSEED=1` | the new file | 26 passed |
| `s2.log` | this commit, `PYTHONHASHSEED=2` | the new file's four order-sensitive cases | 4 passed |
| `base2.log` | `bbb961f8`'s `family.py`, this commit's tests | the new file | 25 failed, 1 passed |
| `rs1.log` | the predicted restack onto main (tree `e607c304`) | the new file, `test_family_add_member_variant.py` | 63 passed |
| `rs2.log` | the same | `test_family_add_spellings_remedies.py`, `test_family_members_case_variants.py`, `test_family_add_refusals.py`, `test_family_head_lost_gitmodules.py` | 207 passed |

- **Fail on base:** 25 of the new file's 26 cases fail on `bbb961f8`'s code. The one that passes, `test_a_checkout_under_an_index_entry_is_the_indexs_to_name`, guards the disk filter's new `CHECKOUT` branch: it keeps what the base already did (M22 shows it catches the branch dropped).
- **The last three small edits**, after the two batches: one closing sentence reads "a checkout there that git submodule add or git mv made", a comment says a git without `check-rules` leaves the bit alone to decide, and the order fix. The order fix: `rows` is a set, so where two members' removals were staged, the order of the `IN_GITMODULES` entries, and of the text naming them, followed the set's hash (one run printed `Members/IRSS` before `Members/IRRS`); `staged_out` now walks the rows sorted. The runs at this commit cover the files that compare that text.
- The three sparse tests carry `needs_check_rules` from `test_family_refuse_up_front.py`, so a git without `git sparse-checkout check-rules` skips them rather than failing.
- `render-cli-reference.py --check`: ok. No line of the README, the handbook, `AGENTS.md` or `docs/cli.md` names the changed text, so none changed.

26 mutants, one per new hunk, each run `-x` on the test named. Round 1 (`mutate.py`, `mut/mutants.txt`) ran M1-M25 on the tree before the last three small edits. M7, M8, M20 and M21 were run again at this commit (`mutate2.py`, `mut2/mutants.txt`), since the order fix below touched M7's line and M20's test was the one that showed the order bug; M26 undoes that fix. Round 2 ran with `PYTHONHASHSEED=0`, the seed whose order the bug printed. All 26 are caught, each by its own assertion (read in its log).

| Probe | What it undoes | Result | The test that fails |
|---|---|---|---|
| M1 | no section lines | caught, 1 failed (round 1) | `test_a_mount_moved_with_git_mv_is_moved_back_with_its_section` |
| M2 | HEAD's section no commit | caught, 1 failed (round 1) | `test_a_section_head_moved_is_a_change_to_commit` |
| M3 | no blocked rows | caught, 1 failed (round 1) | `test_a_file_at_or_under_the_rows_path_is_set_aside_first` |
| M4 | no members entry set aside | caught, 1 failed (round 1) | `test_an_entry_at_members_itself_is_set_aside_first` |
| M5 | aside always out of the index | caught, 1 failed (round 1) | `test_a_file_at_or_under_the_rows_path_is_set_aside_first` |
| M6 | aside forces no commit | caught, 1 failed (round 1) | `test_what_is_set_aside_is_committed_with_the_move` |
| M7 | no staged removal found | caught, 1 failed (this commit) | `test_a_members_removal_staged_beside_the_rename_is_moved_back` |
| M8 | rename back past a staged removal | caught, 1 failed (this commit) | `test_a_members_removal_staged_beside_the_rename_is_moved_back` |
| M9 | one spelling of all held again | caught, 1 failed (round 1) | `test_a_third_spelling_keeps_its_own_remedy_beside_the_rename_back` |
| M10 | replace every index entry | caught, 1 failed (round 1) | `test_a_third_spelling_keeps_its_own_remedy_beside_the_rename_back` |
| M11 | plain git mv though left out | caught, 1 failed (round 1) | `test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit` |
| M12 | no --sparse on git rm --cached | caught, 1 failed, 1 passed (round 1) | `test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit` |
| M13 | move back's --sparse from the bit alone | caught, 1 failed, 2 passed (round 1) | `test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit` |
| M14 | rename back past a left-out entry | caught, 1 failed (round 1) | `test_the_rename_back_is_withheld_where_the_rules_leave_an_entry_out` |
| M15 | a checkout moved like any other | caught, 1 failed (round 1) | `test_a_checkout_left_at_the_variant_is_named_to_delete_by_hand` |
| M16 | closing mv unguarded | caught, 1 failed (round 1) | `test_each_layout_moves_the_gitlink_back_as_printed` |
| M17 | the update first again | caught, 1 failed (round 1) | `test_a_mount_moved_with_git_mv_is_moved_back_with_its_section` |
| M18 | no -f | caught, 1 failed, 1 passed (round 1) | `test_a_mount_moved_with_git_mv_is_moved_back_with_its_section` |
| M19 | no gitmodules-sparse first | caught, 1 failed (round 1) | `test_a_sparse_gitmodules_is_named_first_where_a_section_is_pointed_back` |
| M20 | .gitmodules asked only beside an index gitlink | caught, 1 failed (this commit) | `test_both_removals_staged_with_no_gitmodules_on_disk` |
| M21 | staged removal's checkout named twice | caught, 1 failed (this commit) | `test_a_members_removal_staged_beside_the_rename_is_moved_back` |
| M22 | the disk filter for a checkout | caught, 1 failed (round 1) | `test_a_checkout_under_an_index_entry_is_the_indexs_to_name` |
| M23 | CHECKOUT part dropped | caught, 1 failed (round 1) | `test_a_checkout_left_at_the_variant_is_named_to_delete_by_hand` |
| M24 | aside part dropped | caught, 1 failed (round 1) | `test_a_file_where_the_disk_holds_the_checkout_is_taken_out_first` |
| M25 | aside left in the first line | caught, 1 failed (round 1) | `test_a_file_where_the_disk_holds_the_checkout_is_taken_out_first` |
| M26 | staged rows in the set's order | caught, 1 failed (this commit) | `test_both_removals_staged_with_no_gitmodules_on_disk` |

`git merge-tree --write-tree`, after a fetch at 16:02Z on 2026-10-17.

- **This commit, `deb83e2e`:** clean against #767's final head `bd6d2afe`, #670's `6d7e4ff0`, and the open branches that change these files from #767's base: `fix/684-family-holder-states` `fdc90a6e`, `fix/686-bump-reachability` `cf5e64b3`, `fix/688-family-init-values` `6958dfbc`, `fix/689-next-push-refspec` `15c3b5ab`, `fix/692-raw-folder-paths` `ab55c29f` and `fix/766-spelled-paths` `ef6db40d`. Against main `40e678e6`, and the branches already restacked onto it, it conflicts in the four files #767 changed: the stacked-PR conflict the restack above resolves.
- **The predicted restack, `263d60e8`:** clean against `bd6d2afe`, `fix/693-local-remote-dir` `b5a6c6a1`, and every branch above. Against the wave-7 branches it is clean with `fix/757`, `763`, `768`, `777` and `796` to `801`. `fix/758`, `759`, `760`, `762` and `764` conflict with it only in files this PR does not touch (`tests/conftest.py`, `install.sh`, `validate-family.py`, the `bootstrap.py` copies and their tests), where main has moved since those branches were cut.
- Every open branch that touches one of these five files is clean against the restack.

- **The validator's own move back** (`templates/family-root/scripts/validate-family.py`, `_wrong_case_move`) still prints the update before the hand step, which meets the store trap above where a checkout at the variant shares the member's store. Its `git rm --cached` may refuse a staged variant beside its checkout's work, as `add`'s did; that was not measured for the validator. Both are for the validator's own issue, not this one.
- **A section that gives a case variant of a row's path while the gitlink is at the row's path** (the state #646's old move back left, typed) is not named by `add`, which goes through; the validator refuses it.
- **#574's `git mv` of a directory target** moves an untracked checkout under it too. Not measured.
- **A case variant of a row's path under `members/` itself** (`members/irrs`) is not named by `add` (read from the source, not run).

Review: lane 2's independent review and its re-verification of ce0f2a5, READY (issuecomment-6240244762). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 90649e5 passed tests, tests-git-bash and SonarCloud; the head 3546118 is a clean update-branch merge of main c5d0bde, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 635a3da over main c5d0bde: the family_add_member_variant, family_add_move_back_sections, family_add_spellings_remedies, family_members_case_variants, install_sh, openreposhape_command, repo_hygiene, agents_md_codes, cli_reference sets, 975 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 #761

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…ile` skips on EILSEQ or a `UnicodeError` only and the U+202E directory has no guard, so a full disk or a read-only `tmp_path` fails the test, not SKIPPED (#834) (#845)

Stacked on #806 (`fix/757-non-utf8-test-dirs`, `76ae6419`); retarget to main once it lands. Tests only: nothing a user runs changes, and no pinned file changes.

One new commit on `9ba530a9`, one test file, one line moved. Main is not merged and neither is #806.

**SHOULD 1 (SonarCloud python:S5778, MAJOR): done, the reviewer's way.** The `pytest.raises(pytest.fail.Exception)` block in `test_the_sweep_names_the_first_errno_that_fails_and_stops_there` held two calls that could raise, `Disk(first)` and `raised_for_every_errno(...)`. `disk = Disk(first)` is now built before the block, and the block holds `raised_for_every_errno(disk, hides_the_error, "a path")` alone. The assertions after the block are unchanged and read the same exception: `failed.value.msg` is still the `Failed` that `raised_for_every_errno` raises, and `asked` is still the list the caller was asked. The file's other `pytest.raises` block (in `the_error_of`) already held one call.

### Runs

Through `run9.sh`, Python 3.12.3. Logs are in the scratchpad's `v118/w834/`.

- The two files, `tests/test_non_utf8_oserror_sites.py` and `tests/test_non_utf8_name_callers.py`: 55 passed (`t-two.log`), as the reviewer measured.
- The seven changed or importing files (`tests/test_non_utf8_git_reads.py`, `tests/test_adopt_template_values.py`, `tests/test_non_utf8_name_helpers.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_non_utf8_oserror_sites.py`, `tests/test_adopt_git_reads.py`, `tests/test_family_init_asks_every_value.py`): 311 passed, 1 skipped (`t-B.log`), the same as round 2. The skip is the one in the PR body, from the run script's blank `user.name`; it does not happen in lane 4's runs.
- `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`: 147 passed (`t-C.log`).

### Mutants

The four label and fail-fast mutants of round 2 are all still caught, each by `test_the_sweep_names_the_first_errno_that_fails_and_stops_there` alone, with the new line in place (`tmutants-summary.log`). A fifth, from lane 4's table, is caught too.

| Mutant | Tests that fail |
|---|---|
| the sweep does not pass the label | 1 |
| `the_error_of` drops the label on the way | 1 |
| `fail_where_the_caller_ends` ignores the label | 1 |
| the sweep swallows each failure and goes on | 1 |
| the label goes after the message (lane 4's) | 1 |

The first one reads `- errno 1 (EPERM): the caller ended SKIPPED: hidden` against `+ the caller ended SKIPPED: hidden`.

### Merge checks

`git merge-tree --write-tree` of `8f6a38e3` against main `9f235da2`, #806 (`76ae6419`) and `9ba530a9`: clean. #778 is as before: the one `from conftest import` line of `tests/test_doctor_validator_verdicts.py`, which no round of this PR touches after `b18b243e`.

One new commit on `efb17273`, two test files. Main is not merged and neither is #806.

**SHOULD 1 (SonarCloud python:S8714 on `raised_for_every_errno`): done, the reviewer's way.** The `except pytest.fail.Exception` caught a failure only to fail again with the errno's name in front. The label is now passed in:

- `fail_where_the_caller_ends` (`tests/test_non_utf8_name_callers.py`) takes a keyword-only `said`, put in front of its message, with `""` as the default.
- `the_error_of` threads `said` through, and the loop passes `said=f"errno {code} ({errno.errorcode[code]}): "`. The loop has no `try`.
- Nothing else changes. A failing errno still reads `errno 1 (EPERM): the caller ended SKIPPED: ...`, and the sweep still stops at the first errno that fails, since the failure ends the loop. The three other callers of `fail_where_the_caller_ends`, all in `tests/test_non_utf8_name_callers.py`, pass no `said` and are not touched: the only hunk in that file is the function's own.

A failing errno still names itself, in a run's output. This is the mutant that re-raises only ENOSPC, EROFS and EACCES in `make_file`, on this commit:

```
E       Failed: errno 1 (EPERM): the caller ended SKIPPED: this filesystem refuses a file name that is not UTF-8 (EILSEQ): [Errno 1] Operation not permitted: ...
```

**One new test** holds the label and the fail-fast, `test_the_sweep_names_the_first_errno_that_fails_and_stops_there`: a caller that skips where it should raise must fail with exactly `errno <first> (<NAME>): the caller ended SKIPPED: hidden`, and be asked once. It passes on `efb17273` as well, by design: that commit already gave the same message, and this round keeps it. So it cannot fail there. It fails on the mutants below that drop the label or the fail-fast.

**NIT 2 stays, and is #854.** Under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`, `test_execute_refuses_a_local_source_directory_holding_a_bidi_control` fails with a `UnicodeEncodeError` from `parent.mkdir()`, on `efb17273` and on main alike. It is filed as #854. This round does not change it, and the new test of the sweep does not touch that directory.

### Runs

Through `run9.sh`, Python 3.12.3. Logs are in the scratchpad's `v117/w834/`.

- The seven files that import the changed ones or are changed (`tests/test_non_utf8_git_reads.py`, `tests/test_adopt_template_values.py`, `tests/test_non_utf8_name_helpers.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_non_utf8_oserror_sites.py`, `tests/test_adopt_git_reads.py`, `tests/test_family_init_asks_every_value.py`): 311 passed, 1 skipped (`s-B.log`). The skip is the one in the PR body, on the base too. The count is the 310 of the last round plus the one new test.
- `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`: 147 passed (`s-C.log`).
- The four `non_utf8` files together under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`: 110 passed, 15 skipped, all the ASCII file name encoding (`s-D-clocale.log`).

### Mutants

Ten, each applied to `9ba530a9`'s test files and caught (`smutants-summary.log`):

| Mutant | Tests that fail |
|---|---|
| `make_file` skips on any `OSError` again | 4 |
| `make_file`: denylist of ENOSPC, EROFS, EACCES | 2 |
| `test_cli_reference.py`: a `try` that re-raises 13, 28 and 30 | 1 |
| `test_doctor_validator_verdicts.py`: the same | 1 |
| U+202E `mkdir`: the same | 1 |
| `conftest.py` helper: the probe's guard is a denylist | 3 |
| the sweep does not pass the label | 1 |
| `the_error_of` drops the label on the way | 1 |
| `fail_where_the_caller_ends` ignores the label | 1 |
| the sweep swallows each failure and goes on to the next errno | 1 |

The first six are the earlier mutants that the sweep kills. The last four are new, and a failing run of the first label mutant reads `- errno 1 (EPERM): the caller ended SKIPPED: hidden` against `+ the caller ended SKIPPED: hidden`.

### Merge checks

`git merge-tree --write-tree` of `9ba530a9` against main `190133d5`, #806 (`76ae6419`) and `efb17273`: clean. Against #778 (`fe7b30f6`): the one `from conftest import` line of `tests/test_doctor_validator_verdicts.py`, as before, and this round does not touch that file.

One new commit on `b18b243e`, three test files. Main is not merged and neither is #806. Each point of the review, and what was done:

**SHOULD 1: `REFUSALS` held three errnos, so a guard that re-raised those three and skipped the rest passed. Done.** The emulated disk (`a_disk_that_refuses`) now answers every errno that `errno.errorcode` holds but EILSEQ (129 here) and each caller must raise it, as Python makes it (`PermissionError` for EACCES). The errnos are drawn from `errno.errorcode` and not listed, so one nobody thought of is asked too. The sweep stops at the first errno that fails and names it, so a caller that goes on past a refused name runs its test once and not 129 times. The three-row families are gone, since the sweeps cover them: the new file has 18 tests where it had 32. Both of the reviewer's surviving mutants now fail, with the same mutants and the same run:

- `make_file` with `exc.errno in (EACCES, ENOSPC, EROFS)` in place of `!= EILSEQ`: 2 tests fail (was 0).
- `test_cli_reference.py` with a `try` that re-raises 13, 28 and 30 and skips the rest: 1 test fails (was 0).
- Three more of the same kind also fail: the doctor test's `mkdir`, the U+202E `mkdir`, and the helper in `conftest.py` (a denylist there fails 3 tests, so the sweeps also hold #663's helper to it).

**SHOULD 2: the U+202E docstring claimed more than `refused_then_moved` did. Made true, not narrowed.** The reviewer measured the gap with a `git` that fails `commit` as a full disk does. I measured it the same way, on `b18b243e` and on this commit:

- `b18b243e`: `1 skipped`, "git will not work under '\u202ecaf' here: error: unable to write file .git/objects/xx: No space left on device".
- `efb17273`: `1 failed`, `AssertionError: error: unable to write file ...`, raised at `tests/conftest.py:883`.

Why true and not narrowed: the second skip exists for a name that is not UTF-8, which git may not work under (#757 kept it for that). U+202E is valid text, and git works under it wherever it works at all (reasoned for NTFS and APFS, not run). So the skip there hides a disk that filled after the `mkdir`, which is this issue's whole point, and the fix is one keyword. `refused_then_moved` takes `skip_where_git_fails` (default true, so the byte-name test is as #757 left it), and the U+202E test passes false. Three new tests hold it:

- `test_a_failure_of_git_under_the_bidi_directory_fails_the_test`: a `make_source_repo` that raises the `AssertionError` must reach the test as a failure. It fails on `b18b243e`.
- `test_a_failure_of_git_under_the_byte_directory_still_skips_the_test`: the control, and the known limit. The byte-name test still skips there, and a full disk after its `mkdir` still reads as SKIPPED.
- `test_a_disk_that_refuses_the_bidi_directory_fails_the_test` is now a sweep over every errno.

The docstrings of both tests and of `refused_then_moved` say which skip stays and why.

**NIT 3: done.** `make_file` gives the two skips their own reasons: "this system's file name encoding cannot spell ..." for a `UnicodeError`, and "this filesystem refuses a file name that is not UTF-8 (EILSEQ)" for EILSEQ. The controls assert each one, and that the encoding reason does not say "filesystem". Both fail on `b18b243e`, and a mutant that restores either old wording fails.

**NIT 4: stays.** The emulated disk is still written in four places. Moving `a_disk_that_refuses`, `fail_where_the_caller_ends` and the two fixtures into `tests/conftest.py` adds hunks to a file that #814 and #818 also edit, and Sonar reports 0.0% duplication on new code. It is a follow-up, once those land.

**NIT 5: done.** The own-directory test is now `test_the_cli_reference_and_doctor_tests_fail_where_the_disk_refuses_their_own_directory`, which names its sites. A `-k` selection no longer picks up #757's test of the old name.

**NIT 6: done.** The module docstring keeps `skip_where_a_name_cannot_be_not_utf_8` on one line.

### Tests

The new file passes 18 of 18 on this commit. On `b18b243e`, with only the new file put in, 3 fail and 15 pass. The 3 are the two NIT 3 reasons and the U+202E git failure. The other 15 are mutant-killers for SHOULD 1, and they pass on code that is correct.

Runs on Linux (Python 3.12.3) through `run9.sh`:

- `tests/test_cli_reference.py`, `tests/test_doctor_validator_verdicts.py`, `tests/test_bootstrap_leg_moves.py`: 79 passed.
- The changed files and the files that import them, `tests/test_non_utf8_git_reads.py`, `tests/test_adopt_template_values.py`, `tests/test_non_utf8_name_helpers.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_non_utf8_oserror_sites.py`, `tests/test_adopt_git_reads.py` and `tests/test_family_init_asks_every_value.py`: 310 passed, 1 skipped. The skip is the one named in the PR body (`test_plan_names_the_elector_it_took_from_git_config`, on the base too).
- `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`: 147 passed.
- The four `non_utf8` files together: 124 passed. Under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`: 109 passed, 15 skipped, and every skip is the ASCII file name encoding.

**Mutants.** 21 in all, one or more per hunk, each applied to the fixed tree and caught. The first eleven are the earlier table, rerun on the new tests (each fails 1 to 4 tests). The ten new ones:

| Mutant | Tests that fail |
|---|---|
| `make_file`: denylist of ENOSPC, EROFS, EACCES (reviewer's) | 2 |
| `test_cli_reference.py`: a `try` that re-raises 13, 28, 30 (reviewer's) | 1 |
| `test_doctor_validator_verdicts.py`: the same | 1 |
| U+202E `mkdir`: the same | 1 |
| `conftest.py` helper: the probe's guard is a denylist | 3 |
| `make_file`: the `UnicodeError` reason says "filesystem" again | 1 |
| `make_file`: the EILSEQ reason does not name EILSEQ | 1 |
| `refused_then_moved`: skips on git's failure whatever the caller asks | 1 |
| U+202E test: does not ask for git's failure to raise | 1 |
| byte-name test: asks for git's failure to raise | 1 |

### Merge checks

`git merge-tree --write-tree` of `efb17273` against main `6f64eea2`, #806 (`76ae6419`) and `b18b243e`: clean. Against the 57 open `fix/*` and `l3-*` branches: 50 clean. The seven that conflict are six of the seven that conflicted at `b18b243e` (`fix/712-validate-pins-contracts` moved and is clean now) plus `fix/835-config-count-appends`, which conflicts in `tests/test_adopt_git_reads.py` as #818 does against #806. Every one conflicts the same against #806 or main, with one exception, below. The exception is #778, which conflicts with this branch only, on the one `from conftest import` line of `tests/test_doctor_validator_verdicts.py`, as at `b18b243e`: this round does not touch that file. This round changes no conflict region against #814 (one region in `tests/test_non_utf8_git_reads.py`, before and after).

### Not changed, and a candidate

- **A candidate, not filed:** under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`, `test_execute_refuses_a_local_source_directory_holding_a_bidi_control` fails on `b18b243e` and on #806 (`UnicodeEncodeError` from `parent.mkdir()`: the ASCII encoding cannot spell U+202E). #757 skips three tests of `tests/test_non_utf8_git_reads.py` that way with `skip_where_the_environment_cannot_carry`. The new git-failure test asks it for U+202E and skips there, so this round adds no failure to a C locale. The test of the directory itself is not changed, because the review did not raise it.
- The two checklist candidates in the PR body stand, narrowed: `refused_then_moved` no longer belongs to the first one for U+202E, only for the byte-name test.

Five tests make a directory or a file named with a byte that is not UTF-8, or with U+202E. Each turned ANY `OSError` into `pytest.skip`, beside the one helper #663 added, `skip_where_a_name_cannot_be_not_utf_8`. So a full disk, a read-only `tmp_path` or a folder nobody can write in read as SKIPPED, and no run fails on a skip. CI never has one of those, so nothing failed. #806 moved five other tests onto the helper and left these.

Each line of #834, with the direction chosen:

1. **`tests/test_cli_reference.py`, `test_a_checkout_whose_path_is_not_utf8_is_a_checkout`.** It asks the helper on `tmp_path`, then makes its directory with no `try`.
2. **`tests/test_doctor_validator_verdicts.py`, `test_a_name_that_is_not_utf8_is_never_an_exception_quoted_as_a_finding`.** Same: the helper first, then `mkdir(parents=True)` unguarded. The docstring said "a disk that refuses one (APFS) skips"; it now says which skip and that every other error fails.
3. **`tests/test_bootstrap_leg_moves.py`, `test_a_branch_whose_name_is_not_utf8_is_read_back_on_every_run`.** The helper replaces the `write_bytes` probe that skipped on `OSError` and `ValueError`. The Windows `skipif` stays.
4. **`make_file` in `tests/test_non_utf8_git_reads.py`.** It keeps the `UnicodeError` skip (an ASCII file name encoding, #757's second line) and skips an `OSError` only where `errno` is `EILSEQ` (macOS). ENOSPC, EROFS and EACCES are raised, as the helper does for its `mkdir`. Every caller of `make_file` inherits the failure and not the skip. #757's `test_a_write_the_disk_refuses_still_fails_the_make_file_test` (in `tests/test_non_utf8_name_callers.py`) expected the `AssertionError` of the test's own "not a skip" assert; it now expects the `OSError` itself, with its errno.
5. **`tests/test_adopt_template_values.py`, the U+202E directory.** The `try` is dropped, so `mkdir` raises. The issue offered the other way, narrowing to EILSEQ and naming the system that refuses U+202E. None is known: U+202E is valid text. A `mkdir` of a name that starts with U+202E succeeds on this machine's overlay filesystem (measured), and NTFS and APFS are reasoned to hold it (read, not run on those two). So a narrowing would guard a refusal nobody has seen.

`tests/test_non_utf8_oserror_sites.py` is new: 32 tests. It calls each site as a plain function, with the disk answering as it would on a system CI does not run (CI is Linux only, #597):

- EILSEQ on the probe (macOS) skips the three tests that ask the helper, on the helper's reason.
- EACCES, ENOSPC and EROFS on the probe, on the test's own directory, on `make_file`'s write and on the U+202E directory raise. The test asserts the error and its errno, and fails if the call skipped or ended without one.
- An `os.fsencode` that gives a surrogate three bytes (Windows, emulated with `windows_fsencode`) skips the three helper callers before the disk is touched, and nothing is made under `tmp_path`.
- `make_file` still skips on EILSEQ and on a `UnicodeError` (two controls).

The disk is emulated by `os.mkdir` and `Path.write_bytes`, the two calls the base and the fix use to make a name, so the same row fails on the base for the reason it should. It reuses the two fixtures, `PROBE` and `fail_where_the_caller_ends` of #806's `tests/test_non_utf8_name_callers.py`. No Windows or macOS run was made.

Runs, on Linux (Python 3.12.3), under `run9.sh`:

- `tests/test_non_utf8_oserror_sites.py`, `tests/test_non_utf8_name_callers.py`, `tests/test_non_utf8_name_helpers.py`: 86 passed.
- `tests/test_cli_reference.py`, `tests/test_doctor_validator_verdicts.py`, `tests/test_bootstrap_leg_moves.py`: 79 passed.
- Those two sets with `tests/test_non_utf8_git_reads.py`, `tests/test_adopt_template_values.py`, `tests/test_adopt_git_reads.py` and `tests/test_family_init_asks_every_value.py` (the files that import a changed module): 324 passed, 1 skipped. The skip is `test_plan_names_the_elector_it_took_from_git_config`, which skips on the base too: the run script's blank `user.name` overrides its `GIT_CONFIG_GLOBAL`.
- `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`: 147 passed. `render-cli-reference.py --check` passes.
- `tests/test_non_utf8_git_reads.py` under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`: 40 passed, 12 skipped; every skip is the ASCII file name encoding, as before. Normal locale: 52 passed.

**Fails on the base.** On `76ae6419` with only the two test files put in, 32 of the 68 tests of `tests/test_non_utf8_oserror_sites.py` and `tests/test_non_utf8_name_callers.py` fail: 30 of the new 32 and the 2 changed ones. The other 36 pass: the 2 new controls, which hold what both keep, and the 34 that are unchanged.

**Mutants.** One per hunk, each applied to the fixed tree and caught:

| Mutant | Tests that fail |
|---|---|
| `test_cli_reference.py`: `except OSError` put back around the `mkdir` | 3 |
| `test_cli_reference.py`: helper call removed | 5 |
| `test_doctor_validator_verdicts.py`: `except OSError` put back | 3 |
| `test_doctor_validator_verdicts.py`: helper call removed | 5 |
| `test_bootstrap_leg_moves.py`: the old `write_bytes` probe back | 5 |
| `test_bootstrap_leg_moves.py`: helper call removed | 5 |
| `make_file`: skips on any `OSError` again | 8 |
| `make_file`: EILSEQ no longer a skip | 1 |
| `make_file`: `UnicodeError` no longer a skip | 1 |
| `test_adopt_template_values.py`: `except OSError` put back around the U+202E `mkdir` | 3 |
| `test_non_utf8_name_callers.py`: the old `AssertionError` expectation back | 2 |

`git merge-tree --write-tree` of this head against origin/main (`fc867207`, which has #746), against `bad05d21`, against #806 and against all 52 open `fix/*` and `l3-*` branches: clean, except seven branches.

- **#778 (`l3-707-way-in-policy`) conflicts in one place, and only with this commit.** Both PRs add a name to the `from conftest import ...` line of `tests/test_doctor_validator_verdicts.py` (#778 adds `git`, this one adds the helper). Keep both names. #778 is clean against main and against #806.
- **#818 (`fix/758-git-command-scope`)** conflicts in `tests/test_adopt_git_reads.py`, which this commit does not touch. It conflicts the same against #806 alone, and is clean against main.
- **#814 (`fix/766-spelled-paths`)** conflicts in `tests/test_non_utf8_git_reads.py` and `tests/test_dry_run_parity.py`. It conflicts the same against main and against #806, with one region in each file with and without this commit.
- **`fix/712-validate-pins-contracts`, `fix/798-adopt-legs-asked-first`, `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip`** conflict in files this commit does not touch, and conflict the same against main and #806.

The scan of `tests/` for an `except` that names `OSError` and is followed by `pytest.skip` lists 31 other places. As #834 says, most ask something else (a symlink, a hard link, `locale -a`, a NEL or a line feed in a folder name) and are not changed here. Two kinds are the same defect as this issue's and are not lines of it. Nothing is filed; they are for the checklist.

- **A skip on an `AssertionError` of a git call, kept by #757 as a second skip.** `refused_then_moved` (`tests/test_adopt_template_values.py`), `test_a_utf8_clone_of_a_source_whose_path_is_not_utf8_adopts` (`tests/test_adopt_git_reads.py`) and `test_a_source_directory_that_is_not_utf8_is_shown_spelled` (`tests/test_adopt_plan_escapes.py`) skip with "git will not work under ..." when `make_source_repo` raises `AssertionError`. A disk that fills after the directory is made fails that call the same way, and the test reads as SKIPPED. For the U+202E test of line 5 this means the `mkdir` now fails on a full disk, and a disk that fills a moment later can still skip it.
- **A skip on any `AssertionError`, `UnicodeError` or `OSError` where the branch name is not UTF-8.** `with_default_branch` and `moved_to_a_branch_that_is_not_utf8` (`tests/test_adopt_git_reads.py`) make the branch with `git`, and skip if it fails for any reason.

Review: lane 4's RV845, READY on b18b243 (issuecomment-6240020694), on efb1727 (issuecomment-6240528662) and on 9ba530a (issuecomment-6241540556); 8f6a38e applies RV845's S5778 fix exactly as RV845 probed it. `fc3cb5e9` restacks it onto #806's squash `d6071c7d` with three merges and the same `-U0` lines. CI (Brett Heap's ruling 27): H0 8f6a38e passed tests, tests-git-bash and SonarCloud; the head fc3cb5e merges #806's final head, then #806's squash d6071c7 (the branch's own side for #806's files), then main, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree b572ef4 over main 576e170: the adopt_template_values, bootstrap_leg_moves, cli_reference, doctor_validator_verdicts, family_bootstrap_gate_order, family_unnamed_gitlink, non_utf8_git_reads, non_utf8_name_callers, non_utf8_oserror_sites, pin_validator, validate_family_unnamed_mounts, repo_hygiene, agents_md_codes, cli_reference sets, 524 passed, 1 skipped. 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 #834

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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