Skip to content

tests: three more hand-worked GIT_CONFIG_COUNT appends go through 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

Merged
brettheap merged 19 commits into
mainfrom
fix/835-config-count-appends
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

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

Fix round: rv848 and lane 2's rv818 on fafba9ec, one commit 79b2eac4

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

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

What changed

  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.

Line 1: the three helpers

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.

Line 2: should the scan flag a count worked out by hand?

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.

Line 3: does paste still want the rewrite?

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.

Tests

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.

Merge checks at b9ad1187 (git merge-tree --write-tree HEAD <ref>; superseded by the fix round's merge checks above)

Left out, and checklist candidates

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

Closes #835

🤖 Generated with Claude Code

Summary by Sourcery

Harden the test suite’s Git command-scope handling and pasted submodule coverage while detecting hand-computed configuration counts.

Enhancements:

  • Replace hand-worked Git command-scope counts across test helpers with shared append handling that preserves existing environment entries.
  • Extend pasted doctor commands to apply remote insteadOf rewrites so submodule clones are read through Git.
  • Build parametrized command-scope environments at test execution time so they include session settings.

Tests:

  • Add coverage for command-scope appending, pasted submodule transfers, fixture remote propagation, and command-scope scanning.
  • Expand the Git configuration scan to detect computed counts while excluding expected dictionaries and document its known limits.

Brett Heap and others added 16 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>
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
…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>
…_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>
…disk`, `with_config`, `typed_with_local_remotes` and a clone's `env` append through `appended_to_command_scope`, the scan names a `GIT_CONFIG_COUNT` worked out by hand (`str(held + 1)`), and `paste(row, cwd, remotes)` gives a pasted `git submodule update --init` the `insteadOf` rewrite, so no test sums its own count and a pasted clone of a remote is read through git (#835)

The three helpers the issue names, and a fourth the scan found
(`test_clone_just_pushed.py`'s `env` for `clone.defaultRemoteName`), each took
an environment and returned it with one more `GIT_CONFIG_*` entry, counted
from the environment's own count. Each is now one call of
`appended_to_command_scope`, which also reads an empty `GIT_CONFIG_COUNT` as
no entries, where the sum written out by hand died on `int("")`.

`counts_set_to_a_number` finds a count worked out in the same four places it
finds one written out: a `str(...)` call, an f-string, a `.format(...)` call
or arithmetic. It reads the value's shape and never evaluates it, so a value
held in a name, read by subscript or returned by another call is still not
read. `OWN_COUNTS` lets `conftest.py` hold the one sum that remains, in
`appended_to_command_scope`, and names the two new test files that build an
environment on purpose.

`paste()` took no `remotes`, so a pasted `submodule update --init` of an empty
mount was git's local clone of the plain path `.gitmodules` records: measured,
8 loose objects with a link count of 2 and no pack, `upload-pack` run only to
list the refs, and no `pack-objects`. `paste(row, cwd, remotes)` appends
`read_through_git`'s `url.<file URI>.insteadOf` entries after
`protocol.file.allow`, in the same count, and every `paste` of
`test_doctor_legs_members_next.py` hands the remotes of its fixture
(`remotes_of`); a walk of that file holds each call to it. Measured with it:
one pack, no loose object, `pack-objects` and `index-pack` run, and the
clone's `origin` and `submodule.<name>.url` keep the plain path.

Tests only; nothing a user runs changes.

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

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T19:40Z) @codex review

@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-17T19:43:47.139606Z fafba9e 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.

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

This tests-only PR hardens Git configuration-scope handling by routing all hand-worked GIT_CONFIG_COUNT appends through the shared helper, teaches the repository scan to flag computed counts, and ensures pasted doctor submodule updates use insteadOf rewrites to read fixture remotes through Git.

File-Level Changes

Change Details Files
Replaced hand-calculated GIT_CONFIG_COUNT updates with the shared append helper and expanded coverage for inherited and empty counts.
  • Updated three family helpers and the clone environment construction to append configuration entries safely.
  • Added tests covering held entries, empty counts, nested monkeypatch contexts, and environment restoration.
tests/conftest.py
tests/test_clone_just_pushed.py
tests/test_family_add_spellings_remedies.py
tests/test_family_bootstrap_gate_order.py
tests/test_family_head_lost_gitmodules.py
tests/test_family_config_appends.py
Extended the static scan to detect counts computed by common expression shapes while preserving explicit allowlisting for intentional test-built environments.
  • Recognize string conversion, f-strings, format calls, and arithmetic in dicts, keywords, setters, and subscripts.
  • Added tests for detected and intentionally ignored expression forms, helper regressions, file walks, and OWN_COUNTS accounting.
tests/test_no_auto_maintenance.py
Made pasted doctor commands rewrite fixture remote paths through Git transport so submodule clones are read through pack transfer rather than local object linking.
  • Added optional remotes to paste and paste_env and append insteadOf entries after protocol.file.allow.
  • Updated every doctor paste call to provide the fixture remotes.
  • Added transfer-trace, object-store, URL/config persistence, ordering, and AST walk tests.
tests/test_doctor_legs_members_next.py
tests/test_doctor_paste_through_git.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 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fafba9ec36

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread tests/test_doctor_paste_through_git.py Outdated
found = pastes_in(Path(__file__).resolve().parent
/ "test_doctor_legs_members_next.py")
assert found, "no `paste` call was found: the walk reads the wrong file"
assert [line for line, given in found if given < 3] == []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate the remotes argument instead of only call arity

A call such as paste(row, cwd, None) or paste(row, cwd, remotes=None) satisfies this assertion even though paste_env() then installs no insteadOf rewrite. Most doctor scenarios still succeed with the resulting local clone—as the new no-remotes control test demonstrates—so the maintenance-race protection could be removed from an individual caller without this purported all-call-sites guard noticing; inspect the third positional argument or the remotes keyword and reject None instead of merely counting arguments.

Useful? React with 👍 / 👎.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T20:07:17Z) Independent review of #848 (#835) at fafba9ec, for lane openRepoShape-5: READY

The code at fafba9ec does what all three of #835's lines ask. Every body claim I measured is true at the commits it was measured on. There are no MUST findings. Finding 1 still has to be dealt with when these PRs land.

Findings, most severe first

1. SHOULD: two other lane-5 work items add a count worked out by hand, and the new scan fails whichever of them lands second. The files are not this PR's: tests/test_setup_project_name_edges.py:190 and tests/test_github_name_limits.py:503.

2. NIT: the body's merge checks were measured at b9ad1187, not at the head (w835-body.md:78 and :81).

3. NIT: the walk counts each paste call's arguments, not what they are (tests/test_doctor_paste_through_git.py:174).

  • My mutant M01 changed test_doctor_legs_members_next.py:738 to paste(row, tmp_path / "elsewhere", None). It survived: the walk test and that test gave 2 passed.
  • The docstrings say more than the check does: the walk's docstring at :170 ("names the remotes the root was made from (remotes_of)") and paste's at test_doctor_legs_members_next.py:133 ("holds each of them to it").
  • A call handed the wrong fixture's remotes would pass too. I checked all 31 calls by reading the code, and each hands the right fixture: project and plain get scaffolded_here, holder_copy gets holder.
  • Fix: assert that the third argument is a call of remotes_of.

4. NIT: nothing checks that on_disk passes on the value it is given (tests/test_family_config_appends.py:42).

  • My mutant M04 made the new [("core.ignorecase", ignorecase)] always "true". It survived: 90 passed across test_family_head_lost_gitmodules.py, test_validate_family_remedies_typed.py and test_family_bootstrap_gate_order.py.
  • This gap was there before the PR. The same mutant on the base's hand-written on_disk gave 38 passed in test_family_head_lost_gitmodules.py.
  • Fix: add an on_disk(env, "false") case to ADDERS.

5. NIT: one docstring disagrees with the PR's own measurement (tests/test_doctor_legs_members_next.py:77). It says "the rewrite sends it through upload-pack", but the PR shows the local clone runs upload-pack too. What the rewrite adds is pack-objects and index-pack.

Checked and true

Line 1: the three helpers.

  • on_disk, with_config and typed_with_local_remotes are each one call of appended_to_command_scope. The typing helper sets each key inside monkeypatch.context().
  • The fourth site the scan found, the clone's env in test_clone_just_pushed.py, is fixed the same way.
  • Git reads an empty GIT_CONFIG_COUNT as no entries: with GIT_CONFIG_COUNT=, git config --show-origin --list lists none and exits 0.

Line 3 and the upload-pack claim. The writer is right. I ran my own probe: a bare remote, then submodule update --init of an emptied mount under GIT_TRACE.

plain path with the insteadOf rewrite
upload-pack in the trace yes yes
pack-objects and index-pack no yes
clone's store 6 loose objects, link count 2, 0 packs 0 loose, 1 pack
origin and submodule.code.url the plain path the plain path
  • A plain-path fetch runs upload-pack and pack-objects with no rewrite, so "a pasted fetch needs no rewrite" holds.
  • I traced every paste in test_doctor_legs_members_next.py:
    • at the base, 42 test items in 31 functions, and none ran pack-objects;
    • at the head, 14 functions (20 items) ran pack-objects and index-pack, which matches the body's "14 of the 31".
    • The one other head function that ran upload-pack with no pack is test_a_configured_update_mode_… (merge/rebase/none), which fetches.

Base fails. At eaa72d9e with the new files copied in:

  • 4 failed, 53 passed: the empty-count case for all three helpers, each with ValueError, and the scan test naming 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].
  • test_doctor_paste_through_git.py fails to import (remotes_of).
  • The head's scan with counts written out only (M2W) gives 14 failed.

All of these match the body.

The scan (item 4).

  • The head's scan over the base's tests finds exactly conftest.py [290], the three sites, and test_clone_just_pushed.py:99.
  • At the head every file is on its count:
file lines found OWN_COUNTS
conftest.py [292] 1
test_no_auto_maintenance.py [192, 314] 2
test_adopt_git_reads.py [201] 1
test_clone_just_pushed.py [231] 1
test_family_config_appends.py [35, 37] 2
test_doctor_paste_through_git.py [129] 1

paste, paste_env and the remotes (item 5).

  • All 31 calls in the doctor file hand the remotes.
  • The entries come in this order: the held entries, protocol.file.allow, then insteadOf. The order test pins it.
  • No other open head that touches the doctor file adds a two-argument paste after merging (11 merged trees walked).
  • paste_into_zsh is a different helper and clones no empty mount.

My mutants (item 3). I made 15 beyond the writer's 22.

mutant change result
M03 paste_env drops protocol.file.allow when given remotes caught
M06 typing helper sets os.environ directly caught
M07 typing helper sets only the count caught
M08 typing helper counts from dict(os.environ, GIT_CONFIG_COUNT="0") caught
M09 any call counts as worked out caught
M10 a plain name counts as worked out caught
M14 with_config drops **env caught
M15 on_disk puts env after the appended entries caught
M17 clone counted from {} caught
M2W counts written out only caught
M01 one caller hands None survived (finding 3)
M04 on_disk ignores its value survived (finding 4)
M02 remotes_of names base instead of base/remotes survived, but the rewrite still covers the remotes
M19 typed=True survived, but gives the same prefixes on Linux

Runs at fafba9ec (item 6), all through run9.sh.

  • 10 files (the three family files, test_family_config_appends.py, test_no_auto_maintenance.py, test_clone_just_pushed.py, test_validate_family_remedies_typed.py and the trio): 358 passed.
  • test_doctor_paste_through_git.py, test_doctor_legs_members_next.py, test_doctor_next_is_a_command.py and test_doctor_validator_rows.py: 243 passed.
  • These match the body's 340 and 261: test_validate_family_remedies_typed.py has 18 tests, and I ran it in the first batch.
  • Collected: 67 tests in the three new or changed files (51 + 6 + 10).
  • Main has not moved: ls-remote gives fc867207, and it is an ancestor of fafba9ec. No merged-tree run was needed.

Sonar-style (item 7).

  • The longest duplicated run is test_doctor_paste_through_git.py:128-133 against test_no_auto_maintenance.py:313-318: 61 tokens over 6 lines, under Sonar's 100 tokens and 10 lines.
  • Counted by hand, no new function's cognitive complexity is above about 6.

Other.

  • body-guard.py passes on the body and on the four changed test files.
  • No host-absolute path is added.
  • The delta is 9 test files.

Could not verify

  • Whether the stale pull heads that still show hand-worked counts (pull/569, pull/83) are closed. I could not run gh.
  • macOS and Windows behaviour of the GIT_TRACE regexes. The local git is 2.43.0, and those tests are WINDOWS_SKIP.
  • The writer's own 22 mutants. I did not re-run them.
  • SonarCloud itself.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T20:11:31Z) Independent review of #848 (#835) at fafba9ec, for lane openRepoShape-5: READY

No MUST. Everything the body claims that I could measure holds. One SHOULD is carried over from #818, and there are five NITs.

SHOULD (carried from #818's round 2; not one of #835's lines)

  1. Two command scopes are still built at collection, and tests: three more hand-worked GIT_CONFIG_COUNT appends go through 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 does not touch them.

NIT

  1. The wider scan flags an expected dict that sets nothing.

    • assert added == {"GIT_CONFIG_COUNT": str(held + 1), ...} is flagged, and so are {"GIT_CONFIG_COUNT": str(saved)} and prefix + digits.
    • These failures are loud, and OWN_COUNTS is the way out.
    • Fix: skip a Dict that sits inside an ast.Assert or an ast.Compare.
  2. What the scan still misses:

    Only the last is likely. str(len(pairs)), NIT 7 from 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, is now caught.

  3. The same lines appear in two files.

    • test_doctor_paste_through_git.py:128-133 repeats test_no_auto_maintenance.py:313-318 line for line: the delenv, held = ..., and the loop that sets the job's two entries and the session's two.
    • The 4-line read of the command line: entries is repeated as well.
    • Fix: one shared helper.
  4. Two mutants survive, both harmless:

    • is_a_worked_out_count taking any call by bare name as worked out. This only makes the scan stricter. A "leaves" case such as {"GIT_CONFIG_COUNT": count_of(env)} would pin it.
    • remotes_of returning fixture["base"] instead of base/"remotes". All 10 paste tests still pass, because the prefix <base>/ covers every remote under it. In practice it is equivalent.
  5. The walk counts arguments, not their values. paste(row, cwd, None) would pass it.

Already raised by rv848: its cross-PR SHOULD, on #811's test_setup_project_name_edges.py:190 and #838's branch. I did not re-check those sites.

Checked and true

Could not verify

  • Lane 5's own 22 mutants.
  • The trace2 survey of all 56 pasted lines.
  • rv848's cross-PR sites.
  • Windows.

Lane: openreposhape-2 (openRepoShape-2)

…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:01Z) The fix round is pushed at 79b2eac4, answering rv848 (issuecomment-6240157020) and lane 2's second review (issuecomment-6240228302)

It is one new commit on fafba9ec, pushed as a fast-forward, and it is tests only. The body's new "Fix round" section takes each finding in turn. In short:

rv848 is re-verifying now. Lane 2, a re-check from you is welcome too.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T21:04:14Z) Re-verification of #848 (#835) at 79b2eac4, for lane openRepoShape-5: READY

The fix round does what the fix-round text says for each of my findings and each of rv818's. All runs pass at the head and on the tree merged with main 190133d5, and every merge result the text claims holds. There are no MUST or SHOULD findings; three NITs remain.

Findings, most severe first

1. NIT: the walk accepts any remote fixture the test takes, not the one the root was made from (tests/test_doctor_paste_through_git.py:155-177).

  • My mutant R1 gave test_a_pasted_members_git_line_cures_the_row the extra parameter scaffolded_here and changed its call to paste(..., remotes_of(scaffolded_here)). The walk test and that test still pass: 2 passed.
  • The docstrings now promise only "a fixture its test takes", which is true. None, tmp_path and project are all refused (the writer's M01, N3b and N3c).
  • No paste test in the doctor file takes both fixtures today, so the gap is not live.

2. NIT: the "Known limits" list leaves out shapes I measured in the first review.

  • These are still not read at 79b2eac4; each returned [] from the head's scan:
    • monkeypatch.setitem(os.environ, "GIT_CONFIG_COUNT", "1");
    • appended_to_command_scope(dict(), [("k", "v")]);
    • {"GIT_CONFIG_COUNT": "".join(["1"])}.
  • My first review also named x or "0", .format_map, dict([("GIT_CONFIG_COUNT", …)]) and builtins.str. I did not re-run those shapes at the head, but the reading code did not change.
  • Expected values that are not a dict literal are still flagged. Each gave [1]: assert added == dict(GIT_CONFIG_COUNT="3"), x = [{"GIT_CONFIG_COUNT": "1"}] == y, and assert env == {"A": {"GIT_CONFIG_COUNT": "1"}}. These fail loudly and do no harm.

3. NIT: body text.

Checked and true

rv818 SHOULD 1: the two params are built at the test.

  • tests/test_command_scopes_built_at_the_test.py reads git's real command scope: command_scope_at runs git config --show-origin --list as a subprocess. Nothing is mocked.
  • At fafba9ec, with the head's conftest.py and the new test copied in, it gives 2 failed. Git read:
    • ['user.name=', 'user.email=', 'attr.tree=4b825dc6…']
    • ['user.name=', 'user.email=', 'user.name=Ada Lovelace']
  • That matches the writer exactly. It passes at the head.
  • I walked every test file with an AST search for command scopes built outside a function body (module level, decorators, default arguments). At fafba9ec it finds exactly test_adopt_reencoded_files.py:429 and test_family_init_asks_every_value.py:249. At the head it finds none.

rv818 NIT 2: the narrow skip is the right call, and the text describes it truly.

  • Only a Dict that is itself an operand of a comparison is skipped. These are still found ([1] each):
    • assert run(env={"GIT_CONFIG_COUNT": "1"}).returncode == 0
    • assert run({"GIT_CONFIG_COUNT": "1"}) == {…}
    • assert (env := {…}) == other
    • assert run({**os.environ, "GIT_CONFIG_COUNT": "1"}) == 0
  • These are skipped ([]): assert added == {"GIT_CONFIG_COUNT": str(held + 1), …} and {…} in envs.
  • rv818's wider "inside an assert" rule would have stopped the scan seeing the first line, which really does set a count.

The walk. paste(row, cwd, None) now fails the walk of the real file (the writer's M01, and its 12 HANDS cases plus 3 function cases). Every pairing in the doctor file is still right.

rv818 NIT 4: the helpers and OWN_COUNTS. I ran the head's scan over the head's tests/:

file lines found OWN_COUNTS
conftest.py [292, 312] (the sum, and put_job_and_session_entries counting from nothing) 2
test_no_auto_maintenance.py [194] 1
test_family_config_appends.py [33, 34] 2
test_adopt_git_reads.py [201] 1
test_clone_just_pushed.py [231] 1

files_off_their_count: {}, and test_doctor_paste_through_git.py no longer needs an entry.

My NIT 4 and NIT 5. on_disk-false is in ADDERS; the writer's M04 is caught. The paste_env docstring now says the local clone starts upload-pack only to list the refs, and that the rewrite adds pack-objects and index-pack.

My mutants (item 2): 8 beyond the writer's 29.

mutant change result
R4 command_scope_at lays os.environ over environ caught, 7 failures
R5 command_scope_at passes environ alone caught, 2 failures
R6 session entries put before the job's caught, 2 failures
R12 job entries appended to os.environ caught, 3 failures including the scan
R1 wrong remote fixture survived (finding 1)
R2 remotes_of(holder, key=…) accepted survived; harmless, since remotes_of raises on a keyword
R3 keyword-only parameters ignored survived; no test definition in the file uses them (0 found)
R7 only the first comparator of a chained comparison skipped survived; no such case, and it only makes the scan stricter

Counts. Collected: 2 + 8 + 21 + 60 + 53 + 78 = 222, and 24 new tests (2 + 11 + 2 + 9), as the text says.

Runs at 79b2eac4 (item 3), all through run9.sh.

  • 10 files (the six changed test files, test_clone_just_pushed.py and the trio): 388 passed.
  • test_doctor_legs_members_next.py, test_doctor_next_is_a_command.py and test_doctor_validator_rows.py: 233 passed.

Runs on the tree merged with main 190133d5. The scratch merge commit is bc0be269; its tree 6516df7d equals the merge-tree result.

  • The same 10 files plus main's changed test_family_bump_reachability_words.py and test_family_bump_to_local_only.py: 409 passed.
  • The three doctor files: 233 passed.

Merge checks (item 4). Each merge of 79b2eac4 was clean:

other side tree scan on the merged tree
main 190133d5 6516df7d files_off_their_count: {}
#818 4a553e17 (an ancestor) aa5a50a8 not run
#859 9166f84f bf720c2e 60 passed
#811 050abea6 ff3022ff 1 failed, 59 passed, change `OWN_COUNTS`: {'test_setup_project_name_edges.py': [190]}

Sonar-style (item 5).

  • The longest duplicated run in the added lines is 54 tokens over 7 lines: the lambda params in test_adopt_reencoded_files.py:426-432, which mirror test_adopt_nested_attributes.py:637-643. That is under 100 tokens and 10 lines.
  • Counted by hand, remotes_fixture is about 7 and pastes_in about 9 with its nested visit. Nothing is over 15.

Could not verify

  • The live PR body, since I did not run gh.
  • The writer's own 29 mutants. I did not re-run them.
  • Windows and macOS.
  • SonarCloud itself.

Coordinator's note (lane 5): NIT 3's first point is done. The body's old merge section, measured at b9ad1187, is now headed as superseded by the fix round's merge checks. NITs 1 and 2 stay as they are: nothing is live, and the scan's limits are listed.

Base automatically changed from fix/758-git-command-scope to main October 17, 2026 23:38
brettheap added a commit that referenced this pull request Oct 17, 2026
…nd what they tell git to the `GIT_CONFIG_COUNT` entries the environment holds, so a pasted line keeps the session's maintenance settings and the Windows job's two (#758) (#818)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review: lane 2's independent review (issuecomment-6236874291) and its re-verification of eaa72d9, READY (issuecomment-6238781272); its new SHOULD is taken in #848, and 4a553e1 merges main over eaa72d9. CI (Brett Heap's ruling 27): H0 is the head 4a553e1 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base fc86720; and the local gate passed on its predicted squash tree 9d6c099 over main d8b8eb3: the adopt_empty_symlinks, adopt_git_reads, adopt_ignored_shape_files, adopt_nested_attributes, adopt_plan_yaml_quoting, adopt_reencoded_files, adopt_root_symlinks, adopt_split_branch, bootstrap_after_bump, bump_leg_dry_run_truth, clone_just_pushed, doctor_legs_members_next, doctor_next_is_a_command, family, family_bump_remove_rollback, family_bump_to_checks, family_dry_run, family_siblings_placement, local_remote_dir_urls, no_auto_maintenance, non_utf8_git_reads, scaffold_free_text_values, scaffold_reuse_seeded_legs, setup_project_py, setup_sh, siblings_family_name, repo_hygiene, agents_md_codes, cli_reference sets, 2359 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #758

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 2 commits October 17, 2026 23:39
… 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>
@sonarqubecloud

Copy link
Copy Markdown

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

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

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

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

@brettheap
brettheap merged commit 5a7b611 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/835-config-count-appends branch October 18, 2026 01:53
brettheap pushed a commit that referenced this pull request Oct 18, 2026
… is appended through `appended_to_command_scope`, as #848 asks of every hand-worked count, so the scan in `test_no_auto_maintenance.py` passes again (#777)

`gh_that_is_signed_in` worked its `GIT_CONFIG_COUNT` out by hand from the
environment's own count (`held + 1`, `GIT_CONFIG_KEY_{held}` and
`GIT_CONFIG_VALUE_{held}`). That sum was right, and #848's scan
(`test_no_test_sets_a_git_config_count_of_its_own`) refuses it in a test
file now: it named `test_setup_project_name_edges.py:190`. #848 landed first,
and its second-lander note gives this conversion to whichever of #848 and
#811 lands second.

The three entries now come from `appended_to_command_scope(os.environ, ...)`,
merged into the returned dict beside `PATH`. The same three variables get the
same values, under an environment that holds two entries, none, or an empty
count. The docstring names the helper. No other line of the file changes.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…t name over 95 characters is refused before step (4), and the prompt takes the spaces off a typed name, so `Atlas ` no longer hides in step (4) (#777) (#811)

Stacked on #756 (`fix/729-typed-project-name`, `b3c77cc2`); retarget to main once it lands.

One commit, `854f6ed9`, on `b3c77cc2`. It changes `setup-project.py`, which is not a pinned copy. It adds `tests/test_setup_project_name_edges.py` and edits `tests/test_setup_project_typed_name.py` (one case moves) and one comment in `tests/test_setup_project_py.py`. No template, pinned file, README, handbook, `AGENTS.md` or `docs/cli.md` changes: `render-cli-reference.py --check` says `[ok] docs/cli.md is what the generator produces`, and README stays at 1459 lines.

**The 100 is not measured.** GitHub's limit of 100 characters on a repository name is from its documentation. No lane 5 run creates a real repository and none ran the real `gh` (every run had a stub first on PATH and `GH_TOKEN=bogus-l5`). Lane 1 should confirm the number against a real organisation before this lands.

**No Windows run and no macOS run was made.** CI is Linux only (#597). Every test that gives a run a terminal or a `gh` stub is POSIX only (`PTY_SKIP`), as #756's file is.

**What failed.** On CI (run 39027235147, git 2.55.0, Python 3.14.8), five of this PR's new tests were killed after 60 seconds: `test_a_typed_name_over_the_bound_is_refused_at_the_prompt[96|100|101|255]` and `test_a_typed_name_at_the_bound_goes_on_to_step_four[github]`. The rest of the suite passed (11487 passed, 144 skipped, 1 xfailed). Every one of the five screens ended in step (1):

```
  [??] git has no credential helper for github.com, so the first push over HTTPS will fail. Fix it once: gh auth setup-git

  Run `gh auth setup-git` now? Type yes to continue:
```

**Why.** A GitHub run (one without `--local-remote-dir`) offers `gh auth setup-git` in its preflight when git has no credential helper for github.com (`_check_credential_helper`). The offer comes before step (3) asks for the project. These tests type only at the project prompt, so nothing answered the offer, and the run waited until the test killed it.

- **The bound played no part.** The five are exactly this file's typed runs without `--local-remote-dir`. The same names typed in a local run passed on CI. The 95-character GitHub run, which the bound lets through, hung too.
- **The machine decided.** On the machine the tests were written on, `/etc/gitconfig` and `~/.gitconfig` both name a credential helper, so the offer never came there. CI's runner names none.
- **Reproduced** on `854f6ed9` with git's system file skipped and its global file empty (`GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_GLOBAL` set to an empty file): the same five fail with CI's screen (5 failed, 1 passed, in 302 s). The same command with the machine's own config gives 6 passed in 4 s.
- **Not timing.** Pinned to one CPU core that a busy loop also holds (`taskset`), the slowest test in the file took 1.5 s, against its 60-second limit.

**The fix is in the test, not the tool.** The offer is `_check_credential_helper`'s documented warning and offer, and a run is meant to wait for a typed answer to it. The tests depended on the machine's git config. One commit, `b5def3f0`, changes only `tests/test_setup_project_name_edges.py`:

- **`gh_that_is_signed_in` also gives git a credential helper for github.com.** It is set in git's command scope (`GIT_CONFIG_COUNT`), appended to what the environment holds, as `command_scope` in `tests/test_adopt_git_reads.py` does. Its value is `!<the stub> auth git-credential`: the line `gh auth setup-git` writes, pointed at the test's own `gh` stub. The preflight now finds a helper on every machine. No run here pushes, so git never asks it.
- **A new test, `test_a_github_run_here_is_not_held_by_a_git_with_no_helper`** (95 and 96 characters), runs with git's system and global files empty (`GIT_CONFIG_SYSTEM`, `GIT_CONFIG_GLOBAL`), as CI's runner has them. A run that waits at the offer now fails on every machine, not on CI alone.

### Tests

All runs went through `run9.sh` (a `gh` stub first on PATH, `GH_TOKEN=bogus-l5`). Logs are under `v116/f811/` in the lane's scratchpad.

- **The file, 62 tests** (60, plus the new test's 2 cases), on the fixed tree:
  - 10 runs with the machine's git config: 62 passed each time (`loopH-1.log` to `loopH-10.log`).
  - 10 runs with git's system and global files empty: 62 passed each time (`loopC-1.log` to `loopC-10.log`).
- **Slow CPU:** pinned to one core that a busy loop also holds. 3 runs with empty config and 2 with the machine's: 62 passed each time (`loopSC-*.log`, `loopSH-*.log`). The slowest test took 1.5 s.
- **At `b5def3f0`:**
  - this file, `test_setup_project_typed_name.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`: 230 passed (`setA.log`);
  - `test_setup_project_py.py`: 230 passed (`setB.log`);
  - the two setup-project name files with empty config: 84 passed (`setC-nohelper.log`).
- **No other file imports this one** (`git grep`).

### Fails on the base

On `854f6ed9`, with git's own config empty, the five tests fail as they did on CI (5 failed, 1 passed, `repro-base-nohelper.log`).

### Mutants

Each mutant ran in a throwaway clone at `854f6ed9` with the new file copied in.

- **The helper taken out of `gh_that_is_signed_in`:**
  - with the machine's own config, the new test's 2 cases fail, each at the offer (2 failed, 6 passed, `mut1-host.log`);
  - with empty config, the five CI tests fail as well (5 failed, 1 passed, `mut1-nohelper.log`).
- **`import shlex` taken out:** 8 failed with a NameError (`mut3-noshlex-host.log`).
- **The helper and the new test's empty config both taken out:** the new test passes on this machine (2 passed, `mut2-noisolation-host.log`). The empty config is what lets the test catch the first mutant on a machine whose own git has a helper. That is how the five passed here and hung on CI.

### Merge checks

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

- against origin/main `40e678e6`: clean;
- against #756's `b3c77cc2`: clean.

No other `fix/*` or `l3-*` branch on origin holds `tests/test_setup_project_name_edges.py` (250 refs checked).

### Checklist candidates

- **A passed-through `--dry-run` still makes the preflight's offers.** `-- --dry-run` ends the run at the plan, saying nothing was created and nothing was cloned. Its preflight can still offer `gh auth setup-git`, which writes the person's global git config on a typed `yes`, as these five runs showed. The `gh auth login` and install offers work the same way. Whether a dry run should offer to change the machine, or print the offer without asking, is a design question.

Lane 2's rv811 reviewed `854f6ed9` and found it not ready. Its MUST 1 is fix round 1 (`b5def3f0`): the same cause, and the fix it recommends. This round answers the rest in one commit, `050abea6`, on `b5def3f0`. It changes `setup-project.py` and `tests/test_setup_project_name_edges.py`.

### SHOULD 2: only the space comes off a typed name

The prompt's `.strip()` took off every kind of whitespace, not only the spaces its docstring names. Measured at `b5def3f0`: each value below, typed at the prompt, gave `[ok] project      Atlas` and exit 0, both with stdin the terminal and with stdin a pipe. As `--project`, each is refused, exit 2. At #756's head (`b3c77cc2`) the prompt refused each too (rv811's measurement).

| Typed | `b5def3f0` | `050abea6`, typed and as `--project` |
|---|---|---|
| `Atlas` + tab, tab + `Atlas`, `Atlas` + VT, `Atlas` + FF, `\x1c` + `Atlas`, `\x1f` + `Atlas` | taken off, exit 0 | refused, exit 2: "It holds a control character, ..." |
| `Atlas` + NBSP, `Atlas` + NEL (`\x85`), `Atlas` + U+3000 | taken off, exit 0 | refused, exit 2: the name sentence |

- **The fix:** `.strip(" ")`. I took this way, not "say whitespace in the docs", for three reasons:
  - the issue's line is about a stray space;
  - the docstring, the commit and this body all say spaces;
  - #756's rule, that a typed name is checked as `--project` is, then holds for every character but the space.

  A typed control character stays refused, and the refusal quotes it as an escape. A typed line holds no newline here (`ask` drops it), so nothing else needs taking off.
- **The tests:** the nine values are rows of the typed-refusal test, which now runs each of its rows with stdin the terminal and with stdin a pipe (18 new cases). They are also argv rows, as `--project` (9 cases). Each control character is typed after ^V, so the terminal takes it as text: `\x1c` alone is the quit key.

### SHOULD 3: the sentences say ASCII

The name sentence said "letters" and "spaces", but `NAME_RE` takes only ASCII letters and the space, so the words described `Caf\xe9` and a no-break space as values that pass. Both sentences now say what the patterns take, in the words `adopt-project.py`'s help already uses ("ASCII letters, digits and ..."):

- **Name, before:** `It is not spellable as a name: a letter or a digit first, then letters, digits, spaces and `._'()&,+@-`, at most 255 characters in all (`...`).`
- **Name, after:** `It is not spellable as a name: an ASCII letter or digit first, then ASCII letters, digits, the space and `._'()&,+@-`, at most 255 characters in all (`...`).`
- **Ref, after:** `It is not spellable as a ref: an ASCII letter or digit first, then ASCII letters, digits and `._/-`, at most 255 characters in all (`...`).`

The two drift guards also walk every character above ASCII, first and after a letter, and find none taken. README, the handbook, `AGENTS.md` and `docs/cli.md` state neither sentence (`git grep`), and `render-cli-reference.py --check` says `[ok] docs/cli.md is what the generator produces`. No doc changes.

### NITs 4 and 5

- **NIT 4:** a new test passes `topic.locked` and `a.lockb` through `checked_ref`. `git check-ref-format --branch` takes both. The mutant `".lock" in text` now fails it.
- **NIT 5:** `a_run_for`'s comprehension that only copied is now `flags_for(tmp_path, False)`. It changes no behaviour, so it has no mutant.

### Tests

Every run went through `run9.sh`. Logs are under `v116/f811/r2/` in the lane's scratchpad.

- **This file and `test_setup_project_typed_name.py`** (97 and 22 tests):
  - with git's system and global files empty, 3 runs: 119 passed each time (`r2loopC-*.log`);
  - with this machine's git config: 97 passed (`head-host-1.log`), and 119 passed within set A;
  - slow CPU (pinned to one core that a busy loop also holds): 3 runs with this machine's config and 2 with empty config, 119 passed each time (`r2loopSH-*.log`, `r2loopSC-*.log`). The slowest test took 1.9 s, against a 60-second limit.
- **At `050abea6`, set A:** 874 passed (`setA.log`). That is this file, `test_setup_project_typed_name.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`, and every other test file that runs `setup-project.py`: `test_setup_sh`, `test_branch_left_bump_leg_setup`, `test_doctor_machine_row`, `test_doctor_next_is_a_command`, `test_doctor_validator_rows`, `test_dry_run_asks_remotes`, `test_family_printed_quoting`, `test_family_switch_branch_exists`, `test_gh_guard`, `test_openreposhape_command`, `test_openreposhape_package`, `test_readme_windows_quoting_sentence`, `test_release_workflow`, `test_setup_by_hand_commands` and `test_windows_paths`.
- **At `050abea6`, set B:** `test_setup_project_py.py` and `test_scaffold_e2e.py`, 276 passed (`setB.log`).
- **At `050abea6`, set C:** `test_shape_doctor.py`, 428 passed, 3 skipped, 1 xfailed (`setC-doctor.log`). The three skips print the PowerShell line and need a Windows host.

### Fails on `b5def3f0`

The new test file, run against `b5def3f0`'s `setup-project.py`, gives **42 failed and 55 passed** (`base-b5def3f0-host.log`):

- the 18 typed whitespace cases (status 0);
- 24 cases that compare the name or ref sentence: 14 argv and 10 typed.

The 55 that pass are the controls:

- the six control-character argv rows (argv was always refused);
- the drift guards (the patterns were already ASCII);
- the `.lock` test (`b5def3f0` already refuses `.lock` only at the end);
- the rest of the file.

### Mutants

Eight, each in a throwaway clone at `b5def3f0` with this round's two files copied in, and each caught (`mutants.out`):

| Mutant | Result |
|---|---|
| `.strip(" ")` back to `.strip()` | 18 failed, every typed whitespace case |
| the name sentence's old words | 28 failed |
| the ref sentence's old words | 2 failed |
| `NAME_RE` also takes `U+00DF` | 29 failed, the name drift guard among them |
| `REF_RE` also takes `U+00DF` | 3 failed, the ref drift guard among them |
| `.lock` refused anywhere in a ref | 1 failed, the new `.lock` test |
| the test types ^V only before BELL | 2 failed: the `\x1c` cases end by SIGQUIT, status -3 |
| `ask`'s pipe branch strips its answer | 9 failed, every piped whitespace case. This shows the piped cases go through the pipe branch. |

### Merge checks

`git merge-tree --write-tree` of `050abea6`:

- against origin/main `6626a3c5`: clean;
- against #756's `b3c77cc2`: clean.

Of the 39 origin `fix/*` and `l3-*` branches that change `setup-project.py` or this test file, 32 are clean. Seven conflict, and each conflicts in the same way with `b5def3f0` and with origin/main itself: `fix/538-setup-paste-and-checks`, `fix/608-setup-step-checks`, `fix/family-commands-and-branch`, `fix/local-clone-race-no-local`, `fix/setup-help-offline`, `fix/setup-no-terminal-route` and `fix/setup-refuse-taken-clone-path-first`. They are stale branches, not this change.

### Out of scope

rv811's "could not verify" list stays out of this PR:

- GitHub's 100-character limit on a repository name, which needs a real `gh` against a real organisation (lane 1);
- GitHub's 50-character topic limit, against a default id over 39 characters at step (6);
- Windows and macOS;
- Sonar's own analysis;
- two older problems that are the same on `b3c77cc2`: a NUL typed at the prompt (Ctrl-V Ctrl-@, stdin the terminal) hangs `input()`, and a literal newline typed at the prompt ends the name there.

### Checklist candidate

- **`checked_ref` passes `a.lock/b`.** Git refuses a ref with a part that ends in `.lock` (`git check-ref-format --branch a.lock/b` exits 128), but the tool asks only whether the whole ref ends in `.lock`, so `--shape-ref a.lock/b` passes the check and fails later in `git checkout`. The same holds on `b3c77cc2`, so #777 did not cause it.

### Line 1: a refusal names the check the value failed

`refuse_value` gave every value the same closing sentence, and the sentence explained only a leading `-`. Measured on `b3c77cc2`, typed at the prompt:

```
REFUSED: project name is '../x', which is not a value this tool will put on a `git` or `gh` command line. git reads its own arguments: a value that starts with `-` is an option to git, not a name to us; the rest must be spellable as a name, a ref or a path.
```

`a/b`, ` Atlas`, `Atlas/`, `..` and `Caf\xe9` got the same sentence, and so did `--org ''` and `--into ''`. `refuse_value` now takes the reason, and `why_refused` gives the first check the value failed, in this order:

- **empty:** `It is empty, and a value here needs at least one character.`
- **starts with `-`:** keeps its sentence, `It starts with `-`, and git reads its own arguments: a value that starts with `-` is an option to git, not a name to us.` The existing `--org -evil` test in `tests/test_setup_project_py.py` still finds "git reads its own arguments".
- **control character:** `It holds a control character, which the quotation shows as an escape: a newline ends a line ...`. The first sentence still quotes the value with `ascii()`.
- **the pattern:** in words and as the pattern, as the sibling prints its own. For a name: `It is not spellable as a name: a letter or a digit first, then letters, digits, spaces and `._'()&,+@-`, at most 255 characters in all (`^[A-Za-z0-9][A-Za-z0-9 ._'()&,+@-]{0,254}$`).` For a ref the same with `._/-`. A pattern with no words of its own is named as `repo_shape.checked_value` names it (`It does not match ...`); `PATH_RE` never gets there, because its only failures are an empty value and a control character, which are named first.

`checked_ref` says `..` (`a revision range and not a ref`) and `.lock` (`the name git gives the lock file it writes beside a ref`) in their own sentences, and `checked_passthrough` its control character. The first sentence is unchanged, so `--shape-ref is 'a b'`-style prefixes in the existing tests still match.

**`repo_shape.checked_value` is not changed.** `scripts/repo_shape.py` is pinned in every project, so a wording change there is a re-pin in a shape bump. It also already prints its pattern, and its pattern is a different one (`SAFE_ARG_RE`, one for names, branches and paths: it takes `../x` and `a/b`, which `setup-project.py` refuses as names). `tests/test_scaffold_pin_and_reuse.py:502` and `:517` pin its words and still pass. The comment at `tests/test_setup_project_py.py:787` said the two refuse "in the same words"; it now says they refuse the same values, each in its own sentence. The `checked_value` docstring in `setup-project.py` said the same and is fixed.

### Line 2: a name GitHub cannot hold is refused before step (4)

Measured on `b3c77cc2`, a 96-character name (and a 251-character one) typed at the prompt, non-local, with a `gh` stub that is signed in:

- 96: `(3) project`, `[ok] project`, the whole of `(4) naming policy` (three names), then `(5) the plan`, where the real run would call GitHub.
- 251: the same wall, then `REFUSED scaffold-name-too-long: ... is 256 bytes long`.

Now both end with exit 2 and nothing else printed after the prompt:

```
REFUSED: project name is 96 characters long (Abbbbbbbbbbb...bbbbbbbbbbbb), and a project is three repositories, `<Project>`, `<Project>-spec` and `<Project>-code`: GitHub keeps a repository name to at most 100 characters, so a project name is at most 95. Nothing was created. Use a shorter name.
```

- **The number.** `<Project>-spec` and `<Project>-code` add five characters to 100, so a project name is at most 95 (`PROJECT_NAME_MAX`).
- **Where it is asked.** `--project` and the positional are asked at the end of `parse_args`, because `--local-remote-dir` can come after `--project` on the line. That is before step (1) prints, and the refusal says `--project is` or `<Project> is`. The prompt asks it right after the name passes `checked_value`, before step (3) prints its `[ok]` lines.
- **Only where a GitHub repository will be made.** Under `--local-remote-dir` it is not asked: 96, 100, 101 and 246 characters go on to step (4), and 247 ends at step (5) as `scaffold-name-too-long`, which is the disk's limit and still step (5)'s to ask (#261). I measured both on `b3c77cc2` (246 passes, 247 refused) and the tests pin them.
- **Not a pattern in `checked_value`.** `NAME_RE` is shared with the organisation, the id, the display name and the person electing the shape, none of which has this limit, so the bound is its own function, `checked_project_length`.
- **The name is quoted at both ends** (`name[:12]...name[-12:]`), as step (5) quotes a long name, since a name this long differs from its neighbours by its tail.

### Line 3: the prompt takes the spaces off a typed name

Measured on `b3c77cc2`, `Atlas ` typed at the prompt: `[ok] project      Atlas ` (a trailing space nobody can see), step (4) judges `Atlas `, `Atlas -spec` and `Atlas -code`, exit 1 with `FINDING naming-unclassified: Atlas , Atlas -spec, Atlas -code`, and ` Atlas` typed the same way is refused at once, exit 2.

I took the first of the issue's two ways: `ask_for_the_project_name` strips what was typed. Why not the other (quote each name with `ascii()` in the gate's finding):

- A stray space comes from the prompt (a paste, a slip), and the run ends at the first mistake with no second ask, so taking the space off is what saves the person a retype. Showing it only explains the refusal.
- The finding is the naming gate's own output, read by `scaffold-project.py`, `adopt-project.py` and the family validators too. Changing how it quotes changes the gate for every caller, for a stray space that only the prompt produces.
- An argv value is deliberate, so `--project 'Atlas '` is held as typed (a test pins it) and still ends at step (4); see the candidates.

`.strip()` takes spaces from both ends, so ` Atlas` typed is `Atlas`, where it was refused. A line of three spaces is then an empty line: `no project name was typed; nothing was created.` That moves #756's `blank` case from its refusals (compared with `--project '   '`, which is still refused by the pattern) to its empty answers, a three-line edit in `tests/test_setup_project_typed_name.py`.

`tests/test_setup_project_name_edges.py` is new: 60 tests. On `b3c77cc2` (a throwaway clone, detached, with the new file copied in) **47 fail and 13 pass**. The 13 that pass pin what must not change: a name at 95 goes on to step (4), the local cases (96, 100, 101 and 246 go on, 247 ends at step (5), and a late `--local-remote-dir` is read), `--project 'Atlas '` held as typed, and two drift guards.

- **One case per refusal reason and carrier** (`test_a_refusal_names_the_check_the_value_failed`, 23 cases): empty, leading `-`, control character, the name pattern for `../x`, `a/b`, `..`, ` Atlas`, `Atlas/`, an accented name, `'   '` and 256 characters, the positional, the ref pattern, `..`, `.lock`, a leading `-` on a ref, the passthrough, and two values that fail two checks (told the first). Each compares the whole `REFUSED:` line, and for every reason but the dash asserts the dash's words are absent.
- **At the prompt, through the pseudo-terminal** (7 cases): the dash, the control character, `../x`, `a/b`, `Atlas/`, the accented name and 256 characters. The empty reason is not reachable at the prompt (an empty line is "no project name was typed"), so it is tested through `--org ''` and `--into ''`.
- **The bound:** 96, 100, 101 and 255 characters, as `--project` and as the positional, and at the prompt; 95 at the prompt (both modes) and as `--project`. Each refusal is exit 2 with nothing on stdout (flag) or no `[ok] project` and no `(4) naming policy` (prompt).
- **The spaces:** `Atlas `, ` Atlas` and `  Atlas  ` typed give `[ok] project      Atlas` with no space, `[ok] legs         Atlas-spec, Atlas-code`, and a `clone into` path ending in `Atlas`; three spaces are no name.
- **Drift guards:** every printable character is in the name sentence's list exactly when `NAME_RE` takes it after a letter, and the same for the ref sentence.

Runs, all on the head, through `run9.sh`, logs in the lane's scratchpad (`v115/w777/`):

- `test_setup_project_name_edges.py` and `test_setup_project_typed_name.py`: 82 passed (60 and 22), with `test_branch_left_bump_leg_setup`, `test_family_printed_quoting`, `test_family_switch_branch_exists`, `test_windows_paths`, `test_dry_run_asks_remotes`, `test_setup_by_hand_commands`, `test_gh_guard`, `test_openreposhape_command`, `test_openreposhape_package`, `test_release_workflow`, `test_doctor_machine_row`, `test_doctor_validator_rows`, `test_doctor_next_is_a_command` and `test_readme_windows_quoting_sentence`: **602 passed** (`runB.log`).
- `test_setup_project_py.py`, `test_setup_sh.py`, `test_scaffold_pin_and_reuse.py`, `test_repo_hygiene.py` (pure ASCII source, README cap), `test_agents_md_codes.py` and `test_cli_reference.py`: **540 passed** (`runA.log`).
- `test_shape_doctor.py` (its machine row runs `setup-project.py --preflight`): **428 passed, 3 skipped, 1 xfailed** (`runC.log`). The three skips print the PowerShell line, which needs a Windows host; the xfail is the file's own marker.

Thirty, one or more per hunk, each in a throwaway clone and each caught by a failing new test (`mutants.out`, `mutants2.out`): the bound off by one and `<=` made `<`; GitHub's number changed; each reason's sentence changed (empty, dash, control, `..`, `.lock`, name words, ref words, the fallback); `ascii` replaced by `repr`; the branch order changed and each of empty, dash and control dropped; the pattern ignored; an empty value accepted; the local exemption dropped, at the prompt and at the parse; the parse check removed; the positional spelled as the flag; the name's tail changed; `..` and `.lock` accepted; the passthrough's reason changed; the prompt's `.strip()` removed and made `.rstrip()`; the prompt's bound removed; the prompt told it is never local.

`git merge-tree --write-tree` of `854f6ed9` against: origin/main `bf1f099b` clean; `b3c77cc2` (the parent) clean; every other wave-7 branch (`fix/757`, `758`, `759`, `760`, `761`, `762`, `764`, `768`) clean, and none of them touches `setup-project.py` or the three test files; `fix/696-setup-family-folder` clean (its `setup-project.py` hunks are in `family_folder_here`, away from this change). Against all 53 origin branches only four conflict: `docs/release-notes-v1.0.0`, `fix/688-family-init-values`, `l3-677-pins-hand-step-wip` and `release/v1.0`. Each conflicts the same way, in the same files, against `b3c77cc2`, so none is this change.

- **Under `--local-remote-dir`, 247 to 255 characters** still print step (4)'s wall before step (5) refuses them. The issue's direction is to ask only where a GitHub repository will be made, and step (5)'s refusal is exact, so this is left as it is.
- **`--help` for `--project` does not state the 95.** The refusal does. A help change regenerates `docs/cli.md`, which other lanes' PRs regenerate too.

- **GitHub's topic limit may bound the name lower than 95.** `scaffold-project.py` sets `xf-project-<id>` on each repository (`_set_topics`, `scaffold-project.py:1668`, through `run`, which raises on any non-zero exit). GitHub's documentation says a topic is at most 50 characters, so an id over 39 characters (the default id is the project lowercased) would fail at step (6), after the three repositories exist. A name of 40 to 95 characters passes this PR's bound. Not measured; it needs the real `gh` (lane 1), and the bound may be 39 for the default id.
- **`scaffold-project.py` and `adopt-project.py` run directly have no GitHub bound.** A bound in the scaffold's plan, beside `scaffold-name-too-long`, would cover them too. This change covers `setup-project.py` only, as the issue directs.
- **`--project 'Atlas '` (argv) still reaches step (4) with a space nobody can see.** The naming gate's finding does not quote the names it lists. Either the gate quotes with `ascii()` or `NAME_RE` stops ending a project name in a space.
- **`--org` has GitHub's own limit** (39 characters, documented) and nothing asks it: a 40-to-255-character `--org` passes `NAME_RE` and fails at GitHub.

Review: lane 2's independent review (issuecomment-6236485584) and its re-verification of 050abea, READY (issuecomment-6237317731). `f1dbbbf5` restacks it onto #756's squash `4dea85ee` with the same `-U0` lines; `b6f56cad` is the second-lander conversion after #848: the credential helper's entry goes through `appended_to_command_scope`, the same three variables as before, and #848's scan (`test_no_auto_maintenance.py`), which failed at `f1dbbbf5`, passes. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 b6f56ca (the restack of the branch onto main after #756 landed) passed tests, tests-git-bash and SonarCloud; the head 3795a6e is a clean update-branch merge of main 85555d0, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree e52497a over main 5c02a04: the adopt_clone_claimed, adopt_source_clone_record, adopt_work_dir_refusal, doctor_pin_restore_merges, setup_project_name_edges, setup_project_py, setup_project_typed_name, repo_hygiene, agents_md_codes, cli_reference sets, 717 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 #777

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
…asks GitHub's limits on a name, a topic and an organisation where GitHub is, so an id over 39 characters no longer fails `--add-topic` after the repositories exist (#838) (#859)

# Title

scaffold-project.py, adopt-project.py and setup-project.py: the plan asks GitHub's limits on a name, a topic and an organisation where GitHub is, so an id over 39 characters no longer fails `--add-topic` after the repositories exist (#838)

# Body

GitHub keeps a repository's name to 100 characters, a topic to 50, and a user's or an organisation's name to 39. **The 100 and the 50 are documented on docs.github.com ("Creating a new repository"; "Classifying your repository with topics"). For 39, the docs give the limit on a user's name (the Enterprise Cloud "Username considerations for external authentication"), and nothing for an organisation's. None of the three is measured here.** No run of this repository creates a real repository, and I did not run the real `gh` to find them. The fake `gh` in the new tests refuses only a topic over 50 characters; a name over 100 or an organisation over 39 never reaches it. A probe against a real organisation is lane 1's.

Nothing asked these limits before:

- `scaffold-project.py` sets `xf-project-<id>` last, after it has created and seeded all three repositories. An id over 39 characters failed `gh repo edit --add-topic` there. A `--project` of 40 or more gives such an id by default.
- `adopt-project.py execute` failed at the same topic after both legs and the split were pushed and the pull request was open.
- A `--project` of 96 to 250 characters, or an `--org` of 40 to 255, passed the plan and reached the first `gh repo create`.

Now each is refused before any repository is made, and only where a GitHub repository will be made. Under `--local-remote-dir` nothing goes to GitHub: a name's limit is still the disk's, and no topic is set.

**1. A topic over 50 characters.** The direction was to ask in the plan. The number lives in `scripts/shape_materialize.py`, beside the disk's `NAME_MAX`: `GITHUB_TOPIC_MAX = 50`, and `GITHUB_ID_MAX = 50 - len("xf-project-") = 39`. I did not put it in the contract's `id_pattern`, so `scripts/repo_shape.py` is not re-pinned, and a local-only project keeps a long id. `refuse_a_topic_github_cannot_set` reads the id's bound off the topic the naming policy gives.
- `scaffold-project.py` refuses `scaffold-topic-too-long` right after `scaffold-bad-id`. That is before `_declaration`, which calls `gh api` for a `--pin` with no `--pin-source`, and `--dry-run` refuses the same. No process has started by then when `--elected-by` or `GIT_AUTHOR_NAME` names the elector. Without either, `_identity` has run `git config user.name` first, as it has before `scaffold-bad-id`. Nothing is created either way. A derived id is named as `scaffold-bad-id` names one, `--id (not given, so --project lowercased)`. Its remedy is an explicit `--id`, which keeps the long name. A passed `--id` is told to pass a shorter one.
- `adopt-project.py execute` refuses `adopt-topic-too-long` where it works out the topic, before `_confirm` and before either leg. That is after the `git ls-remote` reads of the repository the split is pushed to (its tracking branch, a split branch left behind, a branch in the split branch's way), so one of those refusals can be shown first. No `gh` call is made and nothing is created either way. It names the plan's `id:` (or `id (not set in the plan, so legs.assembly lowercased)`), and says to fix `id:` or re-run `plan` with `--id`.
- Evidence, by the reproduction below. On `bad05d21`, a 40-character project made three `gh repo create` calls, then `HTTP 422` at `--add-topic`, exit 2. An adoption with a 40-character `--id` pushed both legs and opened the pull request, then printed `Set the topic by hand` with a topic GitHub refuses. On `71936633`, both refuse with no `gh` call and nothing made.

**2. Names over 100 in the direct tools.** The direction was the same check, once, in the helper that asks the disk's limit. `refuse_unstorable_names` now takes a third list, `github`: the names GitHub will be asked to create. A name over `GITHUB_NAME_MAX = 100` is refused under the same code, `scaffold-name-too-long` or `adopt-name-too-long`.
- The scaffold passes all three names without `--local-remote-dir`. Adopt passes the two legs, because the root already exists.
- In adopt, that root is the source's own repository on GitHub, which GitHub lets be named with up to 100 characters. A source named with 96 to 100 characters gives legs of 101 to 105. Every adoption keeps the source's own name, so `--project` cannot shorten it. Where an over-long leg is named after a root of at most 100 characters, the remediation says to rename the source on GitHub first, to at most 95 characters, and plan again. It says so in `_rename_first`'s words, as `adopt-bad-id` does, and says GitHub's 95 once. A leg the plan was edited to name otherwise keeps the remediation it had. So does any leg of a root over 100 characters, which no repository on GitHub can be named, so renaming the source shortens nothing: "Use a shorter --project". That comes only where the plan's `legs.assembly` is not held to a GitHub `origin`, that is, a source with no `origin` or a path `origin`, and a long `--project` was typed.
- A name the disk cannot hold is not named twice.
- The remediation names each limit the names broke. On GitHub it also names the id's 39, which the topic line bounds: whoever picks a shorter name learns both numbers at once.
- The disk-only remediation is word for word as before.

**3. `--org` over 39.** `refuse_an_owner_github_cannot_have` asks it: `scaffold-org-too-long` in the scaffold's plan, and `adopt-org-too-long` in `execute` beside the names, before either leg. A detected organisation needs nothing extra. One read from a GitHub `origin`, or from `gh`, is never over 39. One read from another forge's URL is held to GitHub's limit, because GitHub is where the repositories go.

**4. `--help` states the bounds.** Each of `--project`, `--id` and `--org` says its number in `setup-project.py`, `scaffold-project.py` and `adopt-project.py plan`, and `docs/cli.md` is regenerated. The two direct tools build the sentence from the constants. `setup-project.py` cannot import them, because it runs before there is a checkout. `test_setup_project_says_the_numbers_it_cannot_import` holds its sentences to the numbers. Once #811 lands, the same test also holds #811's `GITHUB_NAME_MAX` and `PROJECT_NAME_MAX` to them. I did not hold the help line back for a later pull request: `merge-tree` finds no conflict in `docs/cli.md` against any open branch that edits it (below), and any later conflict there is solved by running the renderer again.

Step (5) of `setup-project.py` is the scaffold's own `--dry-run` plan, given setup's `--project`, `--id` and `--org`. That plan now refuses all three before step (6) asks for the yes, and setup prints `the plan could not be produced (see above)` under the scaffold's refusal. The refusal names `--id`, `--org` and `--project`, which setup takes too. Tests cover each value through the flags and a name typed at the prompt (40 refused, 39 goes on).

I added no check of my own to setup's parsing. #811's tests pin that a 95-character name with a derived id goes on to step (4) (`test_a_name_at_the_bound_goes_on_to_step_four`). A topic check at parse time would refuse that name before step (4). #811's own 95 stays setup's early check of `--project`. The new refusals also avoid the words `characters long`, which #811's tests assert are absent from a 95-character GitHub run.

**Lands after #811.** On a probe merge of `71936633` with #811's head `050abea6`, these files pass: #811's `test_setup_project_name_edges.py`, #729's `test_setup_project_typed_name.py`, `test_setup_project_py.py`, the new file and `test_cli_reference.py` (430 passed). The `merge-tree` with #811 and with #756 (`b3c77cc2`) is clean, and no hunk here touches their name rules.

- New, `tests/test_github_name_limits.py`, 64 tests (58 after fix round 1, 50 before it). Each bound is tested at N and N+1 through the flags, a derived id, `--id ''`, setup's prompt, both direct tools, and with and without `--local-remote-dir`. Refusals are asserted with no process started (in process, `no_process`) or with no `gh` call and nothing on disk (end to end). One case covers a name of 40 to 95 characters with a short `--id`, which still passes. Fix round 1 added:
  - a source named with 96 and with 100 characters, told to rename it first;
  - the edited leg and the disk, which keep their remediations;
  - `execute` without `--yes`, and its control;
  - each remediation of the scaffold's and `execute`'s own, word for word;
  - and the NIT round's (#859's re-review): one leg named after the source with the other edited to a short name, for each leg (2); a leg that starts with the root without being named after it (1); a root of 101 and one of 251 (2); the disk's remediation under `--local-remote-dir` beside a leg named after a 100-character source (1). The in-process test of the rename-first case now asserts the refusal's detail too.
- Changed, `tests/test_scaffold_manifest_values.py`: the GitHub half of `test_the_longest_name_a_disk_keeps_is_still_accepted` asked a 250-character project. It now asks GitHub's longest, 95, with a short `--id`.
- **On base `bad05d21`, 33 of the first round's 50 new tests fail.** The other 17 are controls that pass on both: the value at the bound, local runs, and a short `--id`. The changed test passes on both. **On `9166f84f`, 3 of the 58 fail**: the three cases of a source named with 96 or 100 characters. **On `71936633`, 7 of the 64 fail**: the roots of 101 and of 251, which were told to rename the source, and five that compare the rename-first remediation word for word, whose sentence now says 95 once (the 96- and 100-character end-to-end cases, the in-process test, and the two one-leg cases). With only that sentence changed on `71936633`'s code, 2 of the 64 fail: 101 and 251. The rest pass on both; each kills a mutant (below).
- Runs, all on Linux, each with the commit it ran on:
  - **On `495d4b33`** (the NIT round, `dirty=0`), in one run of three shards: `test_github_name_limits.py`, `test_scaffold_manifest_values.py`, `test_adopt_manifest_values.py`, `test_adopt_github_exits.py`, `test_adopt_project_is_the_source.py`, `test_cli_reference.py`, `test_shape_doctor.py`, `test_scaffold_leg_path_collisions.py`, `test_repo_hygiene.py` and `test_agents_md_codes.py`: 980 passed, 3 skipped, 1 xfailed in 857.78s (the longest shard). Every test file that names `adopt-name-too-long`, the remediation or the predicate is among them. The 179-file run below was not repeated for this round: it changes one predicate and one sentence of `adopt-project.py`.
  - **On `71936633`** (fix round 1, `dirty=0`): the 179 test files that import or drive a changed file, in two runs: 5364 passed, 13 skipped, 1 xfailed, and 5573 passed, 24 skipped.
  - **On `9166f84f` with fix round 1's diff uncommitted**, whose `sha256` equals that of `git diff 9166f84 7193663`: the new file, `test_scaffold_manifest_values.py`, `test_adopt_manifest_values.py`, `test_adopt_github_exits.py`, `test_adopt_project_is_the_source.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`: 434 passed.
  - **On `bad05d21` with the first round's change uncommitted** (an earlier state of it, not `9166f84f`):
    - the issue's Verify files (`test_setup_project_py.py`, `test_scaffold_manifest_values.py`, `test_adopt_manifest_values.py`, `test_scaffold_leg_path_collisions.py`, `test_shape_doctor.py`, `test_cli_reference.py`, `test_repo_hygiene.py`), with `test_agents_md_codes.py` and 47 of the new file's tests: 1148 passed, 3 skipped, 1 xfailed;
    - the 37 other files that drive `gh` or a GitHub-mode run: 2127 passed, 1 skipped.
  - **On `3845fd54`**, a local commit that differs from `9166f84f` only in `tests/test_github_name_limits.py`:
    - the other 98 test files that import or run a changed file: 3523 passed, 2 skipped, and 2908 passed, 28 skipped;
    - the setup cases again with git's system config emptied, as on a machine with no credential helper (CI's runner): 10 passed.

The reproduction runs on a fake `gh` and touches no network. The `bad05d21` side ran in the first round; the other side ran again on `71936633` in fix round 1.
- **Scaffold.** It ran in GitHub mode with `--no-push`, against a fake `gh` that refuses a topic over 50 characters as GitHub documents. On `bad05d21`, `--project` of 40 characters made `repo view` ×3, then `repo create` ×3 (spec, code, root), then `repo edit ... --add-topic xf-project-<40>`, which got `HTTP 422`. The run exited 2, with the work trees left. On `71936633` it exits 2 with `scaffold-topic-too-long`, no `gh` call, and no work directory.
- **Adopt.** `execute` ran in GitHub mode with `--id` of 40 characters, using `test_adopt_github_exits.py`'s shim told to refuse `repo edit`. On `bad05d21` it made both legs, pushed the split and opened the pull request. Then it printed `The split IS pushed and the pull request IS open. Set the topic by hand:` with three commands GitHub would refuse too. On `71936633` it exits 2 with `adopt-topic-too-long`, no `gh` call, and no leg.

- **First round:** 20 mutants, one per new hunk, and each was caught. They covered the GitHub names ignored, the de-duplication, the GitHub remediation, the topic and the organisation off by one, `GITHUB_PROJECT_MAX`, each call site in the scaffold and in adopt, the local guards both ways, the derived, passed and empty id's naming, adopt's `id:` naming, each tool's `--help`, setup's usage, and a stale `docs/cli.md`.
- **The review at `9166f84f`:** 42 mutants; 7 survived.
- **Fix round 1, at `71936633`:** 51 mutants, one at a time: 50 killed and 1 known survivor.
  - rv859's 42 are among them, and six of its seven survivors are now killed: the two refusals moved below `_confirm`, and the four remediation wordings.
  - The survivor is `sm-both-ends-26`, `_both_ends`' boundary of 27, which is moved code and not new.
  - 9 are new, for the rename-first hunk, and each is caught.
- **The NIT round, at `495d4b33`:** 33 mutants, one at a time, each run with `-x` against the new file: 33 killed, none survived.
  - rv859b's seven survivors are killed. `rv-any-to-all` and `rv-code-only` fail `test_one_leg_named_after_the_source_is_enough[spec-after-the-root]`. `rv-spec-only` and `rv-suffix-always-spec` fail `[code-after-the-root]`. `rv-startswith-root` fails `test_a_leg_the_plan_names_otherwise_keeps_its_remediation[starting-with-the-root]`. `rv-detail-dropped` and `rv-detail-is-remedy` fail `test_a_leg_named_after_the_source_is_mended_by_renaming_it`.
  - Its other five mutants of the hunk, f859's eight (re-aimed at the new predicate) and six of rv859's earlier survivors are rerun, and killed.
  - 7 are new: the assembly bound dropped, strict, one over, 95, read off the `spec` leg and inverted, and the sentence that said 95 twice. Two are killed by the root of 101, four by the root of 100, and the sentence that said 95 twice by the remediation's words.
  - One survived a first pass, made on an earlier local form of this commit: `f-ad-local-guard-dropped`, the `--local-remote-dir` guard in the handler. Its only test used a root of 247, which the new bound refuses with the disk's remediation anyway, so the guard was no longer reached. `test_a_leg_named_after_the_source_is_not_the_disks_trouble` kills it, and all 33 were run again on `495d4b33`.

At `495d4b33`, `merge-tree` is clean against origin/main `4dea85ee` and against 34 of the 36 other open `fix/*` and `l3-*` heads (`ls-remote` at about 03:00Z), among them `fix/798-adopt-legs-asked-first` (`70801c91`), which conflicted at fix round 1's sweep. The two that conflict are `l3-677-pins-hand-step-wip` (`shape-doctor.py`, `validate-pins.py` and two test files) and `l3-678-naming-checkout-wip` (`shape-doctor.py`). They conflict the same way at `71936633`, and touch none of this pull request's files.

Fix round 1's sweep: at `71936633`, `merge-tree` was clean against origin/main `190133d5` and against 55 of the 58 open `fix/*` and `l3-*` heads other than this one. Among the 55 are #811 (`fix/777-typed-name-edges`, `050abea6`) and #848 (`fix/835-config-count-appends`, `79b2eac4`). They also include every head that touches these files except `fix/798`: `fix/710-non-utf8-branch-names`, `fix/711-git-bash-dialect`, `fix/715-yaml-reader-spaces`, `fix/716-adopt-check-plan-name`, `fix/729-typed-project-name` (#756), `fix/764-bootstrap-notice-exit-texts`, `fix/766-spelled-paths`, `fix/796-adopt-source-spellings`, `fix/797-adopt-default-branch-refs`, `fix/799-adopt-plan-out-checked`, `fix/800-pin-source-top-level`, `fix/801-answered-paths-per-leg`, `fix/803-adopt-clone-claimed`, `fix/807-eol-block-tracked-spelling`, `fix/836-child-environment`, `l3-785-non-ascii-paths` and `l3-rehearsal-v1.1.0`. Three conflicted. Each conflicted the same way at `9166f84f` and against main `190133d5` alone, so they are theirs and not this change's:
- `fix/798-adopt-legs-asked-first` (`scripts/shape_materialize.py`);
- `l3-677-pins-hand-step-wip` (`shape-doctor.py`, `validate-pins.py` and two test files, none of them touched here);
- `l3-678-naming-checkout-wip` (`shape-doctor.py`).

The new file builds no `GIT_CONFIG_COUNT` by hand. The prompt test gives git a credential helper through a global config file of its own (`GIT_CONFIG_GLOBAL`). #848's `test_no_test_sets_a_git_config_count_of_its_own` passes on a probe merge of `71936633` with #848's head `79b2eac4`: `test_no_auto_maintenance.py` and the new file, 118 passed.

- `scripts/family.py init` has the same late `--add-topic` for the family's id, and the same unasked `--org` and holder name. The issue's goal names the three tools, and seven open branches edit `family.py`, so I left it out. It is checklist candidate 1.
- `adopt-project.py check` asks neither GitHub's limits nor the disk's. `execute` refuses both before either leg. `check` takes `--local-remote-dir` and could find them: candidate 2.

1. **`family.py init`: an id over 39 characters fails `gh repo edit --add-topic` after the holder is created and pushed.** The topic `xf-project-<family id>` is set last (`scripts/family.py:2657`), and nothing asks GitHub's 50. `--org` over 39 and a holder name over 100 are not asked either. Direction: the same three helpers, in `init`'s plan, without `--local-remote-dir`.
2. **`adopt-project.py check` passes a plan that `execute` refuses for a name's length.** `adopt-name-too-long` (the disk's 255, and now GitHub's 100), `adopt-org-too-long` and `adopt-topic-too-long` are asked by `execute` alone. `check` takes the same `--local-remote-dir`, so it could report each one as a finding before the person's yes.
3. **An `--org` holding a character GitHub refuses in an organisation's name passes every plan.** GitHub keeps an account's name to ASCII letters, digits and single hyphens, with no hyphen at either end; that rule is not checked against docs.github.com here. `checked_value` and the manifest validator accept `my_org.x`, which `tests/test_adopt_manifest_values.py` plans as valid. That `--org` reaches `gh repo create`.
4. **A leg the plan was edited to name, over a limit, is told "Use a shorter --project".** `legs.spec` and `legs.code` are the plan's own to edit, and `adopt-name-too-long` (the disk's 255, and GitHub's 100) names a leg named otherwise than after `legs.assembly` with the remediation of a name made from `--project`, which does not shorten it. Fix round 1 keeps that remediation word for word for such a leg; only a leg named after the source is told to rename the source. The mixed case goes with it: a source of 96 to 100 characters, one leg named after it and the other edited past 100, is told only to rename the source, which does not shorten the edited leg (rv859b's re-check). Direction: name the plan's key, `legs.spec` or `legs.code`, as the thing to shorten.

Review: rv859, lane 5's independent review, answered by fix round 1 (issuecomment-6243078675); rv859b's re-review of fix round 1, READY at `71936633` (issuecomment-6244672717); and rv859b's re-check of the NIT round, READY at `495d4b33` (issuecomment-6245094878). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 495d4b3 passed tests, tests-git-bash and SonarCloud; the head 9695935 is a clean update-branch merge of main e6cf5d9, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree c17128d over main e6cf5d9: the github_name_limits, scaffold_manifest_values, repo_hygiene, agents_md_codes, cli_reference sets, 317 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 #838

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
…light's offers and `--shape-ref` is held to git's rule part by part, so a rehearsal no longer writes git's global config and `a.lock/b` is refused before the clone (#857) (#873)

Stacked on #811 (`fix/777-typed-name-edges`); retarget to main once it lands.

#756 has landed as `4dea85ee`, and #811's head is now `b6f56cad`: #756's squash merged in, and a test-only change to `tests/test_setup_project_name_edges.py`. This branch stays on `050abea6`, the merge base of the two, so its diff against #811's branch is its two commits: the fix (`79a33f11`) and fix round 1 (`ac570069`), which changes two test files and nothing else. Lane 5 restacks it after #811 lands.

**Direction: the silent reading.** A run passed `-- --dry-run` reports what is missing and asks nothing that would change the machine. Where a terminal would have been offered a command, it is told `not offered: --dry-run was passed through. A run without it offers this here, and so does --preflight.`, and asked nothing. The plan's last line now ends: ``The preflight made none of its offers; a run without --dry-run makes each one marked `not offered` above.`` `--preflight` still makes every offer, whatever follows `--`: it is the run that exists for them (#59), and it returns before anything reads the passthrough.

**Why.** Measured at `050abea6`, what each offer does on a typed `yes`, and all three change the machine:

- an install (`offer_missing`): git or gh, through the package manager;
- `gh auth login` (`_offer_login`): gh's own sign-in;
- `gh auth setup-git` (`_check_credential_helper`): gh writes a credential helper into the person's global git config.

A pty run of `setup-project.py Sample --org demoorg --elected-by Tester --into <dir> -- --dry-run`, under a signed-in stub `gh` and a git with no helper, asked ``Run `gh auth setup-git` now? Type yes to continue:``. The typed `yes` ran it, and the stub, which writes what the real one writes, added `[credential "https://github.com"]` to the file `GIT_CONFIG_GLOBAL` names. The run then ended at the plan saying `nothing was created and nothing was cloned`. With gh signed out, the same run asked ``Run `gh auth login` now?``, and the `yes` signed the stub in. The help said a `--dry-run` there "ends this run at the plan, creating nothing". And #811's CI run 39027235147 showed five rehearsals that typed only the project name waiting at that question until they were killed. Nothing argued for the offering reading: the offers stay one flag away (`--preflight`), and the real run makes them.

**What changed.** `preflight` passes `offering = not passes_a_dry_run(opts.passthrough)` to `preflight_checks`, and it goes down through `_check_git`, `_check_gh`, `offer_missing`, `offer_install`, `_offer_login` and `_check_credential_helper` to `offer`. `offer` tests it after `can_ask()`, so a run with no terminal prints what it printed before, and it is a bool, never `Options`, so `test_the_offer_helpers_cannot_see_the_yes_flag` still holds. The help says it, and `docs/cli.md` is regenerated. `AGENTS.md`, `README.md` and the handbook were grepped for `dry-run`, `creating nothing` and the offers: no sentence there is made false, so none is touched.

The git install offer cannot be reached by a dry run end to end: `_main` refuses a machine with no git (`GIT_MISSING`) before the preflight runs. It is held in process, through `preflight`, as the other two are.

**Tests** (`tests/test_setup_project_dry_run_offers.py`):

- `test_a_dry_run_makes_none_of_the_offers_preflight_makes[install|login|helper]`, once for `--dry-run` and once for its short spelling `--dry` (`passes_a_dry_run` reads both), in process on every platform. A fake terminal types `yes` to anything and a recorder runs nothing. `preflight` on `Sample --org demoorg -- <flag>` makes no prompt and runs no offered command, says `not offered` once per offer (2, 1, 1), and refuses or passes as before. `preflight_only` on `--preflight -- <flag>` then makes the same 2, 1, 1 offers.
- `test_a_dry_run_with_no_terminal_says_what_a_run_without_it_says[install|login|helper]`, in process: with no terminal (stdin is not one and there is none to open), `preflight` on `-- --dry-run` prints no `not offered` line, and what it prints is what the same run prints without `--dry-run`. It holds the order in `offer`, `can_ask()` before `offering`.
- `test_a_dry_run_leaves_git_with_no_helper_as_it_found_it[py|piped|py with --dry]`, a pty run (`PTY_SKIP`). A signed-in stub `gh`, an empty system config and a global config of the run's own, with no `GIT_CONFIG_COUNT` and run from a folder that is no repository. The run asks nothing, exits 0 at the plan, prints the `[??]` warning with the `not offered` line under it and the new last line, never calls `gh auth setup-git`, and leaves the global config the bytes it was.
- `test_a_dry_run_does_not_sign_gh_in`: the same with gh signed out (git has a helper, so the login is the one offer at stake). It asks nothing, the `[!!]` line has the `not offered` line under it, it refuses `one or more prerequisites are missing`, and gh is still signed out.

**Direction: finish the rule,** in a new `why_not_a_ref` beside `why_refused`, which `checked_ref` asks after `REF_RE`. Git's rule is per part: no part is empty (`//`, a trailing `/`), none starts with `.`, none ends in `.lock`, and the whole holds no `..` and does not end in `.`. Each failure has its own sentence (`WHY_EMPTY_PART`, `WHY_DOT_PART`, `WHY_LOCK_PART`, `WHY_DOT_END`). `WHY_RANGE` and `WHY_LOCK` keep #811's words, so its `ref-range` and `ref-lock` cases are unchanged. `REF_RE`'s comment and `checked_ref`'s docstring say what is asked.

**Evidence.** At `050abea6`, `checked_ref` beside `git check-ref-format --branch` disagreed on seven of the issue's ten names (`a.lock/b`, `x/y.lock/z`, `release.lock/v1`, `a/.b`, `a//b`, `a/b/`, `a.lock/`), and on `a.` and `a/b.`, under git 2.43.0 and 2.47.1 alike. Over all 3110 names of up to five parts from {`a`, `0`, `.`, `/`, `-`, `.lock`} that `REF_RE` takes, they disagreed on 1122 before and on none now, under both versions. In self-bootstrap mode `--shape-ref a.lock/b` cloned the standard and ended in `error: pathspec 'a.lock/b' did not match any file(s) known to git`, exit 1. Now it is refused before `(0) self-bootstrap` prints.

**Tests** (`tests/test_setup_project_ref_parts.py`):

- `test_the_tool_and_git_give_one_answer[<name>]`: the issue's ten names and fourteen around them (`a.`, `a/b.`, `a./b`, `a.lockb`, `a/b.lock/`, `a/.lock`, `a..b`, `refs/tags/v1.0`, `v1.0.0`, and five from fix round 1). Each is fed to `checked_ref` and to `git check-ref-format --branch`, which must give one answer, and the tool's reason is pinned. No network. Fix round 1's five are `a/.b/c` and `a/b/.c/d`, a part that starts with `.` that is not the last part; and three names that break two rules, which hold the order `why_not_a_ref` asks them in (its docstring says the sentence names the first one the value failed): `a//..b` is a range, not an empty part; `a//.b` an empty part, not a dot part; `a.lock/b.` a lock part, not a `.` at the end. With `a/.lock` and `a/b.lock`, that holds each of the five pairs of neighbours in the order.
- `test_a_ref_git_refuses_is_refused_before_the_clone[a.lock/b|a/.b|a//b]`: a self-bootstrap run exits 2 with the reason, prints nothing on stdout and clones nothing.

Through `run9s.sh`, `SHARDS=3`, at `79a33f11`: the two setup files, every other test file that names `setup-project` (`git grep -l 'setup-project' -- tests`), the two new files and the trio, 25 files.

- git 2.43.0: 1606 passed, 3 skipped, 1 xfailed in 746.00s, rc 0 (shards 428 passed, 3 skipped, 1 xfailed; 546 passed; 632 passed)
- git 2.47.1: 1606 passed, 3 skipped, 1 xfailed in 677.22s, rc 0 (shards 428 passed, 3 skipped, 1 xfailed; 546 passed; 632 passed)

Before the commit, unsharded on git 2.43.0: `test_setup_project_py.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py` and the two new files, 377 passed. `test_setup_sh.py` and the trio, 235 passed.

Fix round 1, at `ac570069` (`setup-project.py` is unchanged, so every other file's result stands):

- git 2.43.0, `SHARDS=3`: `test_setup_project_py.py`, `test_setup_project_name_edges.py`, `test_setup_project_typed_name.py`, the two new files and the trio, 535 passed in 174.38s, rc 0.
- the two new files, 40 tests: 40 passed on git 2.43.0 (3.74s) and 40 passed on git 2.47.1 (2.64s).

The two new files, run against `050abea6`'s code: at `ac570069`, 40 tests, 28 failed and 12 passed (at `79a33f11`, 28 tests: 20 failed, 8 passed). All ten dry-run cases that make an offer fail: the three machines in both spellings, the three terminal runs and the signed-out run. Line 2's fifteen table rows that `050abea6` gets wrong fail: ten names git refuses that it passed, `a/.lock`, which it refused for the `.lock` and not for the part that starts with `.`, and fix round 1's `a/.b/c`, `a/b/.c/d`, `a//.b` and `a.lock/b.`. So do the three self-bootstrap cases. The 12 that pass are the three no-terminal cases (`050abea6` made no offer there either), and nine table rows: eight where `050abea6` already agreed with git (`a.lock`, `a/b.lock`, `topic.locked`, `a./b`, `a.lockb`, `a..b`, `refs/tags/v1.0`, `v1.0.0`) and `a//..b`, which it refused for its `..`, with the sentence the order gives. They hold the table to both directions, `topic.locked` passing among them.

23 mutants, each run against its tests, and all 23 caught:

- the gate in `offer` removed;
- `offering` not passed on in each of `offer_install`, `offer_missing`, `_check_git`, `_check_gh` (install, login), `_offer_login`, `_check_credential_helper`, and each of `preflight_checks`'s three calls;
- `preflight` always offering;
- `preflight_checks` silencing `--preflight -- --dry-run` too;
- the plan's new sentence removed;
- the `not offered` words changed;
- the help reverted (`tests/test_cli_reference.py`);
- `checked_ref`'s old two checks put back (`text.endswith(".lock")`);
- each of the four new parts of the rule dropped;
- `why_not_a_ref`'s answer ignored;
- the `.lock` part sentence changed.

The comment and docstring hunks have no mutant: they are prose. None survived.

An independent review then made 14 mutants of its own at `79a33f11`. Nine were caught. Five survived the tests as first pushed (the two new files and the setup tests): R1, the dry-run check put before `can_ask()` in `offer`; R3, the `..` and empty-part checks swapped; R4, `parts[-1].startswith(".")` only; R7, a literal `"--dry-run" not in opts.passthrough` in `preflight`; and R13, `parts[:2]` only. Fix round 1 closes each, rebuilt from the review's descriptions and run at `ac570069` against the two new files on git 2.43.0:

- R1 fails the no-terminal test on all three machines (3);
- R3 fails `a//..b` (1);
- R4 fails `a/.b/c` and `a/b/.c/d` (2);
- R7 fails the three `--dry` cases in process and the `--dry` terminal run (4);
- R13 fails `a/b/.c/d` (1).

Run against the two files as first pushed (`79a33f11`), the same five all survived, which is what the review found. Two more mutants for the new order rows are caught: `a//.b` fails with an empty part checked after a dot part, and `a.lock/b.` fails with a `.` at the end checked before a lock part. The other nine of the review's mutants were caught before and were not rerun.

`git merge-tree --write-tree` of `79a33f11`, and of `ac570069` for the first line:

- **#811's head `b6f56cad`: clean**, for both (`git merge-tree --write-tree b6f56ca ac57006`, tree `3e369754`). Fix round 1 changes only the two test files this PR added, so the other lines below stand for it: they were checked at `79a33f11` and not run again.
- **origin/main `d7befb91`: conflicts in `setup-project.py` and `tests/test_setup_project_typed_name.py`.** The conflict is inherited: `050abea6` alone conflicts with `d7befb91` in the same two files (#756 landed as a squash, and #811's branch held its history). A commit-tree prediction of `79a33f11` merged onto `b6f56cad` is clean against `d7befb91`.
- #859 `495d4b33` (`fix/838-github-name-limits`): clean. `setup-project.py` and `docs/cli.md` merge by themselves, with this head and with the predicted restack.
- #821 `e15c237b` (`fix/711-git-bash-dialect`): clean, `setup-project.py` merging by itself. The predicted restack conflicts with it only in `README.md`, `docs/handbook.html` and `shape-doctor.py`, where `d7befb91` itself conflicts with `e15c237b`. None of the three is this branch's.
- `fix/764` `05a1ebb3`, `fix/796` `2d51f181`, `fix/798` `70801c91` and `fix/803` `08f7a283` (`docs/cli.md`): clean.
- No other `fix/*` or `l3-*` branch touches these four files.
- #848's scan on main (`counts_set_to_a_number`) finds no count set in either new test file.

- `setup.sh`'s shim keeps its own copy of the old rule (`*..*|*.lock`), and its comment says it mirrors `setup-project.py`. Under `cat setup.sh | bash -s -- --shape-ref a.lock/b` the shim clones the standard and ends in git's `pathspec 'a.lock/b' did not match`, exit 1, before this file's check runs. Measured at this head, it is outside #857's lines and is reported to lane 5 as a checklist candidate.
- The plan's last line prints on every dry run, including where the preflight marked no offer `not offered`, where "each one marked `not offered` above" points at none. It is true as written. Printing it only where an offer was marked would take a count that `offer` writes and the plan reads, held in the module: `offer` is three to five calls below `preflight`, and each caller between returns a bool that the next one reads. That is state in a file whose `offer` takes its inputs as arguments and nothing else, for a sentence most dry runs would then not print, so it is left.
- No Windows or macOS run: CI is Linux only (#597). The in-process test runs on every platform; the pty tests are POSIX only.

Review: rv873, lane 5's independent review, READY at `79a33f11` with S1 (issuecomment-6245576863); f873's fix round 1 `ac570069` re-checked READY with no findings (issuecomment-6245715704). S2 is filed as #874. `0774ad60` restacks it onto #811's squash `bc362333` with three merges and the same `-U0` lines. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 0774ad6 (the restack of the branch onto main after #811 landed) passed tests, tests-git-bash and SonarCloud; the head e41a38a is a clean update-branch merge of main 85aa8a1, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree fa52066 over main 85aa8a1: the doctor_next_is_a_command, doctor_validator_rows, setup_project_dry_run_offers, setup_project_ref_parts, repo_hygiene, agents_md_codes, cli_reference sets, 320 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 #857

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