Skip to content

setup-project.py: a refused value names the check it failed, a project 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

Merged
brettheap merged 11 commits into
mainfrom
fix/777-typed-name-edges
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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

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.

Fix round 1: five tests waited on CI at a gh auth setup-git offer (b5def3f0)

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:

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.

Fix round 2: lane 2's review, SHOULDs 2 and 3 and NITs 4 and 5 (050abea6)

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

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:

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

What changed

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.

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

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.

Mutants

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.

Merge checks

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.

Left out

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

Checklist candidates

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

Closes #777

🤖 Generated with Claude Code

Summary by Sourcery

Improve setup-project.py validation so refused values explain their failed check, GitHub-backed project names respect repository-name limits, and interactively entered names handle surrounding spaces predictably.

New Features:

  • Reject project names longer than 95 characters before GitHub-backed runs reach the naming-policy step.
  • Trim leading and trailing spaces from project names entered interactively while preserving other whitespace and command-line values.
  • Report the specific validation rule that caused each refused value.

Bug Fixes:

  • Prevent interactive project names with stray spaces from being misclassified by the naming policy.
  • Ensure validation behavior is consistent across terminal and piped input, including environments without a Git credential helper.

Enhancements:

  • Clarify validation messages for project names, refs, control characters, revision ranges, and lock-file suffixes, including the applicable ASCII patterns.
  • Preserve the local-run length behavior by applying the GitHub repository-name limit only when remote repositories will be created.

Tests:

  • Add comprehensive edge-case coverage for refusal reasons, project-name length boundaries, interactive whitespace handling, ASCII validation descriptions, and ref suffix behavior.
  • Harden terminal tests against machine-specific Git credential-helper configuration and verify GitHub runs do not stall during preflight.

Brett Heap and others added 3 commits October 16, 2026 23:38
…-project` is and an empty line says no name was typed, so `../x`, `a/b`, `..` and `--help` are refused by name, not by an `--id` hint, a help page or a probe write (#729)

The name typed at the `project name` prompt was used as typed. The same
value as `--project` or as the positional goes through `checked_value` at
parse time and is refused there. Typed, it reached step (3), which builds
the clone path from it and asks the disk about that path: `..` leaves
`--into`, an absolute name replaces it, and #608's write probe is made in
the nearest folder above it. Step (4) did not catch it either: the naming
validator reads only the last part of what it is handed (`../x` passes as
`x`), has no length bound and reads a leading `-` as its own option, so
`--help` printed its help and exited 0. The scaffold's plan then refused
`../x` as `scaffold-bad-id: --id '../x'`, an id nobody passed, and `-h`
with a usage dump.

Now `checked_value("project name", typed)` runs right after the `ask`, the
check `take_positional` makes, so the refusal is the tool's own wording
with `project name` as its subject, exit 2, before anything reads or writes
a path. `a b` and `Bad_Name` still go to step (4): `NAME_RE` is permissive
on purpose and the policy rules on those. Nothing in step (3) changes, and
the validator's basename rule stays as it is.

An empty typed line (and Ctrl-D) now says "no project name was typed;
nothing was created.", exit 2, with no usage text. It said "no --project
given, and no terminal to ask on." after the full usage text, which is the
refusal for no terminal, and `can_ask()` still gives that one.

tests/test_setup_project_typed_name.py types each name at a pseudo-terminal
(POSIX only, as the other terminal tests are).

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… and check are one helper, and the tests pin where the check sits, so a check made after step (3) has asked the disk fails them (#729)

Review round on 9f429f4 (rv729: approve with nits, two SHOULDs).

SHOULD-1. SonarCloud reported python:S3776 on resolve_project_and_elector
(cognitive complexity 16 of 15). The prompt, the empty-line refusal and the
`checked_value("project name", typed)` call move into
`ask_for_the_project_name()`, with their comments; the call site is
`opts.project = ask_for_the_project_name()`. No behaviour changes. The
function is back to 14 by rv729's estimator, and the helper is 1.

SHOULD-2. The tests did not pin where the check sits: a check moved to just
before `check_names`, or to after `resolve_family`, passed all 16 of them.
Now every refused name is typed with a file standing where the folder above
`--into` goes, so a check made after step (3) asks the disk would end in "is
a file" and not "project name is"; and `[ok] project` and `[ok] legs` must
not print. Only the seeded file may be on the disk afterwards, unchanged.
Both moved checks now fail 12 tests each.

Nits. The empty line and Ctrl-D are also typed through the one-liner's shape
(stdin a pipe) and through `setup.sh`, 6 tests in place of 2. `(4) naming
policy` is one constant. The module docstring no longer says `--help`
"exited 0" of the run: the naming validator exited 0 at step (4), and the
run ended at step (5) with exit 2.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t name over 95 characters is refused before step (4) where GitHub will hold the repositories, and the prompt takes the spaces off a typed name, so `../x` is no longer told about a leading `-`, a 100-character name no longer ends at step (5) or at GitHub, and `Atlas ` no longer hides in step (4) (#777)

`refuse_value` gave every refusal the one closing sentence, and the sentence
explained only a leading `-`: `../x`, `a/b`, ` Atlas`, `Atlas/` and a name with
an accented letter were told about a `-` none of them has. It now takes the
reason, and `why_refused` names the first check the value failed, in the order
empty, leading `-` (which keeps its "git reads its own arguments" sentence),
control character, then the pattern, said in words and as the pattern, as
`repo_shape.checked_value` prints its own. `checked_ref` says `..` and `.lock`
in their own sentences and `checked_passthrough` its control character.
`repo_shape.checked_value` is not changed: it is a pinned copy, so a wording
change there is a re-pin, and it already prints its pattern.

A project name over 95 characters is refused before step (1) for `--project`
and the positional, and before step (3) prints for the prompt, unless
`--local-remote-dir` is given. GitHub keeps a repository name to at most 100
characters (its documentation: nothing here creates a real repository, so the
number is not measured) and `-spec` and `-code` add five. Under
`--local-remote-dir` the limit is the disk's and step (5) still asks it as
`scaffold-name-too-long`. The check runs at the end of the parse, because
`--local-remote-dir` can come after `--project` on the line.

The prompt takes the spaces off the ends of what was typed. `Atlas ` passed
`NAME_RE` where ` Atlas` was refused, and step (4) judged `Atlas -spec` with no
space shown anywhere. A line of spaces is now an empty line. An argv value is
deliberate and is held as typed.

tests/test_setup_project_name_edges.py is new. #756's file moves its
three-spaces case from the refusals to the empty answers, and a comment in
tests/test_setup_project_py.py stops saying the two entry points refuse in the
same words.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-17T11:03Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 17, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR refines setup-project.py's validation flow: refusals now explain the specific failed check, non-local project names longer than 95 characters are rejected before step (4), and interactive names are trimmed while command-line values remain unchanged. New edge-case and regression tests cover validation precedence, platform-independent argument paths, GitHub/local length boundaries, and prompt behavior.

Flow diagram for project name validation paths

flowchart TD
    A[Receive project name] --> B{Source}
    B -->|Command line| C[checked_value]
    B -->|Interactive prompt| D[ask_for_the_project_name]
    D --> E[strip spaces]
    E --> C
    C --> F{Validation passes}
    F -->|No| G[why_refused]
    G --> H[refuse_value]
    H --> I[Exit 2]
    F -->|Yes| J{Local mode}
    J -->|Yes| K[Continue to step 4]
    J -->|No| L[checked_project_length]
    L --> M{At most 95 characters}
    M -->|Yes| K
    M -->|No| N[Refuse scaffold-name-too-long]
    N --> I
Loading

File-Level Changes

Change Details Files
Make validation refusals identify the first failed check and provide check-specific guidance.
  • Added ordered reason selection for empty, dash-prefixed, control-character, pattern, range, and lock-file failures.
  • Updated callers and documentation while preserving the pinned repository-shape validator and ASCII-safe quoting.
  • Added exhaustive tests for refusal carriers, precedence, wording, and pattern drift.
setup-project.py
tests/test_setup_project_name_edges.py
tests/test_setup_project_py.py
Reject overlong non-local project names before naming-policy evaluation.
  • Added a 95-character project limit derived from GitHub's documented 100-character repository limit and the five-character suffixes.
  • Applied the check to flags, positional arguments, and prompts, including options parsed after the project name, while exempting local mode.
  • Added boundary tests covering 95 through disk-limit behavior and ensuring no earlier output or filesystem changes.
setup-project.py
tests/test_setup_project_name_edges.py
Normalize whitespace entered through the interactive project-name prompt without changing argv semantics.
  • Strip leading and trailing whitespace before validation and apply the project-length check to the normalized value.
  • Treat whitespace-only answers as an empty response while retaining explicitly supplied argv values verbatim.
  • Updated existing typed-name expectations and added PTY coverage for whitespace normalization and resulting project paths.
setup-project.py
tests/test_setup_project_name_edges.py
tests/test_setup_project_typed_name.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T11:05:02.876578Z 854f6ed Manual request
ℹ️ About Codex in GitHub

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

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

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 854f6ed95c

ℹ️ 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 setup-project.py Outdated
as typed. A line of nothing but spaces is then an empty line.
"""
typed = ask(" project name (ONE CamelCase token, e.g. Atlas): ")
typed = ask(" project name (ONE CamelCase token, e.g. Atlas): ").strip()

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 Strip only ordinary spaces from prompted names

When a prompted name starts or ends with a tab, non-breaking space, or another Unicode whitespace character, Python's unrestricted str.strip() removes it before checked_value runs, so input that CONTROL_RE or NAME_RE deliberately rejects is silently converted to a valid name such as Atlas and scaffolding proceeds. This makes prompt validation differ from --project and can conceal pasted invalid characters; restrict trimming to the intended ASCII space character.

Useful? React with 👍 / 👎.

…dential helper for github.com, and one run empties git's own config, so a GitHub run no longer waits on CI at a `gh auth setup-git` offer nothing answers (#777)

On CI (run 39027235147, git 2.55.0) five of this file's tests were killed
after 60 seconds: the four over-the-bound names typed at the prompt
(96, 100, 101, 255) and the 95-character name typed in a GitHub run. Each
screen 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:

A GitHub run (no `--local-remote-dir`) makes that offer in its preflight
when git has no credential helper for github.com, before step (3) asks for
the project. The tests type only at the project prompt, so nothing
answered the offer, and the run waited. The bound played no part: the same
names in local runs passed, and the 95-character GitHub run hung too. On
the writer's machine /etc/gitconfig and ~/.gitconfig both name a helper,
so the offer never came there. CI's runner names none. With git's system
file skipped and its global file empty, the same five hang on that machine
with CI's screen.

The tool is right: the offer is `_check_credential_helper`'s documented
warning and offer. The tests depended on the machine's git config.

- `gh_that_is_signed_in` also gives git a credential helper for github.com,
  in git's command scope (`GIT_CONFIG_COUNT`, appended to what the
  environment holds): `!<the stub> auth git-credential`, the line
  `gh auth setup-git` writes, pointed at the stub. The preflight 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, as
  CI's runner has them. A run that waits at the offer now fails on every
  machine, not on CI alone.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T16:18:08Z) Independent review of #811 (#777) at 854f6ed9, for lane openRepoShape-5: NOT READY

Findings, most severe first

  1. MUST: CI is red because five new terminal tests depend on the machine's git credential helper. This is a test defect; setup-project.py behaves as designed.

    • Where: tests/test_setup_project_name_edges.py:143-158 (gh_that_is_signed_in) and :170-180 (at_the_prompt), used in GitHub mode (local=False) at :289-297 and :300-306.
    • What happens: without --local-remote-dir, the stub answers gh auth with 0. The preflight then runs _check_credential_helper (setup-project.py:1144-1175), which reads the real git config. With no helper for github.com it asks Run `gh auth setup-git` now? Type yes to continue: before step (3). The test types only once the project prompt shows, so the run waits at the offer until the 60 s timeout.
    • Evidence:
      • All five CI screens (run 39027235147) end at that offer, right after [ok] gh is authenticated as tester. (3) project never prints, and nothing was typed.
      • This host has credential.helper = !/usr/bin/gh auth git-credential for github.com, so here the five pass in 0.16 s to 0.73 s each.
      • With GIT_CONFIG_GLOBAL=<empty file> and GIT_CONFIG_NOSYSTEM=1, the same five hang here at the same offer: 5 failed, 60.0 s each.
      • With that hidden config plus GIT_CONFIG_COUNT=1, GIT_CONFIG_KEY_0=credential.https://githubproxy.fjygbaifeng.eu.org.helper and GIT_CONFIG_VALUE_0=!gh auth git-credential, they pass: 5 passed, slowest 1.33 s.
    • Not the other suspects (terminal width, readline, TERM, the 255-byte canonical line):
      • The run stops before anything is typed.
      • The local-mode prompt tests of the same lengths (95, 96, and the 256-character long case) passed in the same CI run (5 failed, 11487 passed).
    • A person does not hit this. At a terminal with no helper, they get the offer, answer it, then get the project prompt, and the bound applies the same way.
    • What the fix round must do:
      • Make the GitHub-mode runs independent of the host. Either:
        • have gh_that_is_signed_in also return a github.com helper through GIT_CONFIG_COUNT/_KEY_0/_VALUE_0, as tests/test_setup_project_py.py:1199 and tests/conftest.py:1409 already do for other keys; or
        • point GIT_CONFIG_GLOBAL at a file under tmp_path that holds the helper, and set GIT_CONFIG_NOSYSTEM=1.
      • Do not answer the offer with a second prompt. On a host that has a helper the offer never appears, so the test would wait there instead.
      • Prove the fix with the git config hidden (GIT_CONFIG_GLOBAL=<empty file> GIT_CONFIG_NOSYSTEM=1), not only on a machine that has a helper.
  2. SHOULD: the prompt strips every whitespace character, not just the spaces its docs name. A typed tab, \x1f or NBSP is accepted where --project refuses it.

  3. SHOULD: the name sentence says "letters" and "spaces", but the pattern takes only ASCII letters and U+0020, so the words are wrong for the accented name the issue lists.

    • Where: setup-project.py:386-392 (WHY_PATTERN).
    • For Caf\xe9, Café or Atlas\xa0, the words "a letter or a digit first, then letters, digits, spaces and ..." describe a value that would pass. Only the pattern in backticks shows why it fails.
    • The drift guards (tests/test_setup_project_name_edges.py:355-381) only walk string.printable, so they cannot catch this.
    • The issue's own suggested wording has the same gap.
    • Fix: "an ASCII letter or a digit first, then ASCII letters, digits, spaces and ..." (or "A to Z"). Make the same change in the ref sentence and in the test's NAME and REF.
  4. NIT: .lock anywhere in a ref passes the tests.

    • Where: setup-project.py:485. The mutant if ".lock" in text: survives (100 passed: the new file plus test_setup_project_py.py -k ref).
    • The new sentence says "ends in .lock", but no test shows topic.locked going through. One control case would pin it.
  5. NIT: a list comprehension that only copies.

    • Where: tests/test_setup_project_name_edges.py:228, flags = [f for f in flags_for(tmp_path, False)] (ruff C416).
    • flags_for already returns a new list, so this can be flags = flags_for(tmp_path, False).

Checked and true

Could not verify

Since this review (lane 2's coordinator):

  • The branch has moved to b5def3f0 (16:12:40Z), one commit on 854f6ed9. Its message says it answers MUST 1 the first way above: a helper in git's command scope, plus one run that empties git's own config.
  • rv811 re-verifies the delta once you post the head that also answers SHOULDs 2 and 3, or say they stay.

Lane: openreposhape-2 (openRepoShape-2)

…nd the name and ref sentences say ASCII, so a typed tab, form feed or no-break space is refused as `--project` refuses it, and `Caf\xe9` is no longer described as spellable (#777)

Lane 2's review of #811 (rv811, at 854f6ed), SHOULDs 2 and 3 and NITs 4
and 5. Its MUST 1 is b5def3f.

- The prompt's `.strip()` took off every kind of whitespace, not only the
  spaces its docstring names: a tab, VT, FF, `\x1c` to `\x1f`, NEL, NBSP and
  U+3000. Typed, each gave `[ok] project      Atlas` and exit 0, where
  `--project` refuses the same value as a control character or by the
  name pattern, and where #756's head refused it at the prompt too. It is
  now `.strip(" ")`. A typed line holds no newline here (`ask` drops it),
  so nothing else needs taking off. The test file's typed-refusal test
  takes the nine values, with stdin the terminal and with stdin a pipe,
  and the argv refusals take them as `--project`. The same reason is
  given on every way in. Each control character is typed after ^V, so
  the terminal takes it as text (`\x1c` alone is the quit key).
- The name sentence said "letters" and "spaces", but `NAME_RE` takes only
  ASCII letters and U+0020, so its words described `Caf\xe9` and a no-break
  space as values that pass. Both sentences now say "an ASCII letter or
  digit first, then ASCII letters, digits, the space and ..." (the ref
  sentence without the space), as `adopt-project.py`'s help says "ASCII
  letters, digits and ...". The drift guards also walk every character
  above ASCII and find none taken. README, the handbook, AGENTS.md and
  docs/cli.md do not state either sentence (`render-cli-reference.py
  --check` is ok).
- A ref is refused for `.lock` only where it ends in it: a new test passes
  `topic.locked` and `a.lockb` through `checked_ref`.
- `a_run_for`'s list comprehension that only copied is gone.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-17T17:11:13Z) Re-verification of #811 (#777) at 050abea6, for lane openRepoShape-5: READY

The delta from 854f6ed9 is two commits, b5def3f0 and 050abea6, with no merge of main. It touches only setup-project.py and tests/test_setup_project_name_edges.py.

The five findings from the review at 854f6ed9

  1. MUST 1, the CI hang: FIXED (b5def3f0). The GitHub-mode typed runs depended on the machine's git credential helper.
    • The fix: gh_that_is_signed_in (tests/test_setup_project_name_edges.py:164) now gives git a github.com helper that points at the stub. It sits in git's command scope, at index GIT_CONFIG_COUNT, after any entries already set.
    • With git's own config hidden (GIT_CONFIG_GLOBAL=<empty file>, GIT_CONFIG_NOSYSTEM=1, as on the CI runner), the whole new file passes: 97 passed in 14.7 s. The five tests that hung on CI take 0.09 s to 0.51 s each.
    • With an unrelated GIT_CONFIG_COUNT=1 entry already in the environment, the five plus the new test still pass: 8 passed. So the append is right.
    • The new test is test_a_github_run_here_is_not_held_by_a_git_with_no_helper[95|96] (:359). It empties git's system and global files itself, and passes.
    • It catches a regression on any machine. With the helper taken out of gh_that_is_signed_in (mutant MJ), both cases fail after 60 s each, on this machine, which has a helper of its own.
  2. SHOULD 2, the prompt stripping every kind of whitespace: FIXED (050abea6).
    • The prompt now strips only spaces: typed = typed.strip(" ") at setup-project.py:1601.
    • Probed typed both ways (stdin the terminal, and stdin a pipe): leading and trailing tab, VT, FF, \x1c, leading and trailing \x1f, leading and trailing NBSP, NEL and U+3000.
      • Each is refused with exit 2 and the same reason --project gives.
      • Control characters get the control sentence; NBSP, NEL and U+3000 get the name pattern.
    • Spaces still come off: typed Atlas and Atlas become Atlas, and a typed line of spaces is no name.
    • Pinned: the 9 cases are in NOT_THE_SPACE (:76), in the refusals and in the typed cases on both entries. Mutant MA2 (.strip() back) is now caught: 18 failed.
  3. SHOULD 3, the name and ref sentences saying "letters" for ASCII-only patterns: FIXED.
    • The new words are: "an ASCII letter or digit first, then ASCII letters, digits, the space and ._'()&,+@-", and for a ref "... then ASCII letters, digits and ._/-".
    • I walked every code point (U+0000 to U+10FFFF) through NAME_RE and REF_RE:
      • First character: exactly the 62 ASCII letters and digits.
      • After a letter: besides ASCII letters and digits, exactly &'()+,-.@_ for a name and -./_ for a ref.
      • So both sentences are true for every value.
    • The drift guards now walk everything above ASCII (above_ascii_taken, :422). Mutant MO, a name pattern that takes Latin-1 letters, is caught (4 failed).
  4. NIT 4, .lock anywhere passing the tests: FIXED.
    • test_a_ref_is_refused_for_lock_only_at_its_end (:463) checks that topic.locked and a.lockb go through checked_ref.
    • Mutant MB (".lock" in text) now dies.
    • git check-ref-format --branch takes both names and refuses topic.lock, as the test's docstring says.
  5. NIT 5, the copy-only comprehension: FIXED. Line 276 is now flags = flags_for(tmp_path, False).

Checked and true

Read by lane 2's coordinator from GitHub:

Could not verify

Lane: openreposhape-2 (openRepoShape-2)

brettheap added a commit that referenced this pull request Oct 18, 2026
…appended_to_command_scope`, the scan names a count worked out by hand, and `paste()` gives a pasted `submodule update` the `insteadOf` rewrite (#835) (#848)

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

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

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

### What each finding got

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

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

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

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

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

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

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

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

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

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

### Known limits (add to the body)

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

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

This replaces a separate issue for them.

### Tests, through `run9.sh`

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review: rv848, lane 5's independent review, READY (issuecomment-6240157020); lane 2's independent review, READY (issuecomment-6240228302); rv848's re-verification of the fix round 79b2eac, READY (issuecomment-6241026517). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 73b6d2b (the restack of the branch onto main after #818 landed) passed tests, tests-git-bash and SonarCloud; the head 5b27685 is a clean update-branch merge of main ca47b71, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4dbb4a9 over main ca47b71: the adopt_reencoded_files, clone_just_pushed, command_scopes_built_at_the_test, doctor_legs_members_next, doctor_paste_through_git, family_add_spellings_remedies, family_bootstrap_gate_order, family_config_appends, family_head_lost_gitmodules, family_init_asks_every_value, no_auto_maintenance, repo_hygiene, agents_md_codes, cli_reference sets, 645 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #835

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brett Heap and others added 3 commits October 18, 2026 02:46
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… for: setup-project.py tests/test_setup_project_typed_name.py

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-18T02:56:53Z) #811's second-lander conversion after #848: f1dbbbf5..b6f56cad.

#848 landed first, and its second-lander note gives this conversion to whichever of the two lands second. One commit, b6f56cad, on f1dbbbf5, by explicit pathspec, fast-forward pushed. It touches tests/test_setup_project_name_edges.py alone (12 insertions, 12 deletions). Tests only: nothing a user runs changes, and no pinned file, README, handbook, AGENTS.md or docs/cli.md line is touched.

The restack. The branch sits on #756's squash 4dea85ee, by the merges 6400cf4f (#756's final head 946b1354) and f1dbbbf5 (the squash, taking the branch's side in setup-project.py and tests/test_setup_project_typed_name.py). The -U0 lines are unchanged: git diff -U0 4dea85ee f1dbbbf5 has the same + and - lines, in the same 19 hunks, as git diff -U0 b3c77cc2 050abea6 had before the restack. Only the start line of 15 hunks moved.

The conversion. gh_that_is_signed_in built GIT_CONFIG_COUNT, GIT_CONFIG_KEY_{held} and GIT_CONFIG_VALUE_{held} by hand from the environment's own count, for credential.https://githubproxy.fjygbaifeng.eu.org.helper. At f1dbbbf5 #848's scan, test_no_test_sets_a_git_config_count_of_its_own, failed on it: {'test_setup_project_name_edges.py': [190]}. The three entries now come from appended_to_command_scope(os.environ, [("credential.https://githubproxy.fjygbaifeng.eu.org.helper", helper)]) (helper is "!%s auth git-credential" % shlex.quote(str(gh))), merged into the returned dict beside PATH, in #848's form. The file imports the helper in its sorted place in the from conftest import line. The docstring says the entry is appended to what the environment holds, and now names the helper that does it. os and shlex are still used. git grep -n GIT_CONFIG_COUNT -- tests/test_setup_project_name_edges.py finds only that docstring line.

Behaviour is the same. The function at f1dbbbf5 and the function at b6f56cad were run side by side, in one stub directory, and their returned dicts compared:

environment held GIT_CONFIG_COUNT entry written the two dicts
two entries (GIT_CONFIG_COUNT=2) 3 GIT_CONFIG_KEY_2, GIT_CONFIG_VALUE_2 equal, same key order
none 1 GIT_CONFIG_KEY_0, GIT_CONFIG_VALUE_0 equal, same key order
an empty count 1 GIT_CONFIG_KEY_0, GIT_CONFIG_VALUE_0 equal, same key order

The key is credential.https://githubproxy.fjygbaifeng.eu.org.helper and the value is !<stub gh> auth git-credential in all three. PATH is equal too.

The runs, at b6f56cad, through the gh stub, with the git identity blanked as the lane's runner does:

run passed time
test_setup_project_name_edges.py 97, test_setup_project_typed_name.py 22, test_setup_project_py.py 230 349 246.60 s
#848's scan and its siblings: test_no_auto_maintenance.py 60, test_command_scopes_built_at_the_test.py 2, test_family_config_appends.py 8 70 10.74 s
test_repo_hygiene.py 78, test_agents_md_codes.py 47, test_cli_reference.py 23 148 40.02 s

No test failed or was skipped. The scan, which fails at f1dbbbf5, passes.

Lane: openreposhape-5 (openRepoShape-5)

@sonarqubecloud

Copy link
Copy Markdown

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

sourcery-ai Bot commented Oct 18, 2026

Copy link
Copy Markdown

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

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

@brettheap
brettheap merged commit bc36233 into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/777-typed-name-edges branch October 18, 2026 07:50
brettheap pushed a commit that referenced this pull request Oct 18, 2026
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
… for: setup-project.py

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 18, 2026
… RV883 and run to main c5359df, and the back-port comment names ruleset 25325536 (#883)

Round 2 of #883, on lane 3's review RV883 of ede678c (#883
issuecomment-6247451611). Each finding was checked against the code at
main c5359df before the text changed, and each held:

- `plan written to` is printed after a blank line and before the report,
  not first: an org/repo source prints its `cloning` line before it.
- The pipx command is unchanged since v1.1.0 (`src/` is). Measured on the
  package at c5359df, outside a checkout: its `--version` prints a
  `$OPENREPOSHAPE_REPO` holding U+202E, exit 0, and its refusal of such a
  `$OPENREPOSHAPE_REF` holds the character. The notes say "the bash
  `openRepoShape`" and carry this as a known limit.
- `family.py init`'s closing block and `Working trees are at` still print
  a path raw; `make siblings` has no unmerged check, and clones from
  `family.yaml` where git finds no url; `update-shape.py apply --branch`
  meets `unseen-branch` after its writes; a WHY still shows `?` and
  `--json` is ASCII with escapes; a directory in no repository is still
  `git-failed`. Each is now said as the code does it.
- The refresh adds #843 (#803), #811 (#777) and #794 (#793): 50 pull
  requests and 50 issues, with 20 milestone issues still open.
- The release sections name Brett Heap's ruling, option A and ruleset
  25325536, add a step 0, define `<notes>` with a grep a simulation shows
  prints nothing, and check `gh release list` before `--latest`.

release.yml: lines 74-76 of the RELEASING comment said the ruleset waited
on Brett Heap's word. They now name ruleset 25325536. Only comment lines
change; the YAML is identical with comments removed.

The upgrade check was run again at c5359df on fresh clones of a v1.1.0
project and holder: six `upstream-changed` copies each, `apply` green,
and the doctor COMPLIANT.

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