Skip to content

setup-project.py: a project name typed at the prompt is checked as --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) - #756

Merged
brettheap merged 4 commits into
mainfrom
fix/729-typed-project-name
Oct 18, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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

A project 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 before step (1) prints. This is #729, lane 4's L4-21 from #459.

Review: rv729 reviewed 9f429f40 and gave APPROVE WITH NITS (two SHOULDs). The fix round below takes them. The head is b3c77cc2.

What was wrong

  • Step (3) built the clone path from the typed name. Landing.clone_path joins it to --into with Path /, so .. leaves --into and an absolute name replaces it. Step (3) then asks the disk about that path, and since setup-project.py: step (3) passes an unwritable parent or a missing drive, and step (4) shows a chain the plan verifies as [declared-unverified] #608 refuse_a_parent_nobody_can_write_in makes and removes a probe folder in the nearest folder above it. It leaves nothing behind, but it was made outside --into.
  • Step (4) did not catch it. 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.
  • The refusals that did come were in the wrong words.
    • ../x, a/b and an absolute path ended at step (5) as scaffold-bad-id: --id ... is '../x', an id nobody passed.
    • --help made the validator print its help (it exited 0) at step (4), and the run ended at step (5) with the scaffold's usage, exit 2.
    • 256 characters ended at step (5) as unsafe-value: --project is ....
    • .. ended at step (4) as naming-unclassified, exit 1.
    • A control character was printed raw by [ok] project and [ok] legs.
  • An empty line said there was no terminal. It reached usage(sys.stderr) and then "no --project given, and no terminal to ask on."

What changed

setup-project.py has one hunk: ask_for_the_project_name(), new, just before resolve_project_and_elector, and its call in place of the bare ask(...). The helper makes two edits to what a typed line does:

  1. The typed name is checked. checked_value("project name", typed) runs right after the ask. It is 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.
  2. An empty typed line has its own refusal. "no project name was typed; nothing was created.", exit 2, no usage text. Ctrl-D at the prompt reads the same way, because ask returns an empty string for both. The can_ask() gate is unchanged and is still the refusal for no terminal.

Decisions and their evidence:

  • a b and Bad_Name go to step (4), as now. NAME_RE is permissive on purpose and the policy rules on those. The new test types both and checks exit 1 with (4) naming policy printed.
  • Nothing in step (3) changes, and the validator's basename rule stays as it is. The prompt check closes every row above, so scaffold-bad-id cannot be reached from a name step (4) accepts unless the person typed --id themselves.
  • No document changes. --project's help says "Prompted for when omitted and a terminal is attached", which is still true. README and the handbook say nothing about the prompt. render-cli-reference.py --check passes: [ok] docs/cli.md is what the generator produces.
  • No pinned file and no template changes. Only setup-project.py and one new test file.

Fix round

Review 9f429f40 raised two SHOULDs, and the nits below them. All are in b3c77cc2, one new commit.

  • SHOULD-1, SonarCloud python:S3776. resolve_project_and_elector was at cognitive complexity 16 against 15. The prompt, the empty-line refusal and the check moved into ask_for_the_project_name(), with their comments. rv729's estimator (cogcx.py) puts resolve_project_and_elector back at 14 and the helper at 1. Behaviour is unchanged.
  • SHOULD-2, the tests did not pin where the check sits. A check moved to just before check_names (rv729's M11), or to after resolve_family (M12), passed all 16 tests. Now:
    • assert_refused_at_the_prompt seeds a file at tmp_path/parent, where the folder above --into goes. A check made after step (3) has asked the disk would end in "is a file", so the test asserts project name is is on the screen and is a file is not.
    • It also asserts that [ok] project and [ok] legs are not printed, and that the seeded file is the only thing on the disk afterwards, unchanged.
    • M11 and M12 now each fail 12 tests (table below). Over the earlier test file they pass 16 of 16, run again here.
  • The probe-write sentence now says what the tests show. setup-project.py: step (3) passes an unwritable parent or a missing drive, and step (4) shows a chain the plan verifies as [declared-unverified] #608's probe folder removes itself, so no test can see it. What the tests show is that a refused name stops before [ok] project prints and before any later step asks the disk: the seeded file is never reported, and nothing else appears.
  • NIT-1: one hunk with two edits, as written above. NIT-2: the test docstring now says the validator exited 0 at step (4) and the run ended at step (5), exit 2. NIT-3: 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. NIT-5: (4) naming policy is one constant, NAMING_POLICY.

Tests

tests/test_setup_project_typed_name.py is new, 20 tests. It types each name at a pseudo-terminal through run_in_a_terminal, as tests/test_setup_project_py.py does for Sample, POSIX only (PTY_SKIP). It takes piped, run_entry, terminal_env, SETUP_PROJECT and SETUP_SH from tests/test_setup_project_py.py and adds no conftest helper. Each test is a one-call wrapper over one helper, with parametrize for the cases.

  • 10 names refused at the prompt (../x, a/b, .., ., -x, --help, a name with a control character typed with ^V, 256 characters, three spaces, an absolute path under tmp_path). For each: exit 2; the refusal holds project name is <ascii(value)>, which is not a value; no is a file, [ok] project, [ok] legs, (4) naming policy, usage text or --id on the screen; only the seeded file is on the disk.
  • Parity. Each of those 10 also runs the same value as --project with no terminal and holds the two refusals equal once REFUSED: --project is reads REFUSED: project name is.
  • Two more ways in (../x): the one-liner's shape, stdin a pipe and the answer read from the controlling terminal; and setup.sh, the shim.
  • Empty line and Ctrl-D, each through the file run directly, the piped shape and setup.sh (6 tests): the refusal is exactly REFUSED: no project name was typed; nothing was created., exit 2, with no "no terminal", no usage text and no is a file.
  • Bad_Name and a b: exit 1, step (4) printed, no project name is refusal.

Runs on the head b3c77cc2, through the gh-stub runner:

run result
tests/test_setup_project_typed_name.py 20 passed
tests/test_setup_project_py.py 230 passed
tests/test_setup_sh.py (89), the new file (20), tests/test_repo_hygiene.py (77), tests/test_cli_reference.py (22), run together 208 passed

Fails on the base

The new file over origin/main's setup-project.py (66b230ed, in a separate clone): 18 failed, 2 passed. The two that pass are Bad_Name and a b, which already exit 1 at step (4).

Reproduction, base against head

Each name typed at the prompt, with --local-remote-dir and the gh stub:

typed on 66b230ed on the head
../x step (4) and (5) print; REFUSED scaffold-bad-id: --id (not given, so --project lowercased) is '../x', exit 2 REFUSED: project name is '../x', ..., exit 2, nothing after (3) project
a/b the same scaffold-bad-id project name is 'a/b', exit 2
--help the validator's help and the scaffold's usage print, exit 2 project name is '--help', exit 2
256 x REFUSED unsafe-value: --project is ... at step (5), exit 2 project name is 'xxx...', exit 2
absolute path scaffold-bad-id with the lowercased path as the id, exit 2 project name is '/tmp/...', exit 2
a, BEL, b [ok] project prints the raw BEL; step (4) refuses ab, exit 1 project name is 'a\x07b', exit 2
.. step (4) naming-unclassified, exit 1 project name is '..', exit 2
empty line usage text, then "no --project given, and no terminal to ask on." "no project name was typed; nothing was created.", no usage

Mutation probes

Each mutant is applied to the head's setup-project.py in a separate clone and run over the new file (20 tests). Mutants 1 to 3 are the writer's; 4 to 12 are rv729's (mutate9.py, re-aimed at the helper's lines because the check now sits in it).

mutant failed passed
M1 no check (return typed) 12 8
M2 no empty-line refusal (the empty line then fails checked_value as project name is '') 6 14
M3 subject --project in place of project name 12 8
M4 check just before the [ok] lines 0 20
M5 partial check (a leading - or a / only) 5 15
M6 empty line exits 1 6 14
M7 the path pattern in place of the name pattern 9 11
M8 check typed.strip(), keep typed 1 19
M9 empty line prints usage first 6 14
M10 check skips control characters 1 19
M11 check just before check_names 12 8
M12 check after resolve_family 12 8

M11 and M12 passed all 16 tests of 9f429f40, and fail now. M4 passes, and rv729's own order test passes it too: it does no path work before the refusal, and its only difference is that outside --local-remote-dir a gh api user call is made first, which these runs never make.

Merge checks

git merge-tree --write-tree of the head b3c77cc2, against:

No other branch edits tests/test_setup_project_py.py or tests/test_setup_sh.py.

Left out

Not fixed here:

  • refuse_value's one sentence does not say why ../x, a/b or .. is refused. It explains the leading -, then "the rest must be spellable as a name, a ref or a path". A name must start with a letter or digit and hold none of /, \. This is shared by --project, the positional and now the prompt, so a better sentence is a change to every refusal and its tests.
  • A name of 101 to 254 characters passes the prompt. In local mode a 101-character name reaches the confirmation. The scaffold's plan refuses 255 (scaffold-name-too-long, because <Project>-spec is 260 bytes). I did not check what a real gh does with a name over GitHub's own repository-name cap.
  • The validator's basename rule (../x passes as x) stays as it is, as the issue directs.
  • A typed trailing space is treated unlike a leading one (rv729's candidate). Atlas passes the check and ends at step (4), exit 1, while Atlas is refused at once, exit 2. A prompt answer, unlike an argv value, is where stray spaces come from; stripping them would change the three-spaces test, so it is a design choice for v1.1.1.
  • The refusal's sentence names the leading - as the reason for every refusal (rv729's candidate, adding to the first item above): odd for Atlas, Atlas/ and Café. It is shared with --project.
  • A mistyped name ends the run with no re-ask (rv729's candidate; unchanged by this PR).
  • At 255 characters the run ends at step (5) after printing the family wall three times (rv729, reproduced; adds to the 101-254 item above).

Review. rv729 re-verified the fix round: APPROVE at b3c77cc2. The extraction is byte-identical, M11 and M12 now fail, the three test files give 339 passed, and SonarCloud analysed b3c77cc2 with S3776 gone (quality gate OK, 0 code smells).

Closes #729

🤖 Generated with Claude Code

Summary by Sourcery

Validate and clearly report project names entered at the interactive prompt before creating or inspecting project paths.

Bug Fixes:

  • Validate project names entered at the interactive prompt using the same checks as --project and positional arguments, preventing unsafe paths, options, control characters, and invalid values before filesystem work begins.
  • Report empty prompt responses as an explicit missing project name instead of treating them as a missing terminal or displaying usage output.

Enhancements:

  • Keep names accepted by the initial value check available for the existing naming-policy validation in step (4).

Tests:

  • Add pseudo-terminal coverage for invalid prompted names, empty and EOF responses, alternate entry points, refusal ordering, filesystem safety, and parity with --project validation.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-16T23:42Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 16, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR closes the interactive project-name validation gap by applying command-line checks immediately after prompting, adding a specific empty-input refusal, and introducing comprehensive PTY tests that verify consistent errors, safe early termination, and preserved step-(4) policy behavior.

Sequence diagram for interactive project name validation

sequenceDiagram
    actor User
    participant SetupProject
    participant checked_value
    participant NamingPolicy
    participant Filesystem

    User->>SetupProject: ask("project name")
    alt empty input
        SetupProject->>SetupProject: die("no project name was typed#59; nothing was created.")
    else non-empty input
        SetupProject->>checked_value: checked_value("project name", typed)
        alt unsafe project name
            checked_value-->>SetupProject: refusal
            SetupProject->>SetupProject: die(...)
        else accepted by value check
            checked_value-->>SetupProject: project name
            SetupProject->>NamingPolicy: validate at step (4)
            NamingPolicy-->>SetupProject: policy result
            SetupProject->>Filesystem: build project path at step (3)
        end
    end
Loading

File-Level Changes

Change Details Files
Validate project names entered interactively with the same value checks used for command-line input, and distinguish an empty response from the absence of a terminal.
  • Run checked_value("project name", typed) immediately after the prompt, before any path construction or later workflow steps.
  • Refuse empty input with a dedicated exit-2 message without printing usage; preserve the existing no-terminal refusal path.
  • Leave policy-level names such as a b and Bad_Name for step (4), where the naming policy reports them.
setup-project.py
Add pseudo-terminal coverage for validation parity, refusal behavior, and input paths.
  • Cover traversal, slash, dot, option-like, control-character, overlong, whitespace, absolute, and empty inputs, asserting wording, exit status, lack of usage or side effects, and parity with --project.
  • Verify accepted-by-value but policy-invalid names still reach step (4), plus stdin-pipe and setup.sh entry points.
  • Reuse existing PTY helpers and mark the test module POSIX-only.
tests/test_setup_project_typed_name.py

Assessment against linked issues

Issue Objective Addressed Explanation
#729 Validate a project name typed at the prompt with the same check used for --project or the positional argument, before it can be used to build or inspect a path. ✅
#729 Give an empty prompt answer its own exit-2 refusal without a usage dump or a misleading no-terminal message. ✅
#729 Leave names accepted by the shared value check, such as Bad_Name and a b, for the step (4) naming policy to decide. ✅

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

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 9f429f40b6

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

@chatgpt-codex-connector

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-16T23:44:12.497822Z 9f429f4 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.

… 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>
This was referenced Oct 17, 2026
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…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 added a commit that referenced this pull request Oct 17, 2026
…nd what they tell git to the `GIT_CONFIG_COUNT` entries the environment holds, so a pasted line keeps the session's maintenance settings and the Windows job's two (#758) (#818)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #758

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

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 18, 2026 02:44
@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 1 day and 22 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 4dea85e into main Oct 18, 2026
5 checks passed
@brettheap
brettheap deleted the fix/729-typed-project-name branch October 18, 2026 02:45
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 tests/test_setup_project_typed_name.py

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

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

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

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

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

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

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

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

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

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

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

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

### Tests

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

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

### Fails on the base

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

### Mutants

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

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

### Merge checks

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

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

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

### Checklist candidates

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

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

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

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

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

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

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

### SHOULD 3: the sentences say ASCII

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

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

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

### NITs 4 and 5

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

### Tests

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

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

### Fails on `b5def3f0`

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

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

The 55 that pass are the controls:

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

### Mutants

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

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

### Merge checks

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

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

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

### Out of scope

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

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

### Checklist candidate

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #777

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…asks GitHub's limits on a name, a topic and an organisation where GitHub is, so an id over 39 characters no longer fails `--add-topic` after the repositories exist (#838) (#859)

# Title

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

# Body

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

Nothing asked these limits before:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review: rv859, lane 5's independent review, answered by fix round 1 (issuecomment-6243078675); rv859b's re-review of fix round 1, READY at `71936633` (issuecomment-6244672717); and rv859b's re-check of the NIT round, READY at `495d4b33` (issuecomment-6245094878). CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 495d4b3 passed tests, tests-git-bash and SonarCloud; the head 9695935 is a clean update-branch merge of main e6cf5d9, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree c17128d over main e6cf5d9: the github_name_limits, scaffold_manifest_values, repo_hygiene, agents_md_codes, cli_reference sets, 317 passed. Landed on Brett Heap's word of 2026-10-17T21:47:28Z, in lane 5's session (posted on #473 as issuecomment-6241758069): "merge lane 5's v1.1.1 PRs once reviewed and green".

Closes #838

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 18, 2026
…light's offers and `--shape-ref` is held to git's rule part by part, so a rehearsal no longer writes git's global config and `a.lock/b` is refused before the clone (#857) (#873)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #857

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant