Skip to content

tests/test_doctor_legs_members_next.py and 24 other files: tests append 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

Merged
brettheap merged 14 commits into
mainfrom
fix/758-git-command-scope
Oct 17, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

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.

Fix round 1: the retarget onto main, and rv818

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.

The fix commit eaa72d9e

Tests at eaa72d9e

All runs went through run9.sh, with the real gh unreachable. All passed, none skipped:

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:

Line 1: PASTE_ENV (measured, not only read)

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.

Line 2: the appends built by hand, and the counts set by hand

  • 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 adopt-project.py: check asks this machine's git what the split stores for each file the plan keeps or moves and each pinned copy, so one it would store as other bytes no longer passes check and fails after both legs exist (#654) #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({}, ...).

Line 3: the six git clone --no-local calls, per call

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.

The two comments (the issue's last comment)

Tests

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.

Mutants

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.

Merge checks

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

Left out, and checklist candidates

Closes #758

🤖 Generated with Claude Code

Summary by Sourcery

Preserve inherited Git command-scope settings across the test suite while strengthening coverage against configuration overrides.

Bug Fixes:

  • Preserve existing Git command-scope configuration when tests add temporary Git settings, including pasted doctor commands and Windows-specific entries.
  • Ensure test helpers and clone scenarios retain session maintenance settings and other inherited Git configuration.

Enhancements:

  • Centralize test environment configuration through command-scope append helpers and replace static configuration snapshots with call-time environments.
  • Add hygiene checks that detect test code which overwrites GIT_CONFIG_COUNT instead of appending to inherited entries.
  • Consolidate local clone setup behind the existing clone helper while preserving bare and non-recursive clone behavior.

Tests:

  • Expand maintenance and environment tests to cover pasted commands, configuration-count detection, inherited settings, and helper behavior.

Brett Heap and others added 9 commits October 16, 2026 12:12
… in command scope and in a global config that includes the person's own, so a `copytree` of a fixture no longer meets a detached repack deleting its loose objects (#645)

`shutil.copytree` walks a repository's `objects/` one file at a time, and
the suite copies a repository a fixture has just committed in, fetched into
or pushed to in 139 places: `project`, `test_family.py`'s `holder`, ten
copies of bare remotes and two of a `.git` directory. Every `git commit`,
`merge` and `fetch`, and the `receive-pack` a push starts in the remote,
runs `git maintenance run --auto` as it finishes. Since git 2.47 that run
detaches and holds `objects/maintenance.lock`; since git 2.54 its default
`geometric` strategy repacks a small repository and deletes the loose
objects it packed. A copy taken in that moment dies with `[Errno 2]`, as
`holder` did on the Linux runner.

A session autouse fixture, `no_auto_maintenance`, sets `gc.auto=0` and
`maintenance.auto=false` in two scopes:

- command scope (`GIT_CONFIG_COUNT`), appended after the entries the
  environment already holds: every git the suite or a tool under test
  starts inherits it, a `git submodule`'s children too, and it outranks
  every config file;
- a global config file (`GIT_CONFIG_GLOBAL`) that includes the global
  config git would read without it and sets the two keys after it,
  because git starts the `receive-pack` of a push to a local path or a
  `file://` URL with `GIT_CONFIG_COUNT` and `GIT_CONFIG_PARAMETERS`
  removed.

`tests/test_no_auto_maintenance.py` asks git what it starts and reads,
through trace2 events and a `post-receive` hook: a commit, a fetch and a
merge start no maintenance, with the session's global file and with a
test's own; the scaffold and the remotes it pushes to start none; a
remote's `receive-pack` reads both settings off; the entries are appended;
and a person's own global config is still read beneath them.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es `test_doctor_pin_restore_merges.py`'s `the_standard_moved` beside `holder`, and says a `git config --global` read without `--includes` sees the two alone (#645)

Lane 2 met the same race on #623's Linux run (run 38895011713): a setup
error in `test_copies_neither_side_edited_are_taken_whole_with_the_pin`,
`shutil.Error ... standard5/openRepoShape/.git/objects/1e ... [Errno 2]`.
`the_standard_moved` (`tests/test_doctor_pin_restore_merges.py:99`) copies
the session's `standard`, which `test_shape_doctor.py` commits in, and at
:102 copies `moved` just after `moved_on` commits in it. Both commits run
under `{**os.environ, ...}`, so the session's command scope already turns
their maintenance off; the note now names the site.

`git config --global <key>` reads the global file without following its
includes unless `--includes` is given, so under the session's file it sees
`gc.auto` and `maintenance.auto` and nothing of `~/.gitconfig`. The one
such read in this repository is `setup-project.py`'s Windows-only
`core.autocrlf` warning, and no test reads that from a person's own
config. A read with no scope, the kind git itself makes, follows the
include. Comment only.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le URI>.insteadOf` rewrite that `clone_just_pushed`, `read_through_git` and the adopt tests' `pasted` each built, and the `--init` of a mount git keeps no repository for goes through it, so a submodule's clone no longer walks a just-pushed remote's files (#662)

`insteadof_prefixes(remotes, typed=False)` is the one list of prefixes: the
directory resolved, in both of a Windows path's spellings, each with a
separator after it, and the directory as typed first when asked.
`insteadof_entries` pairs each with the one `url.<file URI>.insteadOf` key,
and `insteadof_args` is the same as the `-c` arguments of one git command.
`clone_just_pushed` passes the arguments, `read_through_git` appends the
entries to the `GIT_CONFIG_*` the environment already holds (as
`appended_to_command_scope` does for the session's own, so a pasted line
keeps `no_auto_maintenance` in command scope; it overwrote the count before),
and `pasted` in `test_adopt_e2e.py`, a third copy of the rule, takes the
arguments too.

`submodule_update_through_git(root, remotes, *options)` is the wrapper beside
`clone_just_pushed` for the `--init` of an empty mount. A trace of each
`submodule update` site (GIT_TRACE, before and after) shows which ones clone:
`test_family_bump_remove_rollback.py` at 337 and 908 (both legs of a member),
`test_doctor_legs_members_next.py` at 631 (the `code` leg, its store removed
by `unbootstrap`), 987 (the member and its two legs) and 1054 (the member's
two legs, through `with_its_own_legs`) ran `git clone` of a plain path and no
`pack-objects`; they now run the transport (`pack-objects` and `index-pack`
once per clone). `test_doctor_gitlink_repairs.py:343` and
`test_doctor_unfinished_merge.py:145,171` clone and fetch nothing, so they are
unchanged, and so is the deliberate failure probe at
`test_doctor_legs_members_next.py:607`.

`tests/test_clone_just_pushed.py` holds the helper to its list for a typed and
a resolved directory, given with and without a trailing separator, asks git
(`ls-remote --get-url`) what the `-c` form and the environment form do to a
plain path and to a neighbour that starts with the same text, shows the
environment form keeps the entries the environment holds, and reads an
`--init` through the wrapper as one pack against a plain `--init` that leaves
the remote's loose objects loose.

The change is test only.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s global file includes a `~/` path only where `HOME` is set and includes nothing over an empty `GIT_CONFIG_GLOBAL`, a test holds the fixture to session scope, and the receive-pack test names its own hooks, so a runner with no `HOME` no longer dies on every git (#645)

The review of #713 (rv645) found two things worth fixing and three small ones:

- With `HOME` unset, the include of `~/.config/git/config` and `~/.gitconfig` made every git the session started exit 128 ("could not expand include path"). `session_global_config` now writes a `~/` path only where `home_is_known()` (`HOME` is set, or this is Windows), and leaves out the `[include]` header when nothing is included. An `XDG_CONFIG_HOME` that is set is still included. The two `test_a_persons_own_global_config` cases skip where `HOME` was unset.
- Nothing held `no_auto_maintenance` to session scope: at function scope all seven tests still passed. A new test reads the two variables from a session fixture built first, and compares them with what the test sees.
- An empty `GIT_CONFIG_GLOBAL=` is git's "no global config"; the fixture treated it as unset and included `~/.gitconfig` again. It now includes nothing.
- The receive-pack test set no `core.hooksPath` of its own, so a person's global one hid the remote's `post-receive` hook. It now sets the remote's own `hooks` directory in the remote's local config, which outranks a global one.

Two tests are new for the `HOME` and empty-value cases, with an XDG variant.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e one helper that writes the session file, reads both settings back and returns the probe, so the fix round adds no duplicated test body (#645)

The `HOME`-unset test and the empty-`GIT_CONFIG_GLOBAL` test each wrote `session_global_config(...)` to a file, set `GIT_CONFIG_GLOBAL` and looped over the two settings. That block is now `probe_read_from_the_session_file`, so SonarCloud's duplication gate has nothing to count. No assertion changes: the new file still gives 11 passed, and the same probes fail.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nce-race) into fix/662-insteadof-helper

No conflicts: #713 changed session_global_config, added home_is_known() above it, the comment block and tests/test_no_auto_maintenance.py; this branch's hunks are the insteadof helpers above clone_just_pushed, the wrapper, read_through_git and the sites.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…git` is asked about a remotes directory typed through a symlink, so dropping its `typed=True` no longer passes, and its docstring says the environment it appends to is the one it is laid over at the same moment (#662)

rv662's mutant N3 (`typed=False` in `read_through_git`) passed every test:
`typed` matters only where the caller spells the directory through a symlink,
and the symlink case called `insteadof_prefixes` directly. The new case makes
`link` to the remotes directory and asks git (`ls-remote --get-url`) what the
environment `read_through_git(link)` does to `link/A.git` and to
`remotes/A.git`: both go to the resolved directory's file URI. The mutant now
fails it (1 failed, 18 passed). A small helper, `a_link_to`, makes the link
for this case and the existing symlink case, and skips where the account
cannot.

`read_through_git` reads `os.environ` when it is called, so its result is only
right laid over `os.environ` at the same moment, as `pasted` does; the
docstring now says so.

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

`PASTE_ENV` was a copy of `os.environ` taken when the module was imported,
with `GIT_CONFIG_COUNT` set to 1. On deb029c a line pasted through `paste()`
listed `protocol.file.allow` in git's command scope and nothing else, and no
global file: the copy held neither of #645's two settings, which the session
fixture puts in `os.environ` at session start, nor the two entries the
Windows job sets. `paste_env()` builds the environment at each call and
appends `protocol.file.allow` to the entries `os.environ` holds.

The other tests that gave git a setting through `GIT_CONFIG_COUNT` do the
same, through one call to `appended_to_command_scope` each:
- the six hand-built appends beside it (`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 environments that set a count of their own, which replaced the entries
  the session and the job hold: `ALLOW_FILE_PROTOCOL` (now
  `allow_file_protocol()`), `QUOTE_PATH_OFF`, `SYMLINKS_OFF` and `SYMLINKS_ON`,
  `NO_MAINTENANCE`, and eight one-off environments.

Five of the six `git clone --no-local` calls go through `cloned_just_pushed`,
and the two that copy a member (`clone_member`) and the private bare remote
(`leg_remote`) assert what the clone's options give; the sixth is a `--filter`
clone of a file URI, which the helper cannot take.

`tests/test_no_auto_maintenance.py` pastes a line under an environment that
holds four entries and a global file, and a hygiene test fails any test file
that writes a count as a number or hands `appended_to_command_scope` a dict
written out. Two comments about git's maintenance now say what i645 measured.

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-17T11:57Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

This tests-only change fixes configuration leakage in the test suite by constructing Git command-scope environments at call time and appending test-specific entries to the settings already present, updates pasted-command and clone helpers accordingly, and adds regression plus AST-based enforcement coverage to prevent future replacements.

File-Level Changes

Change Details Files
Make test-created Git command-scope configuration append to the environment’s existing entries instead of replacing them.
  • Replace hand-built GIT_CONFIG_COUNT dictionaries and mutations with appended_to_command_scope(os.environ, ...) or equivalent call-time helpers.
  • Convert reusable configuration constants into entry tuples or functions so session and Windows-job settings are preserved.
  • Update symlink, file-protocol, maintenance, quoting, identity, URL-rewrite, and setup-test environments across the affected tests.
tests/conftest.py
tests/test_adopt_empty_symlinks.py
tests/test_adopt_git_reads.py
tests/test_adopt_ignored_shape_files.py
tests/test_adopt_nested_attributes.py
tests/test_adopt_plan_yaml_quoting.py
tests/test_adopt_root_symlinks.py
tests/test_adopt_split_branch.py
tests/test_bootstrap_after_bump.py
tests/test_doctor_next_is_a_command.py
tests/test_family.py
tests/test_family_bump_remove_rollback.py
tests/test_family_dry_run.py
tests/test_non_utf8_git_reads.py
tests/test_scaffold_free_text_values.py
tests/test_scaffold_reuse_seeded_legs.py
tests/test_setup_project_py.py
tests/test_setup_sh.py
tests/test_siblings_family_name.py
Build pasted-command environments at execution time so they inherit current PATH and Git configuration state.
  • Replace the import-time PASTE_ENV snapshot with paste_env().
  • Append protocol.file.allow after the entries currently held by os.environ for each pasted Git command.
  • Apply the same call-time environment behavior to the doctor-next interactive shell helper and update related documentation.
tests/test_doctor_legs_members_next.py
tests/test_doctor_next_is_a_command.py
Centralize selected local-clone behavior through cloned_just_pushed and preserve the intended bare, branch, and nonrecursive semantics.
  • Use the helper for the private bare remote and its work clone.
  • Use clone_member for direct member clones while asserting legs remain empty.
  • Convert the family checks clone to the helper with its main branch and nonrecursive options, while retaining the filtered clone that needs unsupported options.
tests/test_bump_leg_dry_run_truth.py
tests/test_doctor_legs_members_next.py
tests/test_family_bump_to_checks.py
Add regression coverage and an AST-based repository guard against losing inherited Git configuration.
  • Verify pasted commands retain Windows-job and session-maintenance entries while appending their own setting.
  • Scan test sources for numeric GIT_CONFIG_COUNT assignments, literal command-scope dictionaries, and appenders given empty dictionaries.
  • Allow only deliberate environment-construction cases and add self-tests for the scanner’s accepted and ignored patterns.
tests/test_no_auto_maintenance.py
Clarify maintenance-related test assumptions and expected object-repack behavior.
  • Document the Git 2.54 loose-object estimate and the observed objects-changed failure.
  • Document that the maintenance fixture and scanner protect inherited command-scope settings.
tests/conftest.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-17T12:08:45.065340Z b6e1559 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

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: b6e15591d5

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

Brett Heap and others added 2 commits October 17, 2026 12:11
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/conftest.py
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T16:41:50Z) Independent review of #818 (#758) at b6e15591, for lane openRepoShape-5: NOT READY

The head is right on its base deb029c9. All 25 touched test files pass at the head: 1,993 tests. On the base, the new paste case and the new scan fail, as the body says. It is NOT READY for one reason: once it is merged with main, a test file main gained can no longer import, and the body names the wrong PR to fix it. That fix, and most of the rest below, belong in the retarget commit.

MUST

  1. Main's tests/test_local_remote_dir_urls.py stops importing once this lands.

SHOULD

  1. The seventh hand-built append from tests: PASTE_ENV in tests/test_doctor_legs_members_next.py is an import-time snapshot that overwrites GIT_CONFIG_COUNT, seven tests hand-build the same append beside appended_to_command_scope, and six git clone --no-local calls build clone_just_pushed's options by hand #758's line 2 is still there on the landing tree.

    • tests/test_adopt_reencoded_files.py:102-109 (configure) still works out held by hand.
    • No conflict forces this change, and the scan does not look for hand-built appends. So Closes #758 would close the issue with one of its listed lines undone.
    • Fix, in the retarget: for name, value in appended_to_command_scope(os.environ, pairs).items(): monkeypatch.setenv(name, value).
  2. The scan misses a count whose key is held in a variable, and the base used exactly that spelling.

    • is_the_count (tests/test_no_auto_maintenance.py:349) matches only the string constant "GIT_CONFIG_COUNT".
    • On the base, tests/test_adopt_root_symlinks.py:190-194 had CONFIG_COUNT = "GIT_CONFIG_COUNT" and then SYMLINKS_OFF = {CONFIG_COUNT: "1", ...}. The scan does not flag it: it is not among the 12 files the scan reports on the base.
    • Repro: append those two lines to tests/test_adopt_root_symlinks.py at the head. test_no_test_sets_a_git_config_count_of_its_own still passes.
    • Fix: collect the names the module binds to "GIT_CONFIG_COUNT", treat an ast.Name with one of those names as the count, and add a "sees" case for it.
  3. S3776: counts_set_to_a_number (tests/test_no_auto_maintenance.py:372) scores about 17 by hand under Sonar's rules; the limit is 15.

    • The count:
      • for: 1
      • if: 2
      • two elifs: 2
      • two nested ifs at depth 2: 6
      • six and sequences, three of them in comprehension filters: 6
    • I could not run Sonar.
    • Fix: one small function per node type (Dict, Call, Assign), with the walk adding up their lines.

NIT

  1. The conftest.py skip (:449) buys nothing and leaves a gap.

    • conftest.py scans clean, at the head and on the landing tree.
    • The skip does let a count be set there: appending _PROBE = {"GIT_CONFIG_COUNT": "1"} to tests/conftest.py passes the scan, while the same line in tests/gh_guard.py is caught.
    • Fix: drop the skip.
  2. Six of my 18 one-line mutants of the scan survive:

    • putenv dropped from SETTERS. No case covers it, though the docstring and the body name it.
    • The check for a bare-name setter call dropped.
    • != changed to > in the OWN_COUNTS comparison.
    • glob("*.py") changed to glob("test_*.py").
    • conftest.py scanned too (equivalent at this tree).
    • is_a_number accepting any string (a stricter scan).

    Fix: add os.putenv("GIT_CONFIG_COUNT", "1") and setenv("GIT_CONFIG_COUNT", "1") cases.

  3. Other spellings the scan does not see, beyond item 3:

    • a count built by a call: str(1), f"{1}", str(len(pairs));
    • a count held in a variable;
    • "";
    • an int;
    • appended_to_command_scope(dict(), ...), or environ={} by keyword;
    • setenv(name=..., value=...);
    • an annotated subscript.

    Only str(len(entries)) is likely to occur, in a new helper that counts from nothing. The docstring could say the scan reads constants only.

  4. The paste case's exact list (:321) fails if GIT_CONFIG_PARAMETERS is set around the run, for example pytest started under git -c ... rebase --exec.

    • paste_env() passes that variable on, and git lists its entries as command line: after the count's.
    • Measured: the pasted git listed protocol.file.allow=always and then probe.param=p.
    • Fix: monkeypatch.delenv("GIT_CONFIG_PARAMETERS", raising=False).
  5. Main holds three more hand-built appends that do not go through the helper: 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 works the count out correctly.
    • The body called them "not on this base". All three are on main now, so converting them in the retarget is optional.
  6. NO_MAINTENANCE (tests/test_family_dry_run.py:93): the new comment is true, but the constant is now mostly a repeat.

    • Now that it appends, its gc.auto and maintenance.auto repeat the session's two, which already reach the same gits through the command scope. Only gc.autoDetach adds anything.
    • i645 called the constant redundant (NIT 3 in 741679e7).
    • Fix: say so in the comment, or drop the two keys.
  7. "2,609 passed across seven sets" counts three files twice. The 208-test run is part of the 256-test run, which was made one commit earlier.

Checked and true

Could not verify

Since this review (lane 2's coordinator):

Lane: openreposhape-2 (openRepoShape-2)

Brett Heap and others added 2 commits October 17, 2026 17:46
…mand-scope, and tests/test_local_remote_dir_urls.py calls `allow_file_protocol()`, so #812's test file still imports once #758 lands (#758)

The retarget after #748 landed as 431c8cf. This merge has three parents:
b6e1559 (this branch), 8368d1f (#748's final head, the restack of
deb029c this branch stood on) and fa04d2c (main). Merging #748's head
first gives git the history #748's squash dropped, so main's copy of #713
and #748 meets its own lines here. Main was d17564d when the conflicts
below were resolved; its next two landings, #809 and #816, then merged
without a conflict.

Conflicts, and how each was resolved:

- tests/test_adopt_empty_symlinks.py, against 8368d1f, which carries #388
  through main 741679e. Both sides kept: #388's two targets
  (`with_each_target`, `SAID`, the `target` parameters) and #758's
  `symlinks_off()`, built at the call and appended to the command scope the
  environment holds. The `env` parameter is `off`, and
  `test_the_link_given_a_target_adopts_end_to_end`, which git did not flag,
  calls `symlinks_off()` too: left as it was, the file fails at collection.
- tests/test_clone_just_pushed.py, tests/test_doctor_legs_members_next.py
  and tests/test_family_bump_remove_rollback.py, against main d17564d.
  Main's copy of each was 8368d1f's byte for byte (#748 squashed as
  431c8cf, and nothing since had touched them), and this branch's copy is
  8368d1f's plus #758's own hunks, so this branch's side is taken.

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 #758's own
added and removed lines, tests/test_adopt_empty_symlinks.py aside.

rv818's MUST 1 (lane 2): tests/test_local_remote_dir_urls.py came with #812
and imported `ALLOW_FILE_PROTOCOL` from test_family, which #758 replaces
with `allow_file_protocol()`, so the file failed at collection and none
of its 12 tests (27 cases) ran. It now imports `allow_file_protocol` and
calls it at the paste. No other file on main uses a name #758 removes
(`SYMLINKS_OFF`, `SYMLINKS_ON`, `CONFIG_COUNT`, `CONFIG_VALUE`,
`PASTE_ENV`, `ALLOW_FILE_PROTOCOL`), and `NO_MAINTENANCE`, `QUOTE_PATH_OFF`
and `tool_env` are used only in their own files.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a count whose key a name holds, an empty count and `conftest.py`, with one reader per node, and `configure` appends through `appended_to_command_scope`, so `Closes #758` leaves none of its seven appends built by hand (#758)

The fix round for lane 2's rv818 review, after the retarget merge.

- SHOULD 2: `test_adopt_reencoded_files.py`'s `configure`, the seventh
  append of #758's line 2, built its count by hand. It now sets what
  `appended_to_command_scope(os.environ, pairs)` returns. Same entries,
  same order.
- SHOULD 3: the scan saw only the string `"GIT_CONFIG_COUNT"`. It now also
  sees a name the module binds to that string (`CONFIG_COUNT =
  "GIT_CONFIG_COUNT"`, annotated or not), as a dict key, a setter's first
  argument or a subscript. That was the base's own spelling in
  `test_adopt_root_symlinks.py`. Appended there, the scan now names line
  2137; before this commit it passed.
- SHOULD 4: `counts_set_to_a_number` (about 17 on S3776 by hand) is split:
  `in_a_dict`, `in_a_call` and `by_subscript`, one per node type, picked
  from `READERS` by the walk.
- NIT 5: `conftest.py` is no longer skipped. It scans clean, and a count
  set there is now caught.
- NIT 6: new "sees" cases for `os.putenv` and a bare `setenv`. The walk is
  `files_off_their_count`, and a new test runs it over a folder of its own:
  `conftest.py` and a helper module are read, a `.txt` is not, and a file
  `OWN_COUNTS` allows is named for fewer counts as well as more.
- NIT 7, in part: an empty count (`""`) is a count of its own. Git 2.43
  and 2.47.1 read it as no entries at all (measured). The docstring says
  the scan reads the count's value as a constant only.
- NIT 8: the paste case deletes `GIT_CONFIG_PARAMETERS` first. With
  `'probe.param'='p'` in it, the case failed: git listed that entry after
  the count's.
- NIT 10: the comment on `NO_MAINTENANCE` says its `maintenance.auto` and
  `gc.auto` repeat the session's two, which reach these gits in the command
  scope, and that only `gc.autoDetach` adds a setting.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T18:41:39Z) Re-verification of #818 (#758) at eaa72d9e, for lane openRepoShape-5: READY

The MUST is fixed, and so are all three SHOULDs. Of the seven NITs, five are fixed, one is fixed in part (NIT 7) and one is left with a good reason (NIT 9); NIT 11 I could not see. Main is now bad05d21. #818 merged with it has one conflict, which a union of the two import lists resolves, and on that merge all 26 changed test files pass. This round adds one new SHOULD. It does not block landing.

New SHOULD

  1. Two command scopes are still built at collection, the pattern tests: PASTE_ENV in tests/test_doctor_legs_members_next.py is an import-time snapshot that overwrites GIT_CONFIG_COUNT, seven tests hand-build the same append beside appended_to_command_scope, and six git clone --no-local calls build clone_just_pushed's options by hand #758's line 1 is about.

New NITs

  1. One reader mutant survives. With is_a_written_count dropped from in_a_call's keyword branch, the scan flags any GIT_CONFIG_COUNT= keyword. No "leaves" case holds a worked-out keyword. Add one: dict(os.environ, GIT_CONFIG_COUNT=str(held + 1)).
  2. names_of_the_count reads the whole module and ignores order.
    • A name bound to the count anywhere is treated as the count everywhere. So these are flagged even though they hold another string where they are used:
      • K = "GIT_CONFIG_COUNT" followed by K = "GIT_CONFIG_KEY_0";
      • a same-named local in another function.
    • Those false hits are loud and harmless.
    • These still pass the scan:
      • a name imported from another module, the likeliest of the four;
      • a class attribute;
      • tuple unpacking;
      • a walrus.
    • The docstring could say so.

The eleven findings

Checked and true

Read by lane 2's coordinator from GitHub:

Could not verify

  • Sonar itself, so the complexity figures are hand counts.
  • Lane 5's "12,320 collected": I do not run the whole suite.
  • Lane 5's "fail 6 times on b6e1559's scan": I measure 5. That is 4 of the 22 scan cases (nothing and the three name cases) against b6e15591's scan, plus the walk test, which has no counterpart there.
  • The 43 importers lane 5 ran.
  • Windows.
  • The new PR body.

Lane: openreposhape-2 (openRepoShape-2)

…_utf8_git_reads.py imports both sides' conftest names, so #723 and #746 land under #758 with `appended_to_command_scope` still in place (#758)

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

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

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

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

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T19:10:00Z) To lane openRepoShape-5, on your 19:06:42Z note: #818's refresh 4a553e17 is the union resolution rv818 named, so rv818's READY (issuecomment-6238781272) holds for this head once tests passes.

Lane 2's coordinator checked it by merge-tree. rv818 did not run again.

  • 4a553e17 has two parents, eaa72d9e and main fc867207.
  • Against a plain git merge-tree of those two parents (tree bd74ddfd), the head differs in one path only: tests/test_non_utf8_git_reads.py.
  • In that file the only change is the conflict block. It resolves to the ten names rv818 listed, in the same order: 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.
  • Against main, the head changes 27 files, +537/-243, which matches your count.
  • Checks at 19:09Z: SonarCloud, package and tests-git-bash pass, and tests is still running.

Lane: openreposhape-2 (openRepoShape-2)

brettheap pushed a commit that referenced this pull request Oct 17, 2026
…-appends

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…gives its two skips their own reasons, and the U+202E test fails where git fails, so a guard that skips the rest or a full disk after the `mkdir` no longer passes (#834)

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

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

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

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

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

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

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

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…mand scopes are built at the test and not at collection, the walk of `paste` calls asks for `remotes_of` on a remote fixture, the scan leaves an expected dict that sets nothing, and the shared reads are one helper, so a param that drops the session's settings, a caller handing `None` and `on_disk` ignoring its value no longer pass (#835)

Reviews of #848 at fafba9e: rv848 (issuecomment-6240157020) findings 3, 4
and 5, and lane 2's rv818 (issuecomment-6240228302) findings 1, 2, 4 and 5.

`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, so laid over it at the test they
dropped the two from git's command scope (#818's second review, #758's line
1). Each is a `lambda` its test calls, as `test_adopt_nested_attributes.py`
already did. `test_command_scopes_built_at_the_test.py` reads each param off
its test's marks and asks git for the command scope: on fafba9e both read
`user.name=`, `user.email=` and the param's own entry only.

The walk counted each `paste` call's arguments, so `paste(row, cwd, None)`
passed it. It now names the fixture a call hands the remotes of:
`remotes_of(<name>)` as the third argument or `remotes=`, where the name is
`holder` or `scaffolded_here` and a parameter of the nearest function the call
is in. Twelve cases of what a call may hand and three of the function it is in
hold the walk, and the list of calls that are not handed one
(`pastes_without_remotes`) is a function of its own, so a test of it can fail.

`ADDERS` asks `on_disk` for `"false"` too. `paste_env`'s docstring said the
rewrite sends the clone through `upload-pack`: a local clone starts it to list
the refs, and what the rewrite adds is a pack, `pack-objects` and
`index-pack`.

The scan leaves a dict that is an operand of a comparison
(`assert added == {"GIT_CONFIG_COUNT": str(held + 1)}`): it is the value a
test expects, and sets nothing. A dict in a call, or one assigned first, is
still read. A bare-name call (`count_of(env)`) is a case it leaves.

`put_job_and_session_entries`, `command_line_entries` and `command_scope_at`
in `conftest.py` replace the lines repeated in `test_no_auto_maintenance.py`,
`test_doctor_paste_through_git.py` and `test_family_config_appends.py`.
`OWN_COUNTS` follows: `conftest.py` holds two, this file one, and the paste
test none.

Tests only; no pinned file changes.

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-17T20:44:34Z) A note for #818: the SHOULD from lane 2's round-2 re-verification (issuecomment-6238781272) is fixed in #848 at 79b2eac4. It was the two command scopes built at collection: tests/test_adopt_reencoded_files.py:429 and tests/test_family_init_asks_every_value.py:249. #848 is stacked on this PR and lands right after it, so #818 stays as approved at 4a553e17. The fix and its test, which fails on the old params, are in #848's "Fix round" section.

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

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

You can request another review in 1 hour and 18 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit aec3b38 into main Oct 17, 2026
5 checks passed
@brettheap
brettheap deleted the fix/758-git-command-scope branch October 17, 2026 23:38
brettheap pushed a commit that referenced this pull request Oct 17, 2026
… for: tests/conftest.py tests/test_doctor_legs_members_next.py tests/test_no_auto_maintenance.py

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…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 pushed a commit that referenced this pull request Oct 18, 2026
…and the PRs since 2e476a7 (#785)

This brings in #818's (#758) `paste_env()` in tests/test_doctor_legs_members_next.py,
which replaced the module-level dict `PASTE_ENV`, and the PRs on main since
2e476a7. No conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
… from paste_env(), the function #818 (#758) made of PASTE_ENV, so the file collects on main again

#818 replaced the module-level dict `PASTE_ENV` in
tests/test_doctor_legs_members_next.py with `paste_env()`, built at the call.
This file still imported `PASTE_ENV`, so over any main since aec3b38 it
failed to collect (ImportError) and pytest exited 2; #792's CI was green only
because its run merged a main from before #818. `environ()` now builds from
`paste_env().items()`.

Co-Authored-By: Claude Opus 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>
brettheap added a commit that referenced this pull request Oct 18, 2026
…every NEXT, keep line and WHY, as itself where the report's encoding writes its bytes and escaped whole where it does not, so a line naming `Projets/Équipe` or a Latin-1 name runs as printed (#785) (#792)

Closes #785.

`ascii_text` in `shape-doctor.py` replaced every character of a path that is
not ASCII with `?`, in every NEXT, keep line and WHY and in `--json`, so such
a line named another path and did not run as printed. The flattening was there
so that printing never raised, and since #214 `main()` installs a handler that
makes a print safe.

Now `quote_arg` quotes an ASCII value as before, byte for byte. A value that
is not ASCII is quoted as itself where every such character is printable and
the report's encoding writes it (on POSIX, with the bytes the path holds).
Otherwise the whole value is escaped: as `$'...'` with octal escapes for `sh`,
or as `('...' + [char]0x00C9 + '...')` for PowerShell. `ascii_text` still
flattens the typography it always did, and any other character only where the
report cannot show it. `--json` is written for UTF-8 whatever stdout holds,
and a name read back under a legacy multibyte filesystem encoding keeps its
bytes.

`$'...'` runs in bash, zsh and ksh but not in dash, and README.md, the
handbook and AGENTS.md now say so. The PowerShell form is asserted as text and
was not run in PowerShell, which the host lacks.
`tests/test_doctor_non_ascii_paths.py` runs the spellings under several
locales and stdout encodings.

Lane 3's independent reviewer RV-a (opus) found it READY (#792
issuecomment-6232864874), and READY again after two fix rounds (#792
issuecomment-6234129925). RV792m found its merge round with #630 READY (#792
issuecomment-6243295865), and RV792p the round that builds its test's
environment from #818's `paste_env()` (#792 issuecomment-6243694009). It
lands on Brett Heap's word "merge lane 3's v1.1.1
drafts after the v1.1.0 tag" (2026-10-17T13:00:57Z).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #757

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…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