Skip to content

bump-leg.py: the branch is read in full and pushed as refs/heads/<b>:refs/heads/<b>, dirty names are spelled, committed lists what git holds, and the dry run says it runs no validator (#672) - #746

Merged
brettheap merged 9 commits into
mainfrom
fix/672-bump-leg-branch-and-push-line
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #672 (comment)

bump-leg.py read the root's branch with --short, printed a push line that + could turn into a forced push, printed control characters from file names raw, listed paths the commit did not hold, and said nothing about the validators a dry run skips. Each of #672's five lines was measured on main 35133f29 with a scaffolded project and local bare remotes, and each is gone on this head. One file of code changes: scripts/bump-leg.py.

Fix round: #723 merged in (4a014a79)

This branch now holds #723 (#663, fix/663-non-utf8-name-tests, head 6ba8c5d2) and is stacked on it. #723 lands first. Until then this PR's diff against main also shows #723's six test files. Once #723 lands, the PR is retargeted and restacked, and its own diff is #672's again.

One new commit, a merge commit: 4a014a79 merges 6ba8c5d2 into a5d473ee. It changes no tool code. scripts/bump-leg.py is byte for byte the same as at a5d473ee.

The conflict

The two PRs both changed one assertion in tests/test_non_utf8_git_reads.py::test_a_name_that_is_utf8_under_quote_path_false_is_still_refused:

The resolution keeps #723's form and uses #746's five-space prefix. Neither side alone works here:

Taken as is Result on the merge
the resolution 2 passed
#723's line (four spaces) 2 failed: ValueError: not enough values to unpack (expected 1, got 0); #672's output has no such line
#746's any(...) 2 passed, but the hex-escape check is gone
the resolution, with bump-leg.py decoding as ASCII 2 failed: assert '\\x' not in ' M .git...xc3\\xa9.yml'
#746's any(...), with bump-leg.py decoding as ASCII 2 passed: the mutant is missed

Every other hunk merged the way git merged it.

Every test of each side stands

Test functions per file, before and after the merge:

File #746 #723 merge
tests/test_non_utf8_git_reads.py 14 19 19
tests/test_bootstrap_branch_bytes.py 15 18 18
tests/test_non_utf8_name_helpers.py (none) 12 12
tests/test_adopt_pins.py 44 44 44
tests/test_pin_source_unreadable.py 13 13 13

#723's names include all of #746's, and every one is in the merge. The merge holds #723's other five files exactly as #723 has them. In tests/test_non_utf8_git_reads.py it differs from #723 only in #746's three hunks.

#723's helper tests feed assert_shown_without_hex_escapes three made-up lines. Those lines still start with four spaces, because they are the helper's input, not the tool's output.

Each side's half is needed (tests/test_non_utf8_git_reads.py has 52 tests):

In both runs the 7 failures are the tests that read the dirty list:

  • the two refused_dirty tests, in both modes;
  • the resolved test, in both modes;
  • test_the_refusal_names_every_dirty_file_whatever_its_bytes.

Tests on the merge

Run on the tree 4f4fed60, which is the tree of 4a014a79:

Merge checks

git merge-tree --write-tree of the merge:

What changed, and why

  1. The branch is read in full. current_branch ran git symbolic-ref --short HEAD. Beside a tag of the same name that prints heads/<b>.
    • Measured on main: with a tag main beside the tracking branch main, bump-leg-on-tracking-branch was not given. The bump committed on main and printed push -u origin heads/main.
    • A HEAD on refs/tags/x was read as the branch x, and the bump committed through the tag and moved it.
    • Now the full ref is read and refs/heads/ is taken off. A ref outside refs/heads/ is no branch, so that root is refused bump-leg-root-detached.
  2. The push line names the branch on both sides. NEXT ended push -u origin <branch>. A bare branch name is a refspec, and a leading + means force.
    • Measured on main: a root on +x was told push -u origin +x. Pasted, it printed + e124684...c8932ad x -> x (forced update) and never pushed +x.
    • On +main, pasted, it printed + a6b763e...c8932ad main -> main (forced update): origin's default branch was rewound.
    • Now the line is push -u origin refs/heads/<b>:refs/heads/<b>. Measured: +x and +main push as new branches and leave origin's x and main where they were. v1 beside a tag v1 pushes too, where git's own bare v1 fails with src refspec v1 matches more than one. -u still records origin/<b>.
  3. bump-leg-root-dirty spells each name. Under core.quotePath=false git prints a file name as its raw bytes. On main a tracked, edited caf<U+009B><U+202E>evil.yml went to stderr as the raw bytes 302 233 342 200 256.
    • Each listed line now goes through repo_shape.spelled, so this one shows as caf\x9b\u202eevil.yml.
    • The answer is now split on \n only. splitlines() also broke a name at U+0085 and U+2028, and strip() dropped one from the end of the last name.
  4. committed lists what the commit holds, and no X -> X.
    • The line printed the staged list, and the leg is always staged. Measured on main: after a person moved the gitlink by hand and committed, it printed committed ...: spec, contracts/spec-pin.yaml while the commit held only the pin. The report and the commit message both said 52b767b3490a -> 52b767b3490a.
    • Now the paths come from git diff-tree --no-commit-id --name-only -r -z --ignore-submodules=none <sha>. Each name is passed through spelled, like the dirty list.
    • Where the gitlink already holds the new commit, the commit line and the message name the pin's own commit: as the old commit. A gitlink line, and a sentence in the message, say the gitlink does not move.
    • was is still what the rollback restores.
    • The listing runs after the commit has landed, so a failure there prints a sentence pointing at git show --name-only --ignore-submodules=none <sha> (since the fix round) and the run still exits 0. Before, a git-failed refusal would have exited 2 and implied the bump never landed.
  5. The dry run says it runs no validator. Its closing paragraph now says that a dry run does not run scripts/validate-pins.py or scripts/validate-manifest.py, and that the real run can still end bump-leg-validators-red, rolled back. Behaviour is unchanged, as the issue recommends. The cmd_bump docstring said the dry run refuses everything the real run refuses; it now names this one exception.

Also: the BRANCH_LEFT comment said "the three tools follow one rule". It now names the four copies: family.py, the holder's validate-family.py, setup-project.py and this one (rv647's nit 3).

Decisions and their evidence

  • The push form is refs/heads/<b>:refs/heads/<b>, not refs/heads/<b> alone. The issue allows either. Lane 4's branches print the both-sides form: fix/689-next-push-refspec (fa513848) for family.py's NEXT, and fix/674-update-shape-apply (d0699029) through _full_refspec. The brief asks this PR to match their form, so all three tools print one form. Both forms were measured to push +x, +main and v1 correctly and to set the upstream.
  • diff-tree rather than git show for the committed list. Measured on git 2.43:
    • git show --name-only drops the gitlink under diff.ignoreSubmodules=all. diff-tree is plumbing and ignores that setting.
    • Plain diff-tree also drops the gitlink under submodule.spec.ignore=all. --ignore-submodules=none overrides it.
    • The bump itself still lands under both settings.
  • The ride-along (the root's validator environment) is left as it is. The issue gives the choice. Measured on both trees, with the root's scripts/repo_shape.py deleted and committed:
    • With PYTHONPATH=<standard>/scripts, the gate prints FINDING shape-copy-missing: ... scripts/repo_shape.py is pinned but absent.
    • Without it, the gate prints ModuleNotFoundError.
    • Both runs exit 2 and roll back. No verdict changes, and -S with no PYTHONPATH would only swap the precise finding for a traceback.
  • README.md:527 and AGENTS.md:339 are unchanged. They are placeholder recipes, git -C <path> push -u origin <branch>, right after the switch -c <branch> the person types. They stay true for the names people choose, no test ties them to the tool's printed line, and the README stays out of the README queue.
  • docs/release-notes/v1.0.0.md lines 433 and 440 list two of these lines as v1.0.0 known limits. They describe v1.0.0 and are left alone; the v1.1.0 notes are lane 3's.
  • No --help text changed, so docs/cli.md is unchanged, and tests/test_cli_reference.py passes.

Tests

New file tests/test_bump_leg_branch_and_push_line.py, 20 tests in the first round and 24 after the fix round (see Fix round):

  • Branch: a tag main beside the tracking branch; a HEAD on a ref outside refs/heads/ (refs/tags/release-1 and refs/remotes/origin/main since the fix round).
  • Push: +x and +main with origin's other branch moved on, each run as the printed argv; v1 beside a tag v1, with its upstream checked.
  • Dirty list: four names (U+009B with U+202E, U+2028, U+0085, a name ending in U+2028), each under both the real run and --dry-run, plus a default-quoting control.
  • committed line: checked plain and under submodule.spec.ignore=all and diff.ignoreSubmodules=all; the gitlink moved by hand, in both the dry and real runs; the line when git cannot list the paths.
  • Dry run: a pin that validate-pins.py already refuses.

Tests that held the old form were updated:

  • tests/test_bump_leg.py::test_the_message_and_the_next_step_are_a_pull_request
  • tests/test_bump_leg_paste_lines.py: test_the_closing_next_is_one_command (3 params), test_an_ordinary_root_prints_the_line_it_always_did and test_the_closing_next_carries_the_branch_whole (3 params). Its paste tests still run the line through sh and check the remote.

Results:

  • On main 35133f29: the new file gives 19 failed, 1 passed. The one pass is the default-quoting control, which is meant to pass there.
  • The bump-leg set on this head 326de720: every tests/test_bump_leg*.py, plus tests/test_branch_left_bump_leg_setup.py and tests/test_non_utf8_git_reads.py. Result: 277 passed, 1 skipped.
  • Every other test file that imports or drives bump-leg.py: tests/test_bootstrap_after_bump.py, test_repo_hygiene.py, test_agents_md_codes.py, test_cli_reference.py, test_doctor_gitlink_repairs.py, test_doctor_pin_restore_merges.py, test_doctor_unfinished_merge.py, test_family_switch_branch_exists.py, test_pin_rewrite_layout.py, test_stdout_encoding.py, test_template_text_placeholders.py, test_update_shape_paste_lines.py, test_validate_pins_lockstep_lines.py, test_validate_pins_unreadable_workflows_dir.py and test_validator_child_encoding.py. Result: 697 passed, 1 skipped.
    • That run started a minute before the last code edit, so the three files among them that read a NEXT or push line were run again on 326de720: 217 passed.
  • The two skips are only for other platforms: one needs a case-ignoring disk, the other is about Windows' default encoding.

Mutation probes

One mutant of bump-leg.py per hunk. Each was run against the new file, and the two push mutants also against the paste-line tests. All 12 fail at least one test:

Mutant Tests failed
--short restored 3: the tracking-tag, tag-ref and v1 tests
dirty list printed raw 8
spelled kept but splitlines() restored 6: the U+2028 and U+0085 names
the old commit is always was 1
no gitlink line 1
message uses was 1
staged list printed 5
no guard after the commit 1
no --ignore-submodules=none 1
dry-run sentence removed 1
bare branch in NEXT 12
one-sided refs/heads/<b> in NEXT 12

The comment and docstring hunks change no behaviour and have no mutant.

Merge checks

git merge-tree --write-tree of 326de720 is clean against:

  • origin/main de926142, and 2fac9790;
  • lane 4's fix/674-update-shape-apply (d0699029) and fix/689-next-push-refspec (fa513848);
  • lane 5's fix/665-four-guards (151a0861), which also edits tests/test_bump_leg_paste_lines.py.

Main up to 2fac9790 changes repo_shape.git_out's refusal text (#610), but not spelled or pin_values_in, which this PR imports.

Left out

  • No platform-specific behaviour was measured on Windows or macOS. The new tests run each printed line as the argv a POSIX shell reads from it, without a shell, and skip a file name the disk cannot hold.
  • rv672's checklist candidates 1-3 (see Fix round) stay checklist candidates; this PR fixes none of them.

Fix round (rv672's review of 326de720)

One new commit, a5d473ee, on top of 326de720.

  • S1: the test of a HEAD outside refs/heads/ is now parametrized over refs/tags/release-1 and refs/remotes/origin/main. With refs/tags/x it passed by coincidence even without the refs/heads/ guard, because refs/tags/x is as long as refs/heads/ and the leftover name was empty. rv672's mutant R1 (the guard removed) now fails both cases.
  • S2: current_branch takes only git's newline off symbolic-ref's answer (rstrip("\n")). strip() also took a trailing U+00A0, U+2028 or U+0085 off the branch name. So a root on feat<U+00A0> beside a branch feat committed on the first and printed a push of the second. A new test puts feat<U+00A0> beside feat and runs the printed line: the bump commit reaches origin as feat<U+00A0>, and feat is not pushed.
  • N1: _ensure_root_clean takes off only the last newline. The first line keeps git's leading space, so M no longer reads as M (staged) one column left of the lines under it. The dirty-list expectations in the new file and in tests/test_non_utf8_git_reads.py now read M ..., and a new test with two dirty files checks that both lines line up.
  • N2: the committed fallback names git show --name-only --ignore-submodules=none <sha>.
  • N3: where the gitlink already recorded the commit, the commit message no longer says the gitlink moves in that commit. It says the pin and the workflow references are brought to the gitlink in ONE commit, because scripts/validate-pins.py refuses when they disagree. The ordinary message is byte for byte what it was (checked by rendering both).
  • N4: a new test moves the gitlink by hand with the pin's commit: set to not-a-sha. The commit line then names the gitlink's commit, never that text. rv672's mutant R2 (no COMMIT_RE check) now fails it.
  • B1 and the body: this draft holds no raw control or bidi character. Every escape in it is ASCII text.
  • rv672's checklist candidates 1-3 stay checklist candidates and are not part of this PR.

Fix-round tests, on the tree a5d473ee commits:

  • The new file: 24 passed in 19.33s.
  • The bump-leg set: every tests/test_bump_leg*.py, plus tests/test_branch_left_bump_leg_setup.py and tests/test_non_utf8_git_reads.py. Result: 281 passed, 1 skipped in 252.20s. The skip is the case-ignoring-disk test.

Fix-round mutants: all 18 of the final head fail at least one test. That is the 12 above, re-made on this head, plus R1, R2 and one each for S2, N1, N2 and N3:

Mutant Tests failed
R1, no refs/heads/ guard 2
R2, _moved_from unchecked 1
S2 reverted (strip()) 1
N1 reverted (strip(" \n")) 10
N2 reverted 1
N3 reverted 1

git merge-tree --write-tree of a5d473ee is clean against:

  • origin/main 5c990708, and 2d988200 after it moved (rv672's verify round: clean, and the bump-leg set gives 331 passed, 1 skipped on the merge);
  • fix/674-update-shape-apply (d0699029);
  • fix/689-next-push-refspec (fa513848);
  • fix/665-four-guards (151a0861).

Review. rv672 re-verified the fix round: APPROVE WITH NITS at a5d473ee, the nits being these body lines, now fixed. R1 fails both new cases and R2 fails the not-a-sha test; the bump-leg set gives 331 passed, 1 skipped on the head and on its merge with 2d988200.

Closes #672

🤖 Generated with Claude Code

Summary by Sourcery

Harden bump-leg branch handling, push instructions, reporting, and dry-run messaging against ambiguous refs, unsafe names, and misleading commit details.

Bug Fixes:

  • Correct branch detection to recognize only local heads and preserve branch names exactly.
  • Generate unambiguous full-head refspecs for push instructions so branch names cannot trigger unintended forced or ambiguous pushes.
  • Render dirty filenames safely, including control and bidi characters, while preserving Git status formatting.
  • Report the paths actually recorded by the bump commit and accurately describe gitlink movement.
  • Clarify that dry runs skip project validators and that real runs may still roll back on validator failure.

Enhancements:

  • Add comprehensive coverage for branch resolution, push safety, unusual filenames, commit contents, and dry-run messaging.

Tests:

  • Update existing bump-leg expectations for full refspec push instructions and preserved Git status columns.
  • Add regression tests covering unusual refs, unsafe branch names, control characters, commit path reporting, and validator behavior.

Brett Heap and others added 3 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>
…`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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-16T18:38Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates bump-leg.py to validate full branch refs, generate safe explicit push refspecs, safely spell unusual Git paths, report the actual committed paths and movement semantics, and explain skipped validators during dry runs; extensive regression tests cover each behavior.

Sequence diagram for the bump-leg commit and push flow

sequenceDiagram
    participant User
    participant BumpLeg as bump-leg.py
    participant Git
    participant Validators
    User->>BumpLeg: cmd_bump(args)
    BumpLeg->>Git: symbolic-ref --quiet HEAD
    Git-->>BumpLeg: refs/heads/<branch>
    BumpLeg->>BumpLeg: current_branch()
    alt dry run
        BumpLeg-->>User: Report preview and skipped validators
        Note over BumpLeg,Validators: Validators are not run
    else real run
        BumpLeg->>Git: Commit rewritten leg
        BumpLeg->>Validators: validate-pins.py and validate-manifest.py
        alt validators pass
            BumpLeg->>Git: diff-tree --name-only -r -z --ignore-submodules=none <sha>
            Git-->>BumpLeg: Actual committed paths
            BumpLeg-->>User: Explicit refs/heads/<b>:refs/heads/<b> push command
        else validators fail
            BumpLeg-->>User: bump-leg-validators-red and rollback
        end
    end
Loading

Sequence diagram for committed-path reporting

sequenceDiagram
    participant BumpLeg as bump-leg.py
    participant Git
    BumpLeg->>Git: Commit bump
    Git-->>BumpLeg: commit sha
    BumpLeg->>Git: diff-tree --no-commit-id --name-only -r -z --ignore-submodules=none sha
    Git-->>BumpLeg: Paths held by commit
    BumpLeg->>BumpLeg: spelled(path)
    BumpLeg-->>BumpLeg: Print committed paths
    alt path listing fails
        BumpLeg-->>BumpLeg: Print git show --name-only recovery hint
    end
Loading

Flow diagram for safe branch push refspec generation

flowchart LR
    HEAD[HEAD ref] --> Check{Starts with refs/heads/?}
    Check -->|No| Detached[Refused as detached]
    Check -->|Yes| Branch[Extract branch name]
    Branch --> Refspec[refs/heads/<b>:refs/heads/<b>]
    Refspec --> Push[git push -u origin refspec]
    Push --> Upstream[Records origin/<b> upstream]
Loading

File-Level Changes

Change Details Files
Make branch detection and generated push commands unambiguous and safe for unusual branch names.
  • Read the complete symbolic ref and accept only refs under refs/heads/.
  • Emit a two-sided refs/heads/ refspec so tags and leading '+' characters cannot redirect or force a push.
  • Update branch and paste-line coverage for tracking tags, detached tag refs, ambiguous names, upstream setup, and remote safety.
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
Render Git-reported paths safely and report the paths actually contained in the completed commit.
  • Spell dirty filenames and split only on Git's newline delimiter, preserving Unicode separators in names.
  • Derive committed paths with NUL-separated diff-tree output while overriding submodule ignore settings.
  • Use the pin's commit as the reported source when the gitlink already points at the target, explain the unchanged gitlink, and make post-commit path-read failures non-fatal.
scripts/bump-leg.py
tests/test_bump_leg_branch_and_push_line.py
Clarify dry-run validator behavior without changing execution semantics.
  • State that dry runs skip the project validators and that a real run may roll back with bump-leg-validators-red.
  • Document the exception in cmd_bump and test both the dry-run message and real-run rollback.
scripts/bump-leg.py
tests/test_bump_leg_branch_and_push_line.py
Align explanatory comments and regression coverage with the four shared branch-left implementations.
  • Update the BRANCH_LEFT comment to identify all four copies of the shared rule.
  • Add comprehensive regression tests covering branch refs, push safety, unusual filenames, commit contents, and validator messaging.
scripts/bump-leg.py
tests/test_bump_leg_branch_and_push_line.py

Assessment against linked issues

Issue Objective Addressed Explanation
#672 Read the root's branch from the full HEAD ref so same-named tags cannot disguise the branch or allow a bump on a tracking branch, and print a push command that unambiguously pushes that branch without force semantics. ✅
#672 Escape control and bidi characters in dirty-file names so terminal output remains safe and each filename is kept intact. ✅
#672 Make bump reports accurately describe the commit's paths and the pin movement when the gitlink already has the target commit, and disclose in the dry-run summary that project validators are skipped and the real run may be rolled back by them. ✅

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T18:41:03.806180Z 326de72 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

… 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>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 326de720bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…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>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…es every reason THIS checkout cannot answer, and the notes are current to main bf1f099 with the 25 pull requests since de92614 (#563, #683)

- W683 and RV-f's N4 on #742: README's exit-3 list (Keeping a project's
  shape current, :1208-1220) and its handbook twin (`doctor`) named only a
  pin whose commit this checkout does not carry. They now say, as
  shape-doctor.py's EXIT CODES does: a pin's commit it lacks, a partial
  clone missing what is compared (#683), a read that fails
  (`update-upstream-unreadable`), a crashed validator of its own (#278,
  #410), or its own naming policy not loading (#678). +1 line, with
  tests/readme-cap.d/683.txt; README.md is 1474, its cap (1422 + 2 + 5 +
  12 + 22 + 10 + 1). The two `Keeping a project's shape current` markers
  move: `doctor` text and marker, `current` marker only.
- The notes, "What changed since v1.0.1", one entry per pull request from
  `git log --first-parent de92614..bf1f099`, each from its squash commit and
  the issues GitHub says it closes:
  - adopt-project.py: #699, #719, #749, #727, #717, #728 (with #621's
    hint), #743 + #720 + #718, #722, #721;
  - update-shape.py, bump-leg.py and a project's own scripts: #702 (with
    the pinned validators), #634;
  - scaffold, setup, family and the shared readers: #725, #671, #744;
  - shape-doctor.py: #742, #701, #737, #751, #736, #734, #700 + #705,
    #740;
  - install, CI and the docs: the exit-3 sentence above.
- Counts (99 pull requests since bfed143; 109 of 135 milestone issues
  closed at 10:28Z), the "current to main" sha and the open list (26) are
  at main bf1f099, still placeholders for tag time.

Left for round B: lane 5's eight lines (#630 issuecomment-6227129879),
whose pull requests #726, #747, #682, #724, #746, #745 and #738 are open
drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-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>
Base automatically changed from fix/663-non-utf8-name-tests to main October 17, 2026 17:57
Brett Heap and others added 2 commits October 17, 2026 17:57
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
@sonarqubecloud

Copy link
Copy Markdown

@sourcery-ai

sourcery-ai Bot commented Oct 17, 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 5 hours and 47 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit fc86720 into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/672-bump-leg-branch-and-push-line branch October 17, 2026 19:01
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…_utf8_git_reads.py imports both sides' conftest names, so #723 and #746 land under #758 with `appended_to_command_scope` still in place (#758)

#723 (`bad05d21`) and #746 (`fc867207`) landed on main after `eaa72d9e`.
The merge conflicts in one place: the `from conftest import` block of
`tests/test_non_utf8_git_reads.py`. #758 added `appended_to_command_scope`
there, and #723 added `skip_message`,
`skip_where_fsencode_gives_a_surrogate_no_byte` and `windows_fsencode`. The
resolution keeps both sides' names, sorted as the file sorts them:

    from conftest import (ORG, PROJECT, REPO, appended_to_command_scope,
                          cloned_just_pushed, git, run_script, skip_message,
                          skip_where_fsencode_gives_a_surrogate_no_byte,
                          windows_fsencode)

No other file conflicts. The scan in `tests/test_no_auto_maintenance.py`
flags no file on the merged tree, and no file outside that test's own
docstring uses `PASTE_ENV` or `ALLOW_FILE_PROTOCOL`.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
Main moved from bad05d2 (the main #774's head 012a772 holds) to fc86720
(#672, #746), which changes scripts/bump-leg.py and its tests alone. No file
conflicted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
#813 and #723, v1.0.0's quoted-header and plain-path-clone limits closed and the counts current to main bad05d2 (#563)

Each entry is the one RV630 corrected in its pre-review of the B2 stage,
put at its anchor, and checked against its squash on main: the
`git merge-tree --write-tree` of main before the squash with the head the
entry was written from equals the squash's tree for all seven.

- #810 (#674, `d17564d9`): a new bullet, "`apply`'s lines, its branch and
  its headers.", after "Headers, branches and messages.". It closes
  v1.0.0's quoted `files:`/`shape:` header (v1.0.0.md:439) and `apply`'s
  half of the paths listed as staged (:433); `bump-leg.py`'s half waits
  for #746.
- #731 (#692, `d66a9054`): appended to "Folders, asked before they are
  made.".
- #813 (#689, `f6d17bc5`): appended to "`--tracking-branch`.".
- #809 (#688, `42aed788`): a new bullet, "What `family.py init` records
  and commits as.", after "Names that are not UTF-8.".
- #816 (part of #684, `fa04d2cd`): a new bullet, "More holder states,
  named.", after "family.py refuses before it writes.". #816 closes no
  issue; #684 keeps its reftable case and is on v1.1.1.
- #748 (#662, `431c8cf8`), then #723 (#663, `bad05d21`): in the CI
  bullet, after #713's sentence. With #554 and #578, #748 closes v1.0.0's
  limit on fixtures that clone a just-pushed remote by plain path (:530).

The counts, in the header comment, the counts paragraph, "What changed"
and the open list: main `bad05d2`; 120 pull requests since `bfed143`;
at 18:02Z, 3 of the `v1.1.0` milestone's 133 issues open and 130 closed.
The open list is #563 (#630), #672 (#746) and #686 (#795); #684 and #695
(#738) are on v1.1.1. #746 and #795 have not landed, so neither has an
entry yet. Nothing since 2000488 touches pyproject.toml,
src/openreposhape/, install.sh or .github/workflows/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained #746 (#672, `fc867207`) since bad05d2. No conflict, and it
touches no README.md, handbook, AGENTS.md, docs/ or README cap ledger
file, so every handbook marker reads as before. README.md is 1474 lines,
its cap; AGENTS.md 494, its cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…it closed as one clause, the list's and the known limits' markers made final, the triage lines named by their issues, and the counts current to main fc86720 (#563)

- #746 (#672, `fc867207`): a new bullet, "`bump-leg.py`'s branch and its
  lines.", after "`bump-leg.py --dry-run` gives the real run's answer.".
  `git merge-tree --write-tree bad05d2 a5139a3`, the head #746 was
  gated and landed on, equals the squash's tree `bb920ac1`. The head the
  entry was written from, `4a014a79`, sat on #723's head before its
  squash and does not merge clean with main; `scripts/bump-leg.py` is one
  blob (`226e7b0d`) at `4a014a79`, `a5d473ee`, `a5139a32` and the squash,
  and the squash's own lines in each of its five files equal that head's,
  so the entry stands as written.
- v1.0.0's limit on the paths listed as staged (v1.0.0.md:433) is one
  clause, in #746's entry: with #810 it closes. #810's half-clause is cut.
  `bump-leg.py --dry-run` over a pin its validators refuse (:440) stays a
  limit, which the dry run now says.
- "What changed" runs to main `fc86720`, and says #795 is added if it
  lands next; Known limits' re-read marker and "as read so far" are cut.
  The counts marker stays as the release-body tool reads it.
- Known limits name each triage by its issue: "the checklist triage of the
  L4 lines on #457 and #459" and "of the L1 lines on #457", where they
  named lanes 4 and 1 and D9, and each id reads "#457's L4-05", "#459's
  L4-24" and so on.
- Counts in the four places: main `fc86720`; 121 pull requests since
  `bfed143`; at 19:08Z, 131 of the `v1.1.0` milestone's 133 issues
  closed, the open two #563 (#630) and #686 (#795).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…f64eea, RV630's lead and two named limits under Known limits, and the counts current to main 6f64eea (#563)

- #795 (#686, `6f64eea2`): a new bullet, "`bump --to`, reachable or
  not.", before "family.py refuses before it writes.", the pending
  file's text as written. `git merge-tree --write-tree fc86720 <head>`
  equals the squash's tree `0f2d2ddd` for `17bbbaba` and for the final
  head `cf143c69`. For the head the text was written from, `cf5e64b3`,
  the merged tree differs from the squash only in
  `tests/test_family_bump_reachability_words.py`, so `scripts/family.py`
  is as the text says.
- "What changed" runs to main `6f64eea`, with no word of a later pull
  request.
- Known limits, RV630's words: the lead says the list is not all of
  v1.0.0's open limits, and names two this release worked on and left
  open, the `--into` sentence (#695) and `bump-leg.py --dry-run` over a
  pin its validators refuse (#746, #672).
- Counts in the four places: main `6f64eea`; 122 pull requests since
  `bfed143`; at 20:15Z, 132 of the `v1.1.0` milestone's 133 issues
  closed, the one open #563 (#630).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…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.
brettheap pushed a commit that referenced this pull request Oct 17, 2026
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
…he import list main's #746 extended with `spelled` merges without a conflict (#715)

`716ad457` added `readable_branch` to the `from repo_shape import (...)` list, on the line #746 (`fc867207`) also rewrote, and `git merge-tree` with main `190133d5` stopped there. The list is as main's merge base has it again, and the name has an import of its own after it, as `update-shape.py` imports `_strip_comment`. The predicted merge with `190133d5` is now clean.

Run: `tests/test_yaml_reader_spaces.py` and `tests/test_bump_leg_dry_run_tracking.py`, `-k "bump_leg or tracking"`: 44 passed.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…nd what they tell git to the `GIT_CONFIG_COUNT` entries the environment holds, so a pasted line keeps the session's maintenance settings and the Windows job's two (#758) (#818)

Against main. #748 landed as `431c8cf8`; this branch merged #748's final head and main (see Fix round 1). After that merge, #723 (`bad05d21`) and #746 (`fc867207`) landed. The branch is refreshed over main `fc867207` as `4a553e17`, one merge commit. Its only conflict was `tests/test_non_utf8_git_reads.py`'s `from conftest import` block, resolved by keeping both sides' names, as rv818 asked. The merged tree differs from main in #818's own 27 files, and #818's count scan flags no file. Tests only: 25 files under `tests/`, no shipped code, no pinned file, no README, handbook, `AGENTS.md` or `docs/cli.md` change.

Two new commits on `b6e15591`: the retarget merge `2908e46f`, then the fix commit `eaa72d9e`. Tests only, as before.

### The retarget: merge commit `2908e46f`
#748 landed as `431c8cf8` from its final head `8368d1f8`, and GitHub moved this PR's base to main. The merge commit has three parents: `b6e15591`, `8368d1f8` and main `fa04d2cd`. #748's head goes in first, so git sees the history the squash dropped.

- **#748's head did not merge clean**, as rv818 found. `tests/test_adopt_empty_symlinks.py` conflicts, because `8368d1f8` holds main `741679e7`, which carries #388.
  - I took w758's earlier resolution of that file, which rv818 checked. Main's side of the file is the same at `974d4827`, `8368d1f8` and `fa04d2cd`, so it still fits.
  - Both sides stay: #388's two targets (`with_each_target`, `SAID`, the `target` parameters) and #758's `symlinks_off()`, built at the call.
  - `test_the_link_given_a_target_adopts_end_to_end` calls `symlinks_off()` too. Git does not flag it, and left as it was the file fails at collection.
- **Main then conflicted in three files** (main was `d17564d9` when I resolved them): `tests/test_clone_just_pushed.py`, `tests/test_doctor_legs_members_next.py` and `tests/test_family_bump_remove_rollback.py`.
  - Main's copy of each was `8368d1f8`'s byte for byte.
  - This branch's copy is `8368d1f8`'s plus #758's own hunks, so this branch's side is taken.
  - Main's next two landings, #809 and #816, merged with no conflict.
- **rv818 MUST 1.** `tests/test_local_remote_dir_urls.py` came with #812 and imported `ALLOW_FILE_PROTOCOL` from `test_family`.
  - Without a change, the file fails at collection: `ImportError: cannot import name 'ALLOW_FILE_PROTOCOL' from 'test_family'`, and none of its 27 cases run.
  - It now imports `allow_file_protocol` and calls `pasted(lines, paste, allow_file_protocol())`.
  - `git grep` finds no other file on main that uses a name #758 removes (`SYMLINKS_OFF`, `SYMLINKS_ON`, `CONFIG_COUNT`, `CONFIG_VALUE`, `PASTE_ENV`, `ALLOW_FILE_PROTOCOL`). `NO_MAINTENANCE`, `QUOTE_PATH_OFF` and `tool_env` change shape, and each is used only in its own file.
- **Checked:**
  - Against main, the merged tree differs in #758's 25 files and in `tests/test_local_remote_dir_urls.py`.
  - Each of the 25 differs by exactly #758's own added and removed lines, `tests/test_adopt_empty_symlinks.py` aside.
  - Collecting the whole suite at the head finds 12,320 tests and no error.

### The fix commit `eaa72d9e`
- **SHOULD 2:** `configure` in `tests/test_adopt_reencoded_files.py`, the seventh append from #758's line 2, now sets what `appended_to_command_scope(os.environ, pairs)` returns. It gives the same entries in the same order, so no test can fail on the old form.
- **SHOULD 3:** the scan also reads a name the module binds to `"GIT_CONFIG_COUNT"` (`CONFIG_COUNT = "GIT_CONFIG_COUNT"`, annotated or not) as the count. It does so in a dict key, a setter's first argument and a subscript.
  - rv818's repro: append the base's two lines to `tests/test_adopt_root_symlinks.py`.
  - The old scan passes. The new one names line 2137.
- **SHOULD 4:** `counts_set_to_a_number` is split into one reader per node type: `in_a_dict`, `in_a_call` and `by_subscript`. The walk takes each node's reader from `READERS`. By hand, no function scores more than 5 on S3776. I could not run Sonar.
- **NIT 5:** `conftest.py` is no longer skipped. It scans clean, at the head and on today's main.
- **NIT 6:**
  - New "sees" cases: `os.putenv("GIT_CONFIG_COUNT", "1")` and a bare `setenv("GIT_CONFIG_COUNT", "1")`.
  - The walk is now `files_off_their_count(directory)`. A new test runs it over a folder of its own, and checks three things:
    - `conftest.py` and a helper module are read;
    - a `.txt` file is not;
    - a file `OWN_COUNTS` allows is named for fewer counts as well as for more.
  - Four of rv818's six surviving mutants now fail a case: `putenv` dropped, the bare-name setter dropped, `!=` changed to `>`, and `glob("test_*.py")`.
  - The fifth, `conftest.py` scanned, is now how the code works, and putting the skip back fails the new test.
  - The sixth, the count's value taking any string, still survives: it is a stricter scan. Git refuses a count that is not a number (`bogus count in GIT_CONFIG_COUNT`, exit 128), so a test that holds one fails on its own.
- **NIT 7, in part:**
  - An empty count (`""`) is now a count of its own. Git 2.43 and 2.47.1 read it as no entries at all. Measured: `GIT_CONFIG_COUNT= GIT_CONFIG_KEY_0=a.b GIT_CONFIG_VALUE_0=c git config --get a.b` exits 1.
  - The docstring says the scan reads the count's value as a constant only.
  - Left out:
    - a value built by a call (`str(len(pairs))`) or held in a variable;
    - an int;
    - `dict()`, or `environ={}` by keyword, handed to the helper;
    - `setenv` with keyword arguments;
    - an annotated subscript.

    rv818 judged only `str(len(entries))` likely, and telling it from a count worked out needs what the call means, not how it is spelled.
- **NIT 8:** the paste case deletes `GIT_CONFIG_PARAMETERS` first. Measured on the merge before the fix commit, with `GIT_CONFIG_PARAMETERS="'probe.param'='p'"`, the case failed (`Left contains one more item: 'probe.param=p'`). It passes now.
- **NIT 9, left as it is:** main's three appends built by hand each work the count out right: `tests/test_family_head_lost_gitmodules.py:97`, `tests/test_family_bootstrap_gate_order.py:135` and `tests/test_family_add_spellings_remedies.py:137`. Each of those files is also changed by another open PR (#828 `fix/761-add-move-back-gitmodules`, #820 `fix/762-validate-family-remedies-texts`, `fix/763-holder-docs-agree`), so converting them here would only add conflicts.
- **NIT 10:** the comment on `NO_MAINTENANCE` now says its `maintenance.auto` and `gc.auto` repeat the session's two, which reach these gits in the command scope. Only `gc.autoDetach` adds a setting. The two stay, so the constant alone still keeps maintenance off for these gits.
- **NIT 11:** the count was in lane 5's #473 summary, not in this body. The seven sets held 2,401 distinct passes, not 2,609: the 208-test run repeats three files of the 256-test run.

### Tests at `eaa72d9e`
All runs went through `run9.sh`, with the real `gh` unreachable. All passed, none skipped:
- **The 26 test files the PR changes against main**, in one full-file run each: 2,206 passed (1,108 + 1,098).
  - These are #758's 24 test files, `tests/test_local_remote_dir_urls.py` and `tests/test_adopt_reencoded_files.py`.
  - The count matches the collection exactly.
- **The 43 files that import any of those 26, with `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`:** 1,646 passed (825 + 821).
- **The same merge made on main `d17564d9`** (a local commit, `c16a7e1a`, never pushed), before the fix commit: `tests/test_local_remote_dir_urls.py` and `tests/test_no_auto_maintenance.py`, 55 passed.

### Fails on the base
The new tests were run against the base's scan and walk, as `b6e15591` has them, with `GIT_CONFIG_PARAMETERS` set: 6 failed, 30 passed. The six:
- the paste case;
- the `nothing`, `a-name-bound-to-it`, `an-annotated-name` and `a-name-by-subscript` cases;
- the walk test.

The three other new cases (`a-bare-setenv`, `putenv`, `a-name-bound-to-another-key`) are there to kill mutants, and they pass on any correct scan. MUST 1 fails on main's form at collection, as above. SHOULD 2 and NIT 10 change no behaviour.

### Mutants
I made 26 mutants. 25 are caught, and one survives as rv818 said it would:
- **22 on the scan and the paste case**, each run against `tests/test_no_auto_maintenance.py`:
  - the delenv dropped;
  - `putenv` dropped from `SETTERS`, or the bare-name setter;
  - the name branch of `is_the_count` dropped, or any name taken as the count;
  - `""` no longer a count;
  - the `Assign` names dropped, or the `AnnAssign` names;
  - no names collected;
  - each reader ignoring the names;
  - each of the three readers dropped from `READERS`;
  - `conftest.py` skipped;
  - `!=` changed to `>` or to `<`;
  - `glob("test_*.py")`, or `glob("*")`;
  - `by_subscript` taking any value;
  - the walk pointed at an empty folder.

  The last passes on a clean tree, like any change to an assertion that nothing is found. It is caught by rv818's SHOULD 3 repro, where the real walk fails and the mutant passes.
- **The survivor:** the count's value taking any string. It makes the scan stricter (see NIT 6).
- **`configure`:** with `appended_to_command_scope({}, pairs)`, the scan names the file. With the pairs dropped, three of its tests fail.
- **MUST 1:** main's import put back fails collection with `ImportError`.

### Merge checks at `eaa72d9e`
I ran `git merge-tree --write-tree` against `origin/main` (`fa04d2cd`) and the 53 other `fix/*` and `l3-*` heads on origin:
- **Clean:** `origin/main` and 43 heads.
- **#792 (`l3-785-non-ascii-paths`)** merges clean, but `tests/test_doctor_non_ascii_paths.py` imports `PASTE_ENV`. Whichever PR lands second changes `:50` (the import), the `:266` docstring and `:268` to `paste_env()`.
- **#723 (`fix/663-non-utf8-name-tests`), #746 (`fix/672-bump-leg-branch-and-push-line`, which now holds #723) and #806 (`fix/757-non-utf8-test-dirs`)** conflict only in the `from conftest import` block of `tests/test_non_utf8_git_reads.py` (and of `tests/test_adopt_git_reads.py`, for #806). Both sides add names, so keep them all.
- **Seven heads conflict with main itself, not with this PR:**
  - `l3-732-latin1-section` and `l3-733-ignored-sections` (`tests/test_doctor_legs_members_next.py`);
  - `fix/712-validate-pins-contracts`, `fix/766-spelled-paths` and `fix/798-adopt-legs-asked-first`;
  - `l3-677-pins-hand-step-wip` and `l3-678-naming-checkout-wip`.
- I scanned each clean merged tree's changed test files for a name #758 removes, or a count the scan flags. Only #792 shows up.
- **Main moved after the push.** #723 landed as `bad05d21`, and the two now conflict in one place: the `from conftest import` block of `tests/test_non_utf8_git_reads.py`. The fix is to keep both sides' names: `ORG, PROJECT, REPO, appended_to_command_scope, cloned_just_pushed, git, run_script, skip_message, skip_where_fsencode_gives_a_surrogate_no_byte, windows_fsencode`. Every other change merges in. On that merged tree, with the block resolved that way, the scan flags no file, and no file uses a name #758 removes.

On `deb029c9` a probe test imported `PASTE_ENV` and pasted `git config --show-origin --list` through `paste()`:
- `PASTE_ENV` held `GIT_CONFIG_COUNT=1` (`protocol.file.allow`), a stale `GIT_CONFIG_KEY_1=user.email` left from collection, and no `GIT_CONFIG_GLOBAL`.
- `os.environ` at the call held a count of 4 and the session's global file.
- The pasted git listed one command-line entry, `protocol.file.allow=always`, and `git config --get maintenance.auto` exited 1. So a pasted line ran with git's automatic maintenance on, in both scopes, as the issue said.

`paste_env()` replaces it. It reads `os.environ` at each call (minus `PYTHONPATH`), adds the identity and `PYTHONDONTWRITEBYTECODE`, and appends `protocol.file.allow` with `appended_to_command_scope`. The six uses call it. The import-time copy also predated the session's `gh` guard on `PATH`, as `test_doctor_next_is_a_command.py`'s docstring says of its own environment; a pasted line now gets the `PATH` of the call. That docstring now says `paste_env()` reads at the call too.

- **The seven.** Six are on this base, each now one call to `appended_to_command_scope`: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_ignored_shape_files.py`, `test_adopt_root_symlinks.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`. The seventh, `test_adopt_reencoded_files.py`, came with #743 and is converted in fix round 1. `test_clone_just_pushed.py:96` stays, as the issue says: it is the helper's own test.
- **The other literals.** Each sets a count of its own and is laid over `os.environ`, where the session's two settings and the Windows job's two sit, so each replaced them (`NO_MAINTENANCE` put the two maintenance keys back and dropped the job's two). I read each; each is converted, none left:
  - `test_family.py`: `ALLOW_FILE_PROTOCOL` is now `allow_file_protocol()`; its two importers (`test_siblings_family_name.py`, `test_family_siblings_placement.py`) and the four uses in `test_family.py` call it.
  - `test_family_dry_run.py`: `NO_MAINTENANCE` is a tuple of entries, appended by `isolated()`.
  - `test_non_utf8_git_reads.py`: `QUOTE_PATH_OFF` is a tuple of entries, which `tool_env()` appends; `user_name()` appends too.
  - `test_adopt_empty_symlinks.py`: `SYMLINKS_OFF` is `symlinks_off()`.
  - `test_adopt_root_symlinks.py`: `SYMLINKS_OFF` and `SYMLINKS_ON` are gone; the four tests that set the first call `links_as_files` and the two that set the second call `told_git`, which already appended.
  - one-off environments: `test_scaffold_reuse_seeded_legs.py`, `test_adopt_split_branch.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`, `test_setup_project_py.py`, `test_setup_sh.py`, `test_doctor_next_is_a_command.py`.
  - The two tests that build a held environment on purpose to see it kept (`test_adopt_git_reads.py`, `test_clone_just_pushed.py`) now build it with `appended_to_command_scope({}, ...)`.

Measured with a probe on a throwaway bare remote after a push (18 loose objects): the hand-built options and `clone_just_pushed`'s give the same clone, one pack and no loose object, with `upload-pack` and `pack-objects` in `GIT_TRACE`, and `origin` keeps the plain path. A plain local clone of the same remote keeps all 18 loose objects. For a clone that is not recursive the helper's `insteadOf` rewrite is inert, because no submodule is cloned; the helper adds one place for the options, not a different read.
- `test_bump_leg_dry_run_truth.py:98`, the private bare copy of the session's spec remote: a session remote. `cloned_just_pushed(..., bare=True)`; the fixture now asserts the copy is bare.
- `test_bump_leg_dry_run_truth.py:110`, `private_work`, a clone of that private remote, which tests push tags into: `cloned_just_pushed(..., recurse=False)`.
- `test_doctor_legs_members_next.py:1828` and `:1929`, `Ghost`, a clone of the first member's checkout in the test's own holder copy: not a remote, and the hand-built `-c protocol.file.allow=always` was dead weight on a direct clone. Both call `clone_member`, one helper over `cloned_just_pushed(..., recurse=False)` that asserts the legs stay empty.
- `test_family_bump_to_checks.py:235`, a clone of `a_private_remote`, a bare repository filled by a push: `cloned_just_pushed(remote, work, "main", recurse=False, env=env)`.
- `test_doctor_pin_restore_merges.py:714` is **left as it is**: a `--filter=blob:none` clone of a file URI. The helper takes a path, and has no way to pass `--filter`.

- `test_family_dry_run.py`: the comment on `NO_MAINTENANCE` says what the entries are for, the gits `isolated()` hands an environment to, whose global file `excluding()` replaces, and that the session fixture covers the module's own setup commits, which pass no `env=`.
- `tests/conftest.py`: the two i645 replacements, "repacks once its estimate, taken from `objects/17` alone, is above 256 loose objects, so two loose objects there are enough", and the `objects changed` sentence. **#713's body holds the matching sentence and needs the same two replacements; I cannot edit a body.**

`tests/test_no_auto_maintenance.py` has 17 new cases (28 in the file, 11 before):
- `test_a_pasted_line_keeps_the_entries_the_environment_holds_at_the_call`: the environment holds two entries as the Windows job does, then the two settings as the session fixture appends them, set after the doctor tests were imported. `git config --show-origin --list` through `paste()` lists the four, in order, then `protocol.file.allow`, and reads both settings from the session's global file.
- `test_no_test_sets_a_git_config_count_of_its_own`: reads every `tests/*.py` but `conftest.py` with `ast` and fails a `GIT_CONFIG_COUNT` set to a number (dict, keyword, `setenv`/`setdefault`/`putenv`, subscript) and an `appended_to_command_scope` call handed a dict written out. Three files are allowed the ones that build a held environment on purpose, with a count each in `OWN_COUNTS`.
- 15 cases that hold the scan itself: seven spellings it must see, eight it must leave (`env.get("GIT_CONFIG_COUNT", "0")` is a read).

Runs at the head `b6e15591` through `run9.sh` (the real `gh` unreachable), all passed, none skipped:
- `test_no_auto_maintenance.py`, `test_doctor_legs_members_next.py`, `test_doctor_next_is_a_command.py`: 208 passed.
- `test_clone_just_pushed.py` and `test_doctor_validator_rows.py` (it imports the doctor file), with those three: 256 passed, run one commit earlier (the later commit only moved two blank lines and reworded one failure message).
- The sweep files: `test_adopt_git_reads.py`, `test_adopt_nested_attributes.py`, `test_adopt_root_symlinks.py`, `test_adopt_ignored_shape_files.py`, `test_bootstrap_after_bump.py`, `test_family_bump_remove_rollback.py`, `test_adopt_empty_symlinks.py`, `test_adopt_split_branch.py`: 445 passed.
- `test_non_utf8_git_reads.py`, `test_family.py`, `test_siblings_family_name.py`, `test_family_siblings_placement.py`, `test_scaffold_reuse_seeded_legs.py`, `test_scaffold_free_text_values.py`, `test_adopt_plan_yaml_quoting.py`: 999 passed.
- `test_family_dry_run.py`, the five files that import its `isolated` (`test_family_bump_to_a_tag.py`, `test_family_bump_to_checks.py`, `test_family_bump_to_local_only.py`, `test_family_refuse_up_front.py`, `test_family_remove_gitmodules.py`) and `test_bump_leg_dry_run_truth.py`: 216 passed.
- `test_setup_project_py.py`, `test_setup_sh.py` and their importers (`test_adopt_shape_pin_and_work_dir.py`, `test_dry_run_asks_remotes.py`, `test_clone_url_bytes.py`, `test_setup_by_hand_commands.py`): 313 passed.
- `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_doctor_pin_restore_merges.py`, `test_git_helper_identity.py`: 172 passed. `render-cli-reference.py --check` is ok.

**On the base (`deb029c9`)**, with the new `test_no_auto_maintenance.py` copied in: 2 of the 17 fail, the paste case and the scan (it finds 12 files). The other 15 hold the scan itself and pass on any tree.

Each of the new hunks was broken in a throwaway and run against its tests:
- `paste_env`: a literal count of 1, a snapshot taken at import, and `appended_to_command_scope({}, ...)`: each fails the paste case, and the first and third the scan too.
- The 18 sites that call `appended_to_command_scope(os.environ, ...)` (or `env`), each with `{}` in place of the environment: each fails the scan. Sites: the six of line 2, `allow_file_protocol`, `isolated`, `tool_env`, `user_name` (non-UTF-8), `symlinks_off`, `git_reads_github_from`, `github_at`, `user_name` (free text), `test_adopt_plan_yaml_quoting.py`, the two setup tests, `test_doctor_next_is_a_command.py`.
- The scan: eight changes (ignore dicts, keywords, `setenv` calls, subscripts, `appended_to_command_scope({}`; flag `os.environ`; flag a read; allow fewer in `OWN_COUNTS`) each fail a scan case.
- The clone sites: dropping `bare=True` from the private remote, `recurse=False` from `clone_member`, and `"main"` from the checks clone each fail. Two **survive as equivalents**: `recurse=False` in `private_work` (the spec leg has no `.gitmodules`, so a recursive clone of it is the same clone) and `env=env` in the checks clone (a clone reads no config the isolated environment changes).
- The two comments cannot be mutated.

`git merge-tree --write-tree` of this head against `origin/main` (`974d4827`), #748 (`deb029c9`), #713 (`979bb394`) and every `fix/*` and `l3-*` head on origin (46 refs):
- **Clean:** #748, #713, and 24 of the 46 heads: 21 `fix/*` heads (every lane-5 PR but #723 and #756, and most of lane 4's) and `l3-677`, `l3-678`, `l3-rehearsal-v1.1.0`.
- **`origin/main`: one file, `tests/test_adopt_empty_symlinks.py`** (four hunks), against #388's landing (`5c990708`). The parent #748 merges clean, so this arrives with the retarget. Resolve by keeping main's lines and my `symlinks_off()`. Two more spots need the same change and git does not flag them:
  - `tests/test_adopt_empty_symlinks.py::test_the_link_given_a_target_adopts_end_to_end` uses `SYMLINKS_OFF`, which this change removes: give it the `off` parametrization.
  - `tests/test_adopt_reencoded_files.py::configure`, the seventh hand-built append, goes to `appended_to_command_scope(os.environ, pairs)`.
  - `tests/test_family_head_lost_gitmodules.py::on_disk` (on main) is one more hand-built append: `{**env, **appended_to_command_scope(env, [("core.ignorecase", ignorecase)])}`. The scan does not read for hand-built appends, so it is not required, but it is the same thing.

  I made that merge in a scratch tree (main at `bf1f099b`), resolved it as above, and ran the files this change touches that main also changed, with `test_adopt_empty_symlinks.py`, `test_adopt_reencoded_files.py`, `test_family_head_lost_gitmodules.py`, `test_no_auto_maintenance.py` and the files that import `isolated` or the doctor helpers: 653 and 245 passed, in two runs. The scan finds no other file of main's with a count set by hand.
- **#723 (`fix/663-non-utf8-name-tests`):** `tests/test_non_utf8_git_reads.py`, the `from conftest import` block, where both add names.
- **`l3-732`, `l3-733`:** `tests/test_doctor_legs_members_next.py` (the `from conftest import` block) and `tests/test_adopt_empty_symlinks.py`.
- **19 more** (`fix/664`, `fix/729` (#756) and 17 `l3-*` heads): `tests/test_adopt_empty_symlinks.py` only, because they carry main's #388 as main does.
- **Not a textual conflict, but it breaks on merge:** `l3-785-non-ascii-paths` imports `PASTE_ENV` (`tests/test_doctor_non_ascii_paths.py:47` and `:223`); it must call `paste_env()`. #812 (`fix/693-local-remote-dir`) landed first, with `tests/test_local_remote_dir_urls.py` importing `ALLOW_FILE_PROTOCOL` (`:51`, `:349`). This PR's retarget merge makes it call `allow_file_protocol()` (rv818 MUST 1).

- A pasted `git submodule update --init` still clones a local path: `paste()` takes no remotes, so no `insteadOf` rewrite reaches it (rv662's "build it from `read_through_git`"). It runs under the session's maintenance settings now, which is what the issue asks.
- Three more appends built by hand beside `appended_to_command_scope`, in files this base does not hold: `tests/test_family_head_lost_gitmodules.py:97` (main), `tests/test_family_bootstrap_gate_order.py:135` (#682), `tests/test_family_add_spellings_remedies.py:137` (#767). The scan reads for counts set to a number, not for appends worked out by hand.

Review: lane 2's independent review (issuecomment-6236874291) and its re-verification of eaa72d9, READY (issuecomment-6238781272); its new SHOULD is taken in #848, and 4a553e1 merges main over eaa72d9. CI (Brett Heap's ruling 27): H0 is the head 4a553e1 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base fc86720; and the local gate passed on its predicted squash tree 9d6c099 over main d8b8eb3: the adopt_empty_symlinks, adopt_git_reads, adopt_ignored_shape_files, adopt_nested_attributes, adopt_plan_yaml_quoting, adopt_reencoded_files, adopt_root_symlinks, adopt_split_branch, bootstrap_after_bump, bump_leg_dry_run_truth, clone_just_pushed, doctor_legs_members_next, doctor_next_is_a_command, family, family_bump_remove_rollback, family_bump_to_checks, family_dry_run, family_siblings_placement, local_remote_dir_urls, no_auto_maintenance, non_utf8_git_reads, scaffold_free_text_values, scaffold_reuse_seeded_legs, setup_project_py, setup_sh, siblings_family_name, repo_hygiene, agents_md_codes, cli_reference sets, 2359 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 #758

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
…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 from those plan rows or those refusals' details (#766) (#814)

#692 (#731) spelled the folder refusals with `shown_source`, and #672
(#746) spelled each dirty file's name; the paths beside them still printed
as they were. Measured with a stub `gh`: 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 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;
  `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. The commands a remedy prints
for a person to paste (`switch -c`, `add`, `rm -f`, and the `switch -c`
line of `add`/`bump`/`remove --dry-run`) are quoted for a shell as they
were, and still hold such a name raw inside the quotes. The docstrings of
`_checked_branch` and `_checked_index_unlocked` say so; the follow-ups are
on #459 and #458.

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. The rows for `family-holder-already-here` and the bump-leg
branch refusals also hold a printable name, named unquoted, byte for byte.
`tests/conftest.py` keeps the names and helpers they share, #731's among
them. `family-remote-exists` and `scaffold-remote-exists` cannot be reached
from the command line with such a name (`*-unwritable-value` refuses it
first, already spelled), so their rows ask the question in process. 34 new
tests: 30 fail on the code before the change (the printable rows pass by
design), and each of 21 one-line mutants fails at least one.

Landed after #731 and #746, on which it was stacked.

Closes #766
Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…lper whether a name that is not UTF-8 can be made and skip on its two answers only, and three tests skip where an ASCII file name encoding cannot carry their text (#757) (#806)

Against main. #723 landed as `bad05d2`, and this branch is restacked onto main as `76ae6419`: it merges #723's final head `4cdb427c`, then main, taking this branch's side in `tests/test_non_utf8_git_reads.py`, the one file #723 also changed. Over main it carries exactly its own change: `git diff 6ba8c5d 6b5e907` and `git diff origin/main 76ae641` add and remove the same lines (7 files, +408/-45).

Tests only. No tool, template, pinned file, README, handbook, `AGENTS.md` or `docs/cli.md` changes (`render-cli-reference.py --check`: `[ok] docs/cli.md is what the generator produces`). Seven test files change, one of them new.

**No Windows run and no macOS run was made.** CI is Linux only since #597. What Windows and macOS answer is emulated (see the tests below), and the emulation holds the skip decisions, not a real disk.

**Line 1: the callers ask the helper, and every other disk error is the test's own.**

Four tests and `directory_named` each made the directory themselves and turned any `OSError` into `pytest.skip`, beside `skip_where_a_name_cannot_be_not_utf_8` from #723. That helper skips on the two answers that mean "this system cannot carry the name" (Windows' `os.fsencode` first, macOS's EILSEQ second) and raises the rest. So a full disk or a `tmp_path` this user cannot write in read as SKIPPED in those five and as a failure in the helper's own tests, and no run fails on a skip.

- `tests/test_stdout_encoding.py`, `tests/test_adopt_git_reads.py`, `tests/test_adopt_template_values.py` and the directory test of `tests/test_adopt_plan_escapes.py` now call the helper first. The directory they make after the probe (`b"b\xffd"`, `caf` and byte 0xE9) is no longer guarded.
- Each test keeps its own second skip: `git will not work under <name>` in the three adopt tests, and `this filesystem rewrote a name that is not UTF-8` in the stdout test.
- The file twin, `test_a_leg_extracts_a_path_that_is_not_utf8` in `tests/test_adopt_plan_escapes.py`, asked the same question of a FILE (`caf` and byte 0xE9 plus `.txt`, `write_bytes`, `unlink`). It calls the helper too and no longer writes a probe file of its own. I chose the helper over `make_file`'s skip because `make_file` also skips on any `OSError` of the write (see the candidates), and because the helper's probe is a name that is not UTF-8 under the same directory, which macOS refuses for a file and a directory alike. The file itself is made later, by git, in `filter-repo`'s clone.
- `directory_named` in `tests/test_clone_url_bytes.py` is split. `skip_where_this_system_cannot_name(tmp_path, raw)` asks the helper for a name that is not UTF-8 (`raw.decode("utf-8")` fails) and keeps `directory_named`'s own Windows skip for a control character (a byte below 0x20), which Windows refuses in any file name. `directory_named` then makes the directory with an unguarded `os.mkdir`.

**The Windows half of #459's line (R433 O5, issuecomment 6214823552), decided with line 1.** The old `directory_named` skipped every row on Windows, the `bidi-override` row (the name U+202E plus `evil`, as UTF-8) included, though NTFS holds U+202E. Now the bidi row is not skipped on Windows: the name is valid UTF-8 and holds no control character. **This is reasoned and emulated, not run.** If a Windows run finds the row's other assertions (a refusal text, `str(bad.resolve())`, `os.listdir`) fail there for a reason that is not a defect, the fix is one more skip in `skip_where_this_system_cannot_name`.

**Line 2: the `make_file` test under an ASCII file name encoding.** `test_make_file_writes_a_name_that_is_utf8_where_fsencode_makes_no_byte` asserted `skip_message(make_file, ...) is None`, and `make_file` cannot write `café.yml` where the encoding is ASCII, so the assert failed. The test now asks `skip_where_the_environment_cannot_carry(UTF8)` first, before it swaps in the Windows `os.fsencode`, so it skips for the encoding and for nothing else. The assert is unchanged, so a write refused with EACCES or ENOSPC still fails the test.

**Beyond the issue's measured set.** The issue measured `1 failed, 6 passed, 2 skipped` under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0` on the `make_file` tests. Its Verify asks that the WHOLE file `tests/test_non_utf8_git_reads.py` pass under that environment, and on `6ba8c5d2` it gives `6 failed, 40 passed, 6 skipped`. The other five failures hold no defect either: `subprocess` puts each environment value through `os.fsencode`, which raises `UnicodeEncodeError` for `git config user.name` of an accented name, a U+200C name or a U+202E name, in the test and before the tool runs. They are `test_a_user_name_that_is_text_still_initialises[accent-dry-run]`, `[accent-real]`, `[zwnj-dry-run]`, `[zwnj-real]` and `test_every_other_name_no_file_can_hold_is_refused_the_same_way[u202e]`. Both tests now call `skip_where_the_environment_cannot_carry(value)`, which skips where `os.fsencode(value)` raises `UnicodeEncodeError` (the `make_file` test above calls it too, with the file name), and says so as "this system's file name encoding (...) cannot carry '<value>'". ASCII names, and a lone surrogate that `os.fsencode` gives back as its byte, are not skipped. On Windows and under a UTF-8 file name encoding nothing skips that did not before.

Lane 1's review of `41849725` asked for three fixes and a nit. All four are in one commit, in `tests/test_non_utf8_git_reads.py` and `tests/test_non_utf8_name_callers.py`.

1. **The `make_file` test skipped on a write the disk refused.** `make_file` skips on any `OSError` of its write with a reason that contains "filesystem", so the first version of the test, which skipped on that reason, ended SKIPPED when `write_bytes` raised EACCES or ENOSPC for the plain UTF-8 name, and the reason it gave was false. The test now asks `skip_where_the_environment_cannot_carry(UTF8)` first and keeps its strict assert. The helper's reason no longer ends "in an environment", and the callers file reads "file name encoding". The ASCII row patches `os.fsencode`, and two rows make `Path.write_bytes` raise EACCES and ENOSPC and expect the test to FAIL. The broader narrowing of `make_file` stays a candidate below.
2. **Five own-directory rows failed on a disk that refuses a name that is not UTF-8** (macOS's APFS, a ZFS with `utf8only`): they let the probe through to the real disk, and the helper skipped. `a_disk_that_takes_the_probe` asks the real disk first and only those rows use it. Measured with a plugin that makes `os.mkdir` answer EILSEQ: `31 passed, 5 skipped` (without the fixture, `5 failed, 31 passed`).
3. **S5778 (two places).** `fail_where_the_caller_ends` holds the `pytest.fail`, so each `pytest.raises` block holds one call, as does the new disk-error row.
4. **The Windows rows' names.** The byte and control-character rows now call `directory_named` itself (chosen over renaming), so its order, asking before it makes anything, is held. The bidi row asks the decision function and is named for it.

A new file, so it stays clear of the open branches that edit the five. Each caller is called as a plain function with the disk answering as it would on a system CI does not have:

| group | what the disk does | the caller must | tests |
|---|---|---|---|
| macOS | the probe `probe` + U+DCFF is refused with EILSEQ | skip, on the helper's reason | 6 |
| a folder nobody can write in | the probe is refused with EACCES | fail with `PermissionError`, never skip | 6 |
| the test's own directory | the probe passes, the test's own directory is refused with EACCES. These rows are skipped where the real disk refuses the probe (EILSEQ): a fixture asks it first | fail with `PermissionError`, where it skipped | 5 + 1 (the fixture asked of a disk made to answer EILSEQ) |
| Windows (`os.name` is `nt`, `os.fsencode` gives a surrogate three bytes) | nothing is made | `directory_named` skips for byte 0xE9 (reason names `os.fsencode`) and for a line feed and an escape (names the control character), and does not skip for the bidi override | 4 |
| a system that is not Windows | the disk takes it | `directory_named` makes a directory named with a line feed or an escape | 2 |
| ASCII file name encoding | `os.fsencode` is ASCII with `surrogateescape` | the `make_file` test and the two environment tests skip, on "file name encoding"; the helper skips no text that is carried | 1 + 3 + 4 + 2 |
| a write the disk refuses | `Path.write_bytes` raises EACCES or ENOSPC for the plain UTF-8 name | the `make_file` test still FAILS on its assert, and does not end SKIPPED | 2 |

The six callers are the four tests, `directory_named` and the file twin (the last has no directory of its own, so it is not in the own-directory group). The Windows rows for a byte and a control character call `directory_named` itself, so that it is held to asking before it makes anything; the bidi row asks `skip_where_this_system_cannot_name`, because `directory_named` goes on to make a `Path`, which under an emulated `os.name` of `nt` is a `WindowsPath` that Linux cannot make. The "carried" rows (ASCII text, a lone surrogate that is a byte, a line feed, an escape) hold that the environment skip is not wider than it should be.

All with a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR` and `/usr/bin/python3`, at head `6b5e9074`. Per file: `test_stdout_encoding.py` 48, `test_adopt_git_reads.py` 21, `test_adopt_template_values.py` 78, `test_adopt_plan_escapes.py` 59, `test_clone_url_bytes.py` 30, `test_non_utf8_git_reads.py` 52, `test_non_utf8_name_helpers.py` 18, `test_non_utf8_name_callers.py` 36, and the four files that import `test_stdout_encoding.py` (`test_bootstrap_branch_bytes.py` 61, `test_bootstrap_spelling.py` 205, `test_siblings_family_name.py` 150, `test_validator_child_encoding.py` 55).

- **The twelve files above:** `811 passed, 2 skipped in 295.42s`. Both skips are the same two as on the base and have nothing to do with this change: `test_stdout_encoding.py:285` ("the default this is about is Windows' own") and `test_adopt_template_values.py:224` (`git config user.name` here is not read from `GIT_CONFIG_GLOBAL`).
- **The Verify's C locale**, `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0`, `tests/test_non_utf8_git_reads.py` and the new file: `74 passed, 14 skipped in 20.01s`. The 14 are the 6 that skipped on the base, the 6 this change turns from failures into skips (the `make_file` test, two accent rows, two U+200C rows and the U+202E row), and the 2 disk-error rows of the new file, which need an encoding that can write the name. On the base the first file gives `6 failed, 40 passed, 6 skipped`.
- **A disk that answers EILSEQ to a name that is not UTF-8** (a test plugin that makes `os.mkdir` answer so, as macOS's APFS does), the new file: `31 passed, 5 skipped`. The 5 are the own-directory rows, skipped by the fixture; without it they fail (mutant M9b).
- **`tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`:** `146 passed in 31.39s`.

The new file copied onto `6ba8c5d2` fails 33 of its 36 tests: `33 failed, 3 passed in 27.36s`. The decisive lines: the probe-refusal rows end `Failed: the caller ended without an error` (the base never asks the probe); the own-directory rows end `Failed: the caller ended SKIPPED: no directory here is named b'caf\xe9': PermissionError(13, 'Permission denied')` (stdout: `this filesystem holds no name that is not UTF-8: [Errno 13] Permission denied`; `directory_named`: `this disk cannot hold the name b'caf\xe9': [Errno 13] Permission denied`); the C-locale rows fail on `UnicodeEncodeError: 'ascii' codec can't encode character ...` and on the `make_file` assert. The 3 that pass on the base are the two disk-error rows (the base's `make_file` test is strict, so they guard against the first version of this change, which skipped: on `41849725` they and the ASCII row fail, `3 failed, 33 passed`) and the fixture row, which holds a fixture the new file defines. The seven existing files (the five, `test_non_utf8_git_reads.py` and `test_non_utf8_name_helpers.py`) pass on the base too (`304 passed, 2 skipped`).

Each hunk reverted alone, in a throwaway clone at `6b5e9074`, new file only (36 tests). Every one is caught, by the test it is meant for, except where it says otherwise.

| mutant | result | caught by |
|---|---|---|
| M1 / M2 / M3 / M4: the helper call removed from the stdout, git-reads, template-values and plan-escapes directory test | `2 failed, 34 passed` each | the EILSEQ row and the EACCES-on-the-probe row of that caller |
| M1b / M2b / M3b / M4b: the same four with their own `mkdir` guarded by `except OSError: pytest.skip` again | `1 failed, 35 passed` each | the own-directory row of that caller |
| M5: the file twin's helper call removed | `2 failed, 34 passed` | the EILSEQ and EACCES rows of `adopt-plan-escapes-file` |
| M5b: the file twin's old broad write-and-unlink probe put back | `2 failed, 34 passed` | the same two |
| M6a: the helper call removed from `skip_where_this_system_cannot_name` | `3 failed, 33 passed` | EILSEQ, EACCES and the Windows byte row |
| M6b: `directory_named`'s own `os.mkdir` guarded again | `1 failed, 35 passed` | the own-directory row of `clone-url-bytes` |
| M6c: the old blanket `os.name == "nt"` skip back | `1 failed, 35 passed` | `test_where_windows_holds_a_bidi_override_the_name_check_does_not_skip` |
| M6d: the control-character test made `False` | `2 failed, 34 passed` | both Windows control-character rows |
| M6e: `directory_named` asks after it makes the directory | `3 failed, 33 passed` | the Windows byte row and both control-character rows (new in the fix round: they call `directory_named` itself) |
| M7: the `skip_where_the_environment_cannot_carry(UTF8)` line removed from the `make_file` test | `1 failed, 35 passed` | `test_make_files_utf8_name_test_skips_where_the_encoding_is_ascii` |
| M7b: the broad `"filesystem" in reason` skip back in the `make_file` test, the encoding question kept | `2 failed, 34 passed` | `test_a_write_the_disk_refuses_still_fails_the_make_file_test[EACCES]` and `[ENOSPC]` (new) |
| M8a: `skip_where_the_environment_cannot_carry` returns instead of skipping | `6 failed, 30 passed` | the `make_file` ASCII row, the three "cannot carry" rows and the two-test rows |
| M8b / M8c: its call removed from the text test, or from the every-other-name test | `1 failed, 35 passed` each | the matching row of the two-test group |
| M8d: its reason no longer says "file name encoding" | `6 failed, 30 passed` | the same six, on the new `"file name encoding" in reason` asserts |
| M9a: the probe fixture `a_disk_that_takes_the_probe` asks nothing | `1 failed, 35 passed` | `test_the_fixture_that_guards_those_rows_asks_the_real_disk` (new) |
| M9b: the own-directory rows use the system-only fixture again | `36 passed` on this disk; `5 failed, 31 passed` under a plugin that makes `os.mkdir` answer EILSEQ to a name that is not UTF-8 | **no committed test**: Linux's disk never answers EILSEQ. The probe run is the evidence, the five own-directory rows |
| M10: `fail_where_the_caller_ends` never fails, with the stdout helper call also removed | `2 failed, 34 passed` | the same two rows as M1 (`DID NOT RAISE PermissionError` for the EACCES row). The helper is a refactor of what each row did inline; it has no behaviour of its own that Linux can change |

`git merge-tree --write-tree HEAD <ref>` at `6b5e9074`, against origin/main (`bf1f099b`), against #723 (`6ba8c5d2`, my parent), against `fix/693-local-remote-dir` (`831d76c6`, the one open branch that edits `tests/test_clone_url_bytes.py`) and against every other wave-7 branch (local refs): all clean except two, which are inherited. At the first push (`41849725`) every `fix/*` and `l3-*` head on origin (24 + 22) was checked too, with the same result.

- **`fix/672-bump-leg-branch-and-push-line` (#746, `a5d473ee`) conflicts in `tests/test_non_utf8_git_reads.py`, one region (lines 316-324 of the merged file). It is inherited, not mine:** `merge-tree 6ba8c5d a5d473e` (#723 alone against #746) conflicts in the same file and the same region, and my hunks are at lines 123-127, 497-513, 647 and 668. Whichever of #723 and #746 lands second needs the one conflict resolved by hand, and this branch follows #723.
- **`fix/758-git-command-scope` (local `ceca042f`) conflicts in the same file, in the import block (lines 54-62 of the merged file).** Also inherited: `merge-tree 6ba8c5d ceca042f` conflicts in the same lines.
- `fix/663-...` (#723), `fix/645-...` (#713) and `fix/662-...` (#748) touch `tests/conftest.py`, which this branch does not edit. `l3-785-non-ascii-paths` (`a1e2c9a8`) touches `tests/test_stdout_encoding.py` and merges clean.

- **`make_file`'s own skip** (`tests/test_non_utf8_git_reads.py:100`) turns any `OSError` or `UnicodeError` of the write into a skip. It is the same defect as line 1, in the helper that #723 added, and the issue names it only as the model for the file twin and for line 2. See the candidates.
- **A real Windows or macOS run.** Not possible here.

- **Two more tests make their own "a name that is not UTF-8, or skip" on any `OSError`:** `tests/test_cli_reference.py:632` (`test_a_checkout_whose_path_is_not_utf8_is_a_checkout`, `parent.mkdir()`, then `pytest.skip`) and `tests/test_doctor_validator_verdicts.py:317` (`here.mkdir(parents=True)`, then `pytest.skip`), at `6ba8c5d2`. Same fix as line 1: `skip_where_a_name_cannot_be_not_utf_8(tmp_path)`, then an unguarded `mkdir`. Neither is in #757's lines.
- **`make_file` (`tests/test_non_utf8_git_reads.py:100`) skips on any `OSError` of `path.write_bytes`,** where the helper it calls first raises all but EILSEQ. Only the encoding error (a `UnicodeError` under an ASCII file name encoding) and EILSEQ are the system's answer; a full disk should fail the test.
- **`tests/test_adopt_template_values.py:314`** (`test_execute_refuses_a_local_source_directory_holding_a_bidi_control`) makes a directory `U+202E` plus `caf` and skips on any `OSError` ("no directory here is named ..."). The name is text, not a byte that is not UTF-8, so the helper is not its question, but the broad skip hides a full disk the same way.

Review: lane 1's re-verification of 6b5e907 is PR-READY (issuecomment-6233426675); 76ae641 restacks it onto main after #723 with the same changed lines. `a1498632` merges main `e62b5e17` after #818: its one conflict, `tests/test_adopt_git_reads.py`'s `from conftest import` line, takes both sides' names, and its -U0 changed lines against main equal the reviewed delta but for that line. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a149863 passed tests, tests-git-bash and SonarCloud; the head 22238b4 is a clean update-branch merge of main d6475f9, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 9f94a18 over main d6475f9: the adopt_git_reads, adopt_plan_escapes, adopt_template_values, clone_url_bytes, non_utf8_git_reads, non_utf8_name_callers, stdout_encoding, repo_hygiene, agents_md_codes, cli_reference sets, 486 passed, 2 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 #757

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
… 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>
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