Skip to content

tests: four tests, directory_named and the file twin ask the one helper 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

Merged
brettheap merged 11 commits into
mainfrom
fix/757-non-utf8-test-dirs
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

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 6ba8c5d2 6b5e9074 and git diff origin/main 76ae6419 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.

What changed

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 ''". 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.

Fix round (head 6b5e9074)

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.

The new tests: tests/test_non_utf8_name_callers.py (36)

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.

Tests

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.

Fails on the base

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).

Mutants

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

Merge checks

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.

Left out

Checklist candidates

  • 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 tests: four tests and directory_named still make their own "a directory named with a byte that is not UTF-8, or skip", on any OSError, beside the shared helper #663 adds, and the test that pins make_file's UTF-8 name fails under an ASCII file-name encoding #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.

Closes #757

🤖 Generated with Claude Code

Summary by Sourcery

Narrow non-UTF-8 test skips to platform and encoding limitations while preserving failures for genuine filesystem errors.

Bug Fixes:

  • Ensure tests skip only when the platform or file-name encoding genuinely cannot represent the names they exercise, while still failing on unrelated filesystem and write errors.

Enhancements:

  • Centralize non-UTF-8 name capability checks across affected tests and refine Windows handling so valid bidi names are exercised while control characters remain skipped.

Tests:

  • Add comprehensive emulated-platform and filesystem-error coverage for non-UTF-8 name callers, ASCII file-name encodings, Windows control-character behavior, and strict failure handling.

Brett Heap and others added 4 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>
… 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>
…_values.py, test_adopt_plan_escapes.py, test_clone_url_bytes.py and test_non_utf8_git_reads.py: the four tests and `directory_named` that made a directory named with a byte that is not UTF-8 ask `skip_where_a_name_cannot_be_not_utf_8` and skip on its two answers only, `directory_named` keeps its own Windows skip for a control character and no longer skips a bidi override there, and three tests of `test_non_utf8_git_reads.py` skip where an ASCII file name encoding cannot carry their text, so a full disk or a `tmp_path` nobody can write in fails the test instead of skipping it and a C locale shows no failure that is not a defect (#757)

The four tests (in `test_stdout_encoding.py`, `test_adopt_git_reads.py`,
`test_adopt_template_values.py` and `test_adopt_plan_escapes.py`) and
`directory_named` each made the
directory themselves and turned ANY `OSError` into `pytest.skip`, beside
the helper #663 added to `tests/conftest.py`. 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. They now
call it, and each keeps its own second skip (git will not work under the
name; a listing that shows a rewritten name); the directory they make
after the probe is no longer guarded. The file twin in
`test_adopt_plan_escapes.py` asks the same question of a file name and
calls the helper too.

`directory_named` asks the helper for a name that is not UTF-8 and keeps
its own Windows skip for a control character, which Windows refuses in any
name. The `bidi-override` row, which NTFS holds, now runs on Windows
(reasoned; CI is Linux only, no Windows run was made).

Under `PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0` the file name encoding
is ASCII, and `tests/test_non_utf8_git_reads.py` failed six tests that hold
no defect: `test_make_file_writes_a_name_that_is_utf8_where_fsencode_makes_no_byte`
(the issue's line) and the five rows that put `Jos\u00E9`, a U+200C name or a
U+202E name into a child's environment, where `subprocess` raises
`UnicodeEncodeError` before the tool runs. The first now ends as its
neighbour does, on a skip that names the filesystem; the others skip where
`os.fsencode` cannot carry the value.

`tests/test_non_utf8_name_callers.py` calls each caller as a function with
the disk answering as macOS (EILSEQ on the probe: a skip), as a folder
nobody can write in (EACCES on the probe, and on the test's own
directory: a failure), and `directory_named` as Windows (a control
character skips, a bidi override does not), and holds the two C-locale
skips with an ASCII `os.fsencode` and a `write_bytes` that raises as it
would.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T10:52Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates non-UTF-8 filename tests to ask a shared capability helper before probing platform-specific limitations, while allowing unrelated filesystem errors to fail normally; adds comprehensive Linux-only emulation for macOS, Windows, and ASCII-encoding behavior and adjusts environment-sensitive tests accordingly.

File-Level Changes

Change Details Files
Route filesystem capability checks through the shared non-UTF-8 name helper and let unrelated disk errors fail tests.
  • Probe name support before creating test directories in four adopt/stdout tests and directory_named.
  • Remove broad OSError-to-skip guards from the actual directory creation.
  • Apply the same probe to the file-path twin instead of writing a separate probe file.
  • Preserve test-specific skips for git support and filesystem name rewriting.
tests/test_stdout_encoding.py
tests/test_adopt_git_reads.py
tests/test_adopt_template_values.py
tests/test_adopt_plan_escapes.py
tests/test_clone_url_bytes.py
Make clone URL name skips reflect the actual platform restrictions instead of skipping all Windows cases.
  • Skip non-UTF-8 byte names via the shared helper.
  • Skip Windows control-character names explicitly.
  • Allow the bidi-override name on Windows and perform directory creation without masking errors.
tests/test_clone_url_bytes.py
Handle ASCII filesystem encodings when tests pass non-ASCII names through files or subprocess environments.
  • Skip the make_file assertion only for filesystem encoding failures.
  • Skip environment-dependent cases when os.fsencode cannot carry their values.
  • Keep ASCII, surrogate-byte, and control-character cases running.
tests/test_non_utf8_git_reads.py
Add emulated cross-platform regression coverage for all updated callers and skip boundaries.
  • Verify EILSEQ causes skips while EACCES from the probe or caller-owned directory remains a failure.
  • Emulate Windows behavior for invalid bytes, control characters, and bidi override names.
  • Emulate ASCII encoding behavior for file writes and environment values.
  • Cover every updated caller, including the file twin, with parameterized tests.
tests/test_non_utf8_name_callers.py

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 17, 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-17T10:55:18.260809Z 4184972 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41849725ce

ℹ️ 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".

Comment thread tests/test_non_utf8_git_reads.py Outdated
Comment on lines +127 to +128
if reason is not None and "filesystem" in reason:
pytest.skip(reason)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not turn every make_file failure into a skip

When target.write_bytes() fails for an unrelated reason such as EACCES or ENOSPC, make_file catches that OSError and produces the same reason containing filesystem, so this newly added branch skips the test instead of exposing the disk failure as the previous assert reason is None did. Restrict the skip to the ASCII filename-encoding case rather than accepting every skip emitted by make_file.

Useful? React with 👍 / 👎.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T11:18:05Z)

Lane 1's independent review of #806 at 4184972: CHANGES NEEDED

Lane 1 reviewed this as the diff of 41849725 against 6ba8c5d2 (#723). It also ran the head as a local merge with origin/main bf1f099b.

The tests-only change is mostly sound. Two things need fixing before it is ready, and one is a Sonar smell that SonarCloud has already raised. Linux coverage is unchanged. Everything lane 5 measured reproduced, except one skip: lane 5's run has a second skip at test_adopt_template_values.py:224 and mine had only the stdout one, which looks like an environment difference.

Issue lines

Issue line Status Measured
Line 1, five callers (the four tests and directory_named) Fixed The new callers file copied onto main with #723: 33 failed. On the merge with #806: 33 passed.
Line 1, the file twin (test_a_leg_extracts_a_path_that_is_not_utf8) Fixed The same two runs.
Line 2, make_file test under an ASCII file-name encoding Partly fixed The failure is gone, but only because the skip is too wide (finding 1).

Line 2 in numbers, on test_non_utf8_git_reads.py under PYTHONUTF8=0 LC_ALL=C PYTHONCOERCECLOCALE=0:

Ranked findings

1. The new make_file skip turns a real disk error into a skip (SHOULD FIX)

Codex's P2 comment on tests/test_non_utf8_git_reads.py:128 is right, and I reproduced it.

  • make_file skips on any OSError of write_bytes, with a reason that contains "filesystem".
  • The new lines if reason is not None and "filesystem" in reason: pytest.skip(reason) therefore skip when the disk refuses a write.
  • That is the defect this PR removes elsewhere, and the file name here is plain UTF-8.

Repro, a scratch test run next to conftest.py on each tree:

import errno; from pathlib import Path; import pytest
import test_non_utf8_git_reads as m
@pytest.mark.parametrize("code", [errno.EACCES, errno.ENOSPC])
def test_probe(tmp_path, monkeypatch, code):
    def write_bytes(self, data): raise OSError(code, "disk says no", str(self))
    monkeypatch.setattr(Path, "write_bytes", write_bytes)
    try: m.test_make_file_writes_a_name_that_is_utf8_where_fsencode_makes_no_byte(tmp_path, monkeypatch)
    except pytest.skip.Exception as s: pytest.fail("ENDED SKIPPED: " + s.msg)
    except AssertionError: return
    pytest.fail("ended green")

The new emulation row test_make_files_utf8_name_test_skips_where_the_encoding_is_ascii cannot catch this. It makes write_bytes raise UnicodeEncodeError for a non-ASCII name and checks only that the test skipped.

Suggested fix, which I tested. Ask the encoding question first, before the Windows os.fsencode swap, and keep the strict assert:

    skip_where_the_environment_cannot_carry(UTF8)
    monkeypatch.setattr(os, "fsencode", windows_fsencode)
    target = tmp_path / UTF8
    assert skip_message(make_file, target, b"on: [pull_request]\n") is None
    assert target.read_bytes() == b"on: [pull_request]\n"

Results with that edit:

  • EACCES/ENOSPC probe: both rows fail as on main.
  • ASCII environment: the test skips with "this system's file name encoding (ascii) cannot carry 'caf\xe9.yml'".
  • UTF-8 mode: 3 passed on -k make_file.

Two knock-ons:

  • The shared helper's reason ends "in an environment", which is wrong for a file name. Drop " in an environment" from it, and change the two assert "environment" in reason, reason lines in the callers file to assert "file name encoding" in reason, reason.
  • Replace the ASCII emulation row so it patches os.fsencode instead of Path.write_bytes:
    monkeypatch.setattr(os, "fsencode", ascii_fsencode)
    reason = skip_message(
        non_utf8_git_reads
        .test_make_file_writes_a_name_that_is_utf8_where_fsencode_makes_no_byte,
        tmp_path, monkeypatch)
    assert reason is not None, "the test went on past an ASCII encoding"
    assert "file name encoding" in reason, reason

Add a row that makes Path.write_bytes raise OSError(EACCES) and OSError(ENOSPC) and expects pytest.raises(AssertionError) around that test. I tried this combination: 87 passed on the two files under UTF-8 mode, and 73 passed, 14 skipped under the C locale, because the two disk-error rows skip there, which is honest.

The better long-term fix is to narrow make_file itself to the helper's two answers, UnicodeError and EILSEQ. That also covers the neighbouring control at :130-142 and every other make_file caller. It is your candidate bullet 2, so it can stay a follow-up. But this PR's own sentence ("a full disk ... fails the test instead of skipping it") must not be false for line 2.

2. Five new rows FAIL on a disk that refuses non-UTF-8 names (SHOULD FIX)

test_a_disk_that_refuses_the_tests_own_directory_fails_the_test lets the real probe through to the real disk. Its fixture, a_system_that_carries_the_byte, only asks os.fsencode, so it guards Windows and nothing else.

Repro, a pytest plugin that makes os.mkdir answer EILSEQ to a name that is not UTF-8, loaded with -p:

_real = os.mkdir
def apfs_mkdir(path, *a, **k):
    try: os.fsencode(path).decode("utf-8")
    except UnicodeDecodeError: raise OSError(errno.EILSEQ, "Illegal byte sequence", os.fsdecode(path))
    return _real(path, *a, **k)
@pytest.fixture(autouse=True)
def _apfs(monkeypatch): monkeypatch.setattr(os, "mkdir", apfs_mkdir)

Suggested fix, which I tested. Add a fixture that asks the real disk first, and use it only on the own-directory rows:

from conftest import (NOT_UTF_8_PROBE, skip_message,
                      skip_where_a_name_cannot_be_not_utf_8, windows_fsencode)

@pytest.fixture
def a_disk_that_takes_the_probe(tmp_path, a_system_that_carries_the_byte):
    """The rows that let the probe through ask the real disk first: macOS's
    APFS, or a ZFS with `utf8only`, answers EILSEQ to it, and they would FAIL
    there on a system that holds no defect."""
    skip_where_a_name_cannot_be_not_utf_8(tmp_path)

Then change @pytest.mark.usefixtures("a_system_that_carries_the_byte") to @pytest.mark.usefixtures("a_disk_that_takes_the_probe") on that one test. Results with the fix:

  • Linux: 33 passed.
  • Under the APFS plugin: 28 passed, 5 skipped.

3. Two S5778 smells in the new file (SHOULD FIX)

SonarCloud passed the gate at this head with 2 New issues. Both are python:S5778 ("Refactor this exception test to have only one invocation possibly throwing an exception"):

  • tests/test_non_utf8_name_callers.py:138, in test_a_disk_that_refuses_the_probe_with_eacces_fails_the_caller
  • tests/test_non_utf8_name_callers.py:152, in test_a_disk_that_refuses_the_tests_own_directory_fails_the_test

Each pytest.raises(PermissionError) block holds skip_message(...) and pytest.fail(...). SonarCloud reports no S1192, and duplication on new code is 0.0%.

Suggested fix, which I tested. Move the fail into a helper, so each block holds one call:

def fail_where_the_caller_ends(call, *args) -> None:
    """Fail where `call(*args)` ends without an error, SKIPPED or not: the
    caller was to raise, and one call in a `pytest.raises` block is all it
    may hold (python:S5778)."""
    reason = skip_message(call, *args)
    pytest.fail("the caller ended "
                + (f"SKIPPED: {reason}" if reason else "without an error"))
    with pytest.raises(PermissionError):
        fail_where_the_caller_ends(call, tmp_path, adopter)

This gives 33 passed. With the stdout helper call reverted on top of it, 3 rows still fail, so the rows keep their teeth.

4. Nits

  • The three Windows tests are named "directory_named skips ..." but call skip_where_this_system_cannot_name. directory_named's call order (skip before os.mkdir) is held only by Linux behaviour: a version that called the skip after mkdir would pass. Rename the three tests, or add one row that calls directory_named under windows_names_it.
  • The PR body cites a local script, $Q/v110/run9.sh. See the text replacements below.

Checked and fine

Exact text replacements for the PR body

  1. The "Line 2" paragraph, true once finding 1 is fixed:

    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.

  2. The Tests line:

    All with a stub gh first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR and /usr/bin/python3, at head 41849725.

  3. In the "Mutants" table, the M7 row must match the new line-2 change: "M7: the skip_where_the_environment_cannot_carry(UTF8) line removed from the make_file test". Add rows for the new EACCES/ENOSPC and own-directory-disk tests.

  4. In the "new tests" table, the third group (own directory) needs: "skipped where the real disk refuses the probe (EILSEQ)".

For the checklist (outside the issue; same results on main with #723 and on #806)

Lane: openreposhape-1 (openRepoShape-1)

…ke_file` test asks the file name encoding first and keeps its strict assert, the rows that let the probe through ask the real disk first, and each `pytest.raises` block holds one call, so a write the disk refuses fails the test and no skip is made of it, a disk that refuses the probe skips the rows and does not fail them, and the new file has no S5778 smell (#757)

A fix round for lane 1's review of #806 at 4184972 (three SHOULD FIX, one nit).

1. The `make_file` test skipped on `make_file`'s own "filesystem" reason,
   which `make_file` gives for ANY `OSError` of its write, so a write refused
   with EACCES or ENOSPC for the plain UTF-8 name `caf` and U+00E9 plus `.yml`
   ended the test SKIPPED, and the reason it gave was false. The test now
   calls `skip_where_the_environment_cannot_carry(UTF8)` first, before it
   swaps in the Windows `os.fsencode`, and keeps the strict
   `assert skip_message(make_file, ...) is None`: it skips for the file name
   encoding and for nothing else. The helper's reason loses " in an
   environment", which was wrong for a file name; the callers file reads
   "file name encoding". The ASCII row now patches `os.fsencode`, and two
   rows make `Path.write_bytes` raise EACCES and ENOSPC and expect the test
   to FAIL.
2. The own-directory rows let the probe through to the real disk, and
   their fixture asked only `os.fsencode`, so on macOS's APFS or a ZFS with
   `utf8only` the real probe answers EILSEQ, the helper skips, and the row
   failed. `a_disk_that_takes_the_probe` asks the real disk first and those
   rows alone use it; a row makes the disk answer EILSEQ and asks the
   fixture.
3. `fail_where_the_caller_ends` holds the `pytest.fail`, so each
   `pytest.raises` block holds one call (python:S5778, two places, and the
   new EACCES/ENOSPC rows).
4. The Windows rows for a byte and a control character now call
   `directory_named` itself, so its order (ask, then make) is held: a version
   that skipped after `os.mkdir` leaves a directory or fails on
   `os.fsencode` of a path. The bidi row asks `skip_where_this_system_cannot_name`
   and is named for it, because `directory_named` goes on to make a `Path`,
   and under an emulated `os.name` of `nt` that is a `WindowsPath`.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-17T11:37:08Z)

Lane 1's re-verification of #806 at 6b5e907: PR-READY

I fetched refs/pull/806/head and merged it locally with current origin/main bb439a29. The merge is clean. Everything below was run on that merge, one mode, git-filter-repo on PATH.

# Finding Result Measured
1 The make_file test skipped on a disk-refused write FIXED My EACCES/ENOSPC probe: 2 passed, so the test fails honestly on both, as on main with #723. Before, both ended SKIPPED with a false reason. C locale, test_non_utf8_git_reads.py alone: 40 passed, 12 skipped. Main with #723: 6 failed, 40 passed, 6 skipped. The same file plus the new file: 74 passed, 14 skipped, as the body says. UTF-8 mode: 52 passed.
2 Five own-directory rows failed on a disk that answers EILSEQ FIXED My EILSEQ plugin on the new file: 31 passed, 5 skipped, all five skips "needs a file name that is not UTF-8, which this filesystem refuses (EILSEQ)". Before: 5 failed.
3 Two S5778 smells FIXED SonarCloud analysed 6b5e9074: gate OK, 0 new issues, 0 code smells, 0.0% duplication. The two S5778 issues are CLOSED FIXED. No S9073 or S1192.
4 Windows rows called the inner function FIXED The byte row and both control-character rows now call directory_named itself and assert nothing was made. I moved directory_named's skip after its os.mkdir as a mutant, and exactly those three rows failed. The bidi row still asks skip_where_this_system_cannot_name. The body's reason holds: under an emulated os.name of nt, directory_named would build a WindowsPath, which Linux cannot make.

Other runs

The body's new text is true at the head, with three small points:

  1. The "Merge checks" paragraph names origin/main bf1f099b. Main is now bb439a29, and the merge is clean there too. Say bb439a29 if you want it current.
  2. fix/758-git-command-scope (ceca042f) is not on origin, so I could not check that conflict claim.
  3. The body says 2 skips in the twelve-file run. My eight-file run shows only the stdout one; I did not see the test_adopt_template_values.py:224 skip. Both runs had GIT_CONFIG_GLOBAL=/dev/null, so I did not chase it, and it is no defect.

Nothing new that needs a fix. My earlier checklist items still stand, unchanged by this round: the 27 C-locale failures in the five caller files (identical on main with #723 and on #806), the Windows branch naming only bytes below 0x20, and the broad "git will not work under ..." skips.

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 2 commits October 17, 2026 18:13
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
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>
…s `appended_to_command_scope` import and #806's `skip_where_a_name_cannot_be_not_utf_8` import both stay in `tests/test_adopt_git_reads.py`'s `from conftest import` block, as the union of the two names, sorted as the file sorts them

main moved from `677f9bfa` to `e62b5e17`. #818 (`aec3b38f`) changed
`tests/test_adopt_git_reads.py` and `tests/test_non_utf8_git_reads.py`, as #806
does. `tests/test_non_utf8_git_reads.py` merged by itself. In
`tests/test_adopt_git_reads.py` the only conflict was the `from conftest import`
block: #806's side added `skip_where_a_name_cannot_be_not_utf_8`, #818's side
added `appended_to_command_scope` and took the block back to one line. The block
is now both names, the constant first and the rest in alphabetical order, wrapped
at 79 columns as the other test files wrap it. The rest of the file is #818's
change to `command_scope` and to
`test_the_user_name_given_is_appended_to_the_config_the_environment_holds`,
beside #806's use of the skip helper.

Conflicts:
	tests/test_adopt_git_reads.py

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…appended_to_command_scope`, the scan names a count worked out by hand, and `paste()` gives a pasted `submodule update` the `insteadOf` rewrite (#835) (#848)

Stacked on #818 (`fix/758-git-command-scope`, `4a553e17`); retarget to main once it lands. The fix is one commit, `b9ad1187`, written on #818's `eaa72d9e`. The head `fafba9ec` merges #818's refresh `4a553e17` (main `fc867207`) into it, cleanly.

Tests only. Nothing a user runs changes, and no pinned file changes. No README, handbook, `AGENTS.md` or `docs/cli.md` sentence is touched: none mentions these helpers (`render-cli-reference.py --check` passes).

Both reviews say READY with no MUST: rv848 (issuecomment-6240157020) and lane 2's second review of #848 (issuecomment-6240228302, "rv818" below). One commit on `fafba9ec`, by explicit pathspec, fast-forward pushed. Tests only; no pinned file changes; main and #818 are not merged in. The rv818 numbers are next to rv848's.

### What each finding got

| rv848 | rv818 | taken | what changed |
|---|---|---|---|
| 1 SHOULD: #811's and #838's counts | | the text | the merge section below says who converts it |
| 2 NIT: stale merge checks | | yes | re-run at the head, lines corrected below |
| 3 NIT: the walk counts arguments | 6 NIT: it does not read their values | yes, one change | `paste(row, cwd, None)` now fails the walk |
| 4 NIT: `on_disk` is asked for `"true"` only | | yes | `ADDERS` holds `on_disk-false` |
| 5 NIT: the `upload-pack` docstring | | yes | `paste_env` says what the rewrite adds: `pack-objects` and `index-pack` |
| | 1 SHOULD: two command scopes built at collection | yes | #818's second review's SHOULD (#758's line 1), taken here so that #818 needs no third round |
| | 2 NIT: an expected dict is flagged | yes | the scan leaves a dict that is an operand of a comparison |
| | 3 NIT: what the scan misses | the text | "Known limits" below, no code |
| | 4 NIT: the same lines in two files | yes | three helpers in `conftest.py` |
| | 5 NIT: two surviving mutants | in part | the `count_of(env)` case is added; the `remotes_of` one is left, below |

**rv818 SHOULD 1.** `test_adopt_reencoded_files.py`'s `attr.tree` param and `test_family_init_asks_every_value.py`'s `user.name` param were built at collection, before the session fixture put `gc.auto=0` and `maintenance.auto=false` in the environment. Laid over the environment at the test, each dropped the two from git's command scope. Each is a `lambda` its test calls, as `test_adopt_nested_attributes.py` already did.

New `tests/test_command_scopes_built_at_the_test.py` (2 tests) reads each param off its test's own marks, builds it, and asks git for the command scope (`git config --show-origin --list`, read and never written). It asks for the session's two settings beside the param's entry. **It fails on `fafba9ec`** (the two test modules as they were, with the new `conftest.py`): `2 failed`, git read `['user.name=', 'user.email=', 'attr.tree=4b825dc6...']` and `['user.name=', 'user.email=', 'user.name=Ada Lovelace']`, with no `gc.auto=0`. It passes on the head.

**rv848 3 and rv818 6.** `pastes_in` now returns, for each `paste` call, the fixture it hands the remotes of: `remotes_of(<name>)` as the third argument or as `remotes=`, where the name is `holder` or `scaffolded_here` and a parameter of the nearest function the call is in. `None`, another call, an expression on the call, a name the test does not take, or no third argument gives no fixture. The list of calls with none is `pastes_without_remotes`, a function of its own, so that a test of it can fail. 15 new cases hold the walk: twelve for what a call may hand and three for the function it is in.

**rv818 2.** `counts_set_to_a_number` skips a `Dict` that is an operand of a comparison (`assert added == {"GIT_CONFIG_COUNT": str(held + 1), ...}`, `!=`, a dict on the left). I took the narrower reading of "inside an assert or a comparison": a dict in a call inside an assert (`assert run(env={"GIT_CONFIG_COUNT": "1"}).returncode == 0`), or one assigned before the comparison, still sets a count and is still read. Four cases are still found, three are left, and the `count_of(env)` case of rv818 5 is left.

**rv818 4.** `JOB_ENTRIES`, `put_job_and_session_entries(monkeypatch)`, `command_line_entries(listing)` and `command_scope_at(environ, cwd)` in `conftest.py` replace the `delenv`, `held = ...` and loop lines of `test_no_auto_maintenance.py` and `test_doctor_paste_through_git.py`, and the four-line read of the `command line:` entries in three files. `OWN_COUNTS` follows: `conftest.py` holds two (the sum, and the job's entries counted from nothing), `test_no_auto_maintenance.py` one, and `test_doctor_paste_through_git.py` none. A new test checks that both readers put `GIT_CONFIG_PARAMETERS` aside (rv818's own round-2 NIT 8).

**rv818 5, left.** `remotes_of` returning `fixture["base"]` instead of `fixture["base"] / "remotes"` still survives, and I name it equivalent in practice: the prefix `<base>/` covers every remote under `<base>/remotes`, so all the pasted clones are rewritten either way.

### Merge checks (replace the body's section)

Re-run at the head `79b2eac4` (`git merge-tree --write-tree`), against 152 heads: every `fix/*` and `l3-*` branch, and the pull heads from #700. Main `fc867207` is an ancestor of the head (it came in with #818's refresh `4a553e17`).

- **Main.** `fc867207`: clean. Main is `190133d5` now (two commits on `family.py`, the README and the handbook): clean too.
- **#818's head `4a553e17`:** clean. The body's "one conflict, `tests/test_non_utf8_git_reads.py`" was true only for `b9ad1187` and `eaa72d9e`, before the refresh.
- **The open heads that edit the files here merge clean:** #811 (`050abea6`), #820, #822, #828 (`ce0f2a5f`), #774 (`012a7726`) and #775 (`8c5ad0e8`). #761, #762 and #763 are the same heads as #828, #820 and #822. The body said #774 and #775 conflict; they do not, since the refresh.
- **#838's pushed head `9166f84f`:** clean.
- **Conflicts the other heads show** are the ones #818's head shows too (20 heads, in files this PR does not touch: `tests/test_adopt_git_reads.py`, `tests/test_non_utf8_git_reads.py`, `tests/test_dry_run_parity.py`, `shape-doctor.py`, `scripts/shape_materialize.py` and others). #806 (`76ae6419`) and #845 (`b18b243e`, branch `fix/834-oserror-skips`) conflict in `tests/test_adopt_git_reads.py`, which this PR does not touch.
- **This commit adds two, both stale PR heads whose squash is in main:** #767's `bd6d2afe` (landed as `98f14976`, in `tests/test_family_add_spellings_remedies.py`) and #809's `98561405` (landed as `42aed788`, in `tests/test_family_init_asks_every_value.py`).
- **Whichever of #848, #811 and #838 lands later converts that count to `appended_to_command_scope`.** #811 (`fix/777-typed-name-edges`, `050abea6`) adds `"GIT_CONFIG_COUNT": str(held + 1),` at `tests/test_setup_project_name_edges.py:190`: the scan finds `[190]` where `OWN_COUNTS` allows none, so `test_no_test_sets_a_git_config_count_of_its_own` fails once #848 and #811 are both on main. rv848 measured the same for #838's local `3845fd54` at `tests/test_github_name_limits.py:503`; the head #838 has pushed, `9166f84f`, holds no `GIT_CONFIG_COUNT` in that file. It is the goal of #835, so it takes no `OWN_COUNTS` entry. No file of theirs is changed here.

### Known limits (add to the body)

Shapes the scan does not read, so a test that sets a count this way passes it:

- a count key name imported from another module, held as a class attribute, bound by tuple unpacking or by a walrus (only a plain or annotated assignment of the name in the same module is read);
- a value built by `format()`, `repr()` or `os.fsdecode()` (`str(...)`, an f-string, a `.format(...)` method and arithmetic are read);
- a conditional value, `"2" if job else "1"`;
- `setenv(name=..., value=...)`, with keywords;
- an annotated subscript, `env["GIT_CONFIG_COUNT"]: str = ...`;
- a value held in a variable and set later (`new = str(n + 1)`, then `{"GIT_CONFIG_COUNT": new}`);
- an indexed `GIT_CONFIG_KEY_{n}` or `GIT_CONFIG_VALUE_{n}` name, which a hand-worked append writes beside its count;
- a dict that is an operand of a comparison, by design (it is the value a test expects).

This replaces a separate issue for them.

### Tests, through `run9.sh`

- At the commit `79b2eac4`: **222 passed** (`test_command_scopes_built_at_the_test.py` 2, `test_family_config_appends.py` 8, `test_doctor_paste_through_git.py` 21, `test_no_auto_maintenance.py` 60, `test_adopt_reencoded_files.py` 53, `test_family_init_asks_every_value.py` 78).
- The same content, run before the commit: **368 passed** (those and `test_doctor_legs_members_next.py` 127, `test_clone_just_pushed.py` 19) and **253 passed** (`test_doctor_next_is_a_command.py`, `test_doctor_validator_rows.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`).
- New tests: 24 (2 + 11 + 2 + 9). Before the round: `test_doctor_paste_through_git.py` 10, `test_family_config_appends.py` 6, `test_no_auto_maintenance.py` 51.
- Fail on `fafba9ec`: the command-scope test above, `2 failed`. The walk, `on_disk-false` and the scan cases pass on the code of `fafba9ec`, which is right; what they add shows in the mutants, where four survive the files of `fafba9ec` and none survives these.

### Mutants, 29 in a throwaway clone at the head, each caught

- **rv848 4:** `on_disk` ignoring its value. Caught by `on_disk-false` (2 failures); survives the `test_family_config_appends.py` of `fafba9ec`.
- **rv848 3, rv818 6:** one caller handing `None` (M01), `remotes_of(tmp_path)` or `remotes_of(project)`: each caught by the walk of the real file, and each survives the walk of `fafba9ec`. Eight mutants of the walk itself (the `remotes=` keyword, the parameter check, the remote-fixture check, the argument count, the `remotes_of` name, the nearest-function rule, the call check, and `pastes_without_remotes` always empty): each caught by its own case.
- **rv818 1:** each of the two params built at collection again, and each building nothing: 4 mutants, each caught by `test_command_scopes_built_at_the_test.py`.
- **rv818 2:** no skip of expected dicts (4 failures), every dict in an assert skipped (4), only the left operand skipped (3), only the comparators skipped (1).
- **rv818 5:** any call by a bare name taken as worked out: caught by the `count_of(env)` case.
- **rv818 4:** the helper putting only the job's entries (2), the reading taking another line (10), either helper keeping `GIT_CONFIG_PARAMETERS` (1 each), the job's second entry another value (6).
- **`OWN_COUNTS`:** `conftest.py` allowed one (2 failures), this file allowed two (1), no allowance for `test_family_config_appends.py` (1).

1. **Line 1.** `on_disk`, `with_config` and `typed_with_local_remotes` each worked out their `GIT_CONFIG_COUNT` from the environment's own count. Each is now one call of `appended_to_command_scope(env, [(key, value)])`; `typed_with_local_remotes` sets each key the helper returns, inside its `monkeypatch.context()`. The scan found a fourth: `test_clone_just_pushed.py`'s `env` for `clone.defaultRemoteName` summed its own count too, and goes through the helper.
2. **Line 2.** `counts_set_to_a_number` finds a count worked out in the same four places it finds one written out (a dict, a keyword, a `setenv`-style call, a subscript): a `str(...)` call, an f-string, a `.format(...)` call or arithmetic. `OWN_COUNTS` lets `conftest.py` hold the one sum that remains, in `appended_to_command_scope`.
3. **Line 3.** `paste(row, cwd, remotes=None)` and `paste_env(remotes=None)`. Given the fixture's remotes, they append `insteadof_entries(remotes)` after `protocol.file.allow`, in the same count. Every `paste` in `test_doctor_legs_members_next.py` hands `remotes_of(<fixture>)`, and a walk of that file holds each call to it.

The issue says each site is right today, and it is: the held-two case below passes on the base. What the helper adds is an empty `GIT_CONFIG_COUNT`, which git reads as no entries and `appended_to_command_scope` counts as none (`or 0`). The sums written out by hand died on `int("")`.

`tests/test_family_config_appends.py` (6 tests) starts each helper under `GIT_CONFIG_COUNT=2`, the Windows job's two entries, and asks `git config --show-origin --list`: both held entries and the new key, in order. It starts each under an empty count and checks the entry is the first. `typed_with_local_remotes` is asked from inside `typed_as_printed`, and the environment is checked to be back as it was.

**Yes.** Measured by running the extended scan over every `tests/*.py` at the base: it finds exactly the three sites, `test_clone_just_pushed.py:99`, and `conftest.py:290`, the helper itself. No other file, so no false positive.

- The scan reads the SHAPE of the value and never evaluates it. `str(...)`, an f-string, `.format(...)` and arithmetic are found. A value held in a name, read by subscript (`added["GIT_CONFIG_COUNT"]`) or returned by any other call (`env.get(...)`) is still not read, and so is `int(env.get("GIT_CONFIG_COUNT", "0"))`, which sets nothing. `is_a_written_count` still reads constants only; the new `is_a_worked_out_count` is the one that reads shapes, and both docstrings say so.
- `test_the_scan_leaves_a_count_that_is_worked_out` moved its `worked-out` and `a-call` (`str(held)`) cases to the found list, and is now `test_the_scan_leaves_what_it_does_not_read`, with the cases that are still not read.
- `OWN_COUNTS` gains `conftest.py: 1` and the two new files (`test_family_config_appends.py: 2`, `test_doctor_paste_through_git.py: 1`), each a test that builds an environment on purpose. A second sum in `conftest.py`, or none, is named, as it is for every other file.
- `test_the_scan_names_each_helper_that_worked_its_count_out` holds the three helpers' old source, so the scan names the line of each.

**Yes.** Measured on a pasted `git -C <root> submodule update --init --recursive --checkout -- code` of an empty mount, with `GIT_TRACE`:

| | no rewrite (base) | with the rewrite |
|---|---|---|
| clone's store | 8 loose objects, 0 packs | 0 loose objects, 1 pack |
| link count of the loose objects | 2 (linked to the remote's own files) | none |
| `upload-pack` in the trace | yes (a local clone starts it to list the refs) | yes |
| `pack-objects` and `index-pack` in the trace | no | yes |
| `origin`, and the `submodule.code.url` the root records | the plain path | the plain path |

Across the whole of `test_doctor_legs_members_next.py` (trace2 events of all 56 `sh -c` lines it runs), no pasted line ran `pack-objects`: every pasted clone was a local clone. A pasted fetch needs no rewrite (it asks `upload-pack` whatever the URL is), so the rewrite matters where a mount is cloned: 14 of the 31 tests that paste.

**The issue's Verify names `upload-pack` and `pack-objects` for the `GIT_TRACE` run. `upload-pack` is in both columns,** so the tests ask for `pack-objects` and `index-pack`, and for the store `assert_store_read_through_git` asks of every other clone.

#645 keeps maintenance from starting, so nothing breaks today. #662 put every other read of these remotes through git as a second guard, and `paste` was the one left. The rewrite is for the directory resolved, which is how `.gitmodules` records each url; no pasted line names a remote itself, so `typed=True` is not needed.

`tests/test_doctor_paste_through_git.py` (10 tests):
- a pasted update of an empty mount of a project, and of a holder's member with its legs: `pack-objects` and `index-pack` run, `assert_read_through_git`, `origin` equal to the url `.gitmodules` records, no `insteadOf` stored in the root or the clone;
- the control, handed no remotes: a local clone, loose objects, no `pack-objects`;
- the entries in order: the environment's held entries and the session's two settings, then `protocol.file.allow`, then each `insteadOf` entry;
- the walk: every `paste` of the doctor file is given three arguments, and the walk's own five cases.

Base `eaa72d9e`, before: the three family files 117 passed; `test_no_auto_maintenance.py`, `test_clone_just_pushed.py` and `test_repo_hygiene.py` 132 passed; `test_doctor_legs_members_next.py` 127 passed.

On the head `b9ad1187`, through `run9.sh` (gh stub first, `GH_TOKEN=bogus`, empty `GH_CONFIG_DIR`):
- **340 passed:** `test_family_head_lost_gitmodules.py`, `test_family_bootstrap_gate_order.py`, `test_family_add_spellings_remedies.py`, `test_family_config_appends.py`, `test_no_auto_maintenance.py`, `test_clone_just_pushed.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`.
- **261 passed:** `test_doctor_paste_through_git.py`, `test_doctor_legs_members_next.py`, `test_validate_family_remedies_typed.py`, `test_doctor_next_is_a_command.py`, `test_doctor_validator_rows.py`.

New or changed tests: 31 new (6 + 10 + 15; `test_no_auto_maintenance.py` goes from 36 to 51 tests).

**Fail on the base**, in a tree detached at `eaa72d9e` with the new test files copied in:
- the helpers' tests and the scan: 4 failed, 53 passed. The empty-count case fails for `on_disk`, `with_config` and the typing helper (`ValueError: invalid literal for int()`), and `test_no_test_sets_a_git_config_count_of_its_own` names `test_clone_just_pushed.py [99, 233]`, `test_family_add_spellings_remedies.py [138]`, `test_family_bootstrap_gate_order.py [136]` and `test_family_head_lost_gitmodules.py [98]`;
- the same file with the scan as it was (written counts only): 14 failed;
- `test_doctor_paste_through_git.py`: ImportError (`remotes_of`), 10 not run; with `paste` ignoring the remotes (mutant 3a), the three pasted-clone tests fail.

**Mutants**, 22 in a throwaway clone at the head, each caught:
- 1a-1c each helper back to a hand-worked sum: `test_no_test_sets_a_git_config_count_of_its_own`, and the empty-count case; 1d the clone's `env`: the same scan test; 1e `on_disk` keeping a count of 1 held in a name, which the scan does not read: the held-two case;
- 2a-2d `str`, `.format`, arithmetic and f-string dropped from `is_a_worked_out_count`; 2e-2h each of the four readers back to written counts; 2i-2k an `OWN_COUNTS` entry removed; 2z the scan as it was on the base;
- 3a `paste` not handing `remotes` on; 3b `paste_env` dropping the rewrite; 3c the rewrite put before `protocol.file.allow`; 3d one caller not handing the remotes (the walk); 3e `remotes_of` naming the wrong directory.

- origin/main `fc867207`: one conflict, `tests/test_non_utf8_git_reads.py`, the one #818 has with main at its old head. It is the same with the base `eaa72d9e`, and none of the files here conflicts.
- #818's head now, `4a553e17` (main merged in): clean.
- The 141 other heads (every `fix/*` and `l3-*` branch, and the pull heads from #700): the conflicts they show are the same with the base, in files this commit does not touch (`tests/test_non_utf8_git_reads.py`, `tests/test_adopt_git_reads.py`, `tests/test_dry_run_parity.py` and others). This commit adds one: #767's stale PR head `bd6d2afe`, which landed as `98f14976`.
- The open heads that edit the files here merge clean with it: #820, #822, #828, #748, #747, #761, #762 and #763. #774 and #775 conflict only in `tests/test_non_utf8_git_reads.py`, as they do with the base.

- Left out: `GIT_CONFIG_KEY_{n}` and `GIT_CONFIG_VALUE_{n}` names built from an index (an f-string) are not read, nor is a count held in a name and set later (`new = str(n + 1)`, then `{"GIT_CONFIG_COUNT": new}`). A test that did that would pass the scan. Reading the indexed key names in the same four places would close it.
- `typed=True` is not used for `paste`; the entries are for the resolved directory.

Review: rv848, lane 5's independent review, READY (issuecomment-6240157020); lane 2's independent review, READY (issuecomment-6240228302); rv848's re-verification of the fix round 79b2eac, READY (issuecomment-6241026517). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 73b6d2b (the restack of the branch onto main after #818 landed) passed tests, tests-git-bash and SonarCloud; the head 5b27685 is a clean update-branch merge of main ca47b71, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4dbb4a9 over main ca47b71: the adopt_reencoded_files, clone_just_pushed, command_scopes_built_at_the_test, doctor_legs_members_next, doctor_paste_through_git, family_add_spellings_remedies, family_bootstrap_gate_order, family_config_appends, family_head_lost_gitmodules, family_init_asks_every_value, no_auto_maintenance, repo_hygiene, agents_md_codes, cli_reference sets, 645 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 #835

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

Copy link
Copy Markdown

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

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

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

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

@brettheap
brettheap merged commit d6071c7 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/757-non-utf8-test-dirs branch October 18, 2026 04:17
brettheap pushed a commit that referenced this pull request Oct 18, 2026
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 18, 2026
… for: tests/test_non_utf8_name_callers.py

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