Skip to content

scaffold-project.py: the --reuse-empty-repo help, its docs and each remedy that sends a person into that re-run say ssh, a credential helper or askpass can still make it wait, and how to keep it from waiting (#915) - #1101

Merged
brettheap merged 2 commits into
mainfrom
l3-915-prompt-words
Oct 21, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

U037 is the last item of #915. A --reuse-empty-repo re-run turns off git's own terminal prompt and no other prompt, so ssh, a credential helper or an askpass program can still make it wait. This PR says so in the places where it is true, and says how a person keeps a run from waiting. It changes nothing that a run does.

The ruling

Brett Heap, 2026-10-21T10:39:44Z: "U039 and U037: take your recommendations". For U037, lane 3 recommended leaving the operator's own ssh, credential-helper and askpass setup alone, and saying where a prompt can still come.

Why the environment stays the operator's:

  • Credential helpers and askpass programs are how people give git a login. Turning them off would refuse reads and pushes that work today.
  • ssh's BatchMode is not forced through GIT_SSH_COMMAND. git's GIT_SSH_COMMAND overrides a configured core.sshCommand, so forcing it would replace the operator's own ssh command (a key given by -i, a proxy, a port). This was measured below: with both set, only GIT_SSH_COMMAND ran.

Measured before writing, at main 2a119f2

Each case ran scaffold-project.py --reuse-empty-repo in its GitHub mode against the tests' fake gh, with the stub gh behind it, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL set to a scratch file and no ssh agent. Each ran in a pseudo-terminal (the tests' run_in_a_terminal) and was killed after 20 s. git read https://github.com/testorg/ through an insteadOf rule, either from fakehost.invalid: or from a local HTTP server that answers 401. No real host was reached. The real ssh reached a fake server on stdio through a ProxyCommand, and that server does just enough key exchange to reach the client's host-key check.

case git reaches the leg through result
t0 probe http 401, no helper, no askpass refused at once (1.6 s): could not read Username for '...': terminal prompts disabled
t1 probe real OpenSSH 9.6p1, unknown host key waited at Are you sure you want to continue connecting (yes/no/[fingerprint])? until killed
t1 push (all three empty) same waited at the same prompt until killed
t1 probe fake GIT_SSH_COMMAND that asks the tty for a passphrase waited at Enter passphrase for key ... until killed
t1 push same waited until killed
t2 probe a credential helper that asks the tty waited until killed
t2 push same waited until killed
t3 probe GIT_ASKPASS waited at Fake askpass: Username for 'http://127.0.0.1:...' until killed
t4 probe SSH_ASKPASS, with no GIT_ASKPASS waited until killed
t5 probe core.askPass waited until killed
t6 probe real ssh with GIT_SSH_COMMAND='ssh -o BatchMode=yes ...' refused in 1.3 s: scaffold-remote-exists, reason Host key verification failed.
t6 push same refused in 3.2 s
override core.sshCommand and GIT_SSH_COMMAND both set only GIT_SSH_COMMAND ran

git passed ssh no BatchMode. The argv was -o SendEnv=GIT_PROTOCOL fakehost.invalid git-upload-pack 'testorg/Atlas-spec.git'. Lane 2's probes t1 to t5 at fdb0fa5 (L2-21) were the earlier evidence.

What changed

  • scaffold-project.py. Words only, plus local passed to _exists_remedy so the words appear only where a prompt can follow:
    • The comment over REMOTE_ENV now records the ruling, and why the environment stays the operator's. It replaces the open question the comment held before.
    • PROMPTS_STILL_ASK is one sentence. Each remedy whose exit is the --reuse-empty-repo re-run now ends with it: _exists_remedy's for a leg git could not read, for an empty leg or one holding the seed, and for an empty root, and _login_remedy's. The sentence reads: "That re-run turns off git's own terminal prompt and no other, so it can still wait at an ssh passphrase or host-key prompt, at a credential helper that prompts, or at GIT_ASKPASS, SSH_ASKPASS or core.askPass: to keep it from waiting, load your key into an ssh agent, use a helper that answers without prompting, or set GIT_SSH_COMMAND='ssh -o BatchMode=yes' (with anything your core.sshCommand adds) in your shell for the run, which makes ssh fail at once instead."
    • The --reuse-empty-repo help says the same in the third person, of a run on GitHub.
  • docs/cli.md is regenerated by scripts/render-cli-reference.py. Only the scaffold's --reuse-empty-repo entry moved.
  • README.md and docs/handbook.html say it where the re-run is described, in "Starting a project in a new organisation" and the handbook's matching list. The handbook's seven markers for that README section now carry its new blob, ffaedeefe617.
  • tests/readme-cap.d/915.txt records the seven README lines. The README was at its cap of 1482.
  • tests/test_scaffold_reuse_seeded_legs.py:
    • STILL_ASKS, a literal, is now part of UNREAD_EXIT, RE_RUN_WITH_THE_FLAG and RE_RUN_AS_IT_WAS.
    • The new test test_each_re_run_remedy_says_the_prompts_it_can_still_wait_at covers six remedies, in-process. Each remedy whose exit is the re-run ends with the sentence on GitHub. The remedy for a leg with other commits and the one for a root with commits do not, and no remedy carries it under --local-remote-dir.
    • The local empty-leg refusal now asserts the sentence is absent.

Decisions the Direction left open:

  • Which refusals say it. Only the remedies whose exit is the re-run. A remedy that sends a person to delete a leg or to adopt a root does not. Under --local-remote-dir, git reads paths on disk with no ssh, helper or askpass, so no prompt can follow there.
  • Where in a refused push. Only the login remedy says it. A push refused for any other reason (a ruleset's refusal, an HTTP 403) still prints exactly what it always printed, which test_what_a_refused_push_prints pins.
  • The GIT_SSH_COMMAND advice. It says "with anything your core.sshCommand adds", because setting it replaces that setting for the run.
  • setup-project.py. Unchanged. Its help never names --reuse-empty-repo: it passes anything after -- to the scaffold, and the scaffold's remedies already say "(after -- through setup.sh)".
  • AGENTS.md. It is at its line cap, so no line was added there.

Left out of scope

Tests

Each file was run alone with /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-<name> -o tmp_path_retention_policy=failed tests/<file>, wrapped in timeout 2400. Each run had the stub gh (exit 97) first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null and EMAIL=tests@openreposhape.invalid.

file result
tests/test_scaffold_reuse_seeded_legs.py 90 passed in 74.88s
tests/test_cli_reference.py 23 passed in 33.94s
tests/test_readme_sections.py 76 passed in 0.54s
tests/test_agents_md_codes.py 47 passed in 2.23s
tests/test_update_add_codes_documented.py 6 passed in 0.56s
tests/test_repo_hygiene.py 100 passed in 2.83s
tests/test_no_auto_maintenance.py 58 passed, 2 skipped in 15.20s
tests/test_readme_windows_quoting_sentence.py 44 passed in 19.27s
tests/test_dry_run_asks_remotes.py 32 passed in 12.92s

The two skips in test_no_auto_maintenance.py both come from line 222: "the session began with GIT_CONFIG_GLOBAL set, and its file includes that one". That is this harness's GIT_CONFIG_GLOBAL=/dev/null. CI runs those two.

pyflakes reported no new warning. scaffold-project.py has 0 before and after. The test file has the same 14 before and after, all present at main.

Mutants

Each mutant broke the change, ran the named test, and put the file back byte for byte.

mutant killed by
M1: drop the new help sentence test_cli_reference.py::test_every_python_tools_help_is_the_text_the_file_carries and ::test_the_committed_reference_is_what_the_generator_produces (2 failed)
M2: _exists_remedy says nothing (still = "") test_each_re_run_remedy_says_the_prompts_it_can_still_wait_at (4 ids) and test_a_leg_git_cannot_read_is_refused_as_existing_not_as_a_failure (2 ids)
M3: _reusable_remotes does not pass local test_an_empty_leg_without_the_flag_is_refused_naming_the_flag
M4: _login_remedy says nothing test_what_a_refused_push_prints (9 ids)
M5: drop the README sentence test_repo_hygiene.py::test_the_handbook_follows_the_readmes_outline (the stale marker)

No test notices if the handbook's list item is dropped. The handbook's prose is not pinned anywhere.

Merges

Review round 1 (RV1101: READY, no MUST)

Head 2cbf54b, one commit on top of 2c6d51a:

M1, re-made against the new help (the sentence dropped), was KILLED by test_cli_reference.py::test_every_python_tools_help_is_the_text_the_file_carries and ::test_the_committed_reference_is_what_the_generator_produces. The file was restored and cmp found it identical to the saved copy.

Tests at 2cbf54b, one file at a time under the same harness:

file result
tests/test_scaffold_reuse_seeded_legs.py 90 passed in 58.31s
tests/test_cli_reference.py 23 passed in 32.36s
tests/test_readme_sections.py 76 passed in 0.30s
tests/test_repo_hygiene.py 100 passed in 2.50s

Closes #915

Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code

…README, the handbook and each remedy that sends a person into that re-run say it turns off git's own prompt and no other, so ssh, a credential helper or askpass can still make it wait, and how to keep it from waiting (#915)

U037, the last item of #915. Brett Heap ruled it on 2026-10-21T10:39:44Z,
"U039 and U037: take your recommendations". Lane 3's recommendation leaves
the operator's own ssh, credential-helper and askpass setup alone and says
where a prompt can still come.

Measured at 2a119f2 before writing, in a pseudo-terminal, against a fake gh
and fake or local hosts only: a `--reuse-empty-repo` re-run's probe and push
waited until killed at the real OpenSSH's host-key prompt (through a
ProxyCommand to a fake server on stdio), at a passphrase prompt (a fake
`GIT_SSH_COMMAND`), at a credential helper that prompts, and at
`GIT_ASKPASS`, `SSH_ASKPASS` and `core.askPass`. git passed ssh no
`BatchMode`. `GIT_SSH_COMMAND='ssh -o BatchMode=yes'` made ssh fail at
once: the probe was refused in 1.3 s with ssh's `Host key verification
failed.` And a set `GIT_SSH_COMMAND` ran in place of a configured
`core.sshCommand`, which is why the scaffold does not force one.

- The comment over `REMOTE_ENV` records the ruling and why the environment
  stays the operator's, in place of the open question it held.
- `PROMPTS_STILL_ASK` is the one sentence each remedy whose exit is the
  re-run now ends with: `_exists_remedy`'s for a leg git could not read, an
  empty or seeded leg and an empty root, and `_login_remedy`'s. Not under
  `--local-remote-dir`, where git reads paths on disk and nothing prompts.
- The `--reuse-empty-repo` help says it, and docs/cli.md is regenerated.
- README.md and docs/handbook.html say it where the re-run is described;
  the handbook's markers for that README section carry its new blob, and
  `tests/readme-cap.d/915.txt` records the seven lines.

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap added this to the v1.2.0 milestone Oct 21, 2026
@sourcery-ai

sourcery-ai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR documents that --reuse-empty-repo disables only Git's native terminal prompt, explains how SSH, credential-helper, and askpass prompts can still block, and provides operator-controlled ways to fail fast; it updates synchronized documentation and adds coverage ensuring the guidance appears only on applicable GitHub re-run remedies.

File-Level Changes

Change Details Files
Document the prompt behavior and non-blocking operator options for --reuse-empty-repo without changing runtime prompt handling.
  • Add shared help/remedy text explaining that only Git's terminal prompt is disabled and SSH, credential-helper, and askpass prompts may still wait.
  • Recommend an SSH agent, non-interactive credential helper, or GIT_SSH_COMMAND='ssh -o BatchMode=yes', while preserving the operator's SSH configuration.
  • Apply the guidance only to GitHub re-run paths; omit it for local disk remotes and remedies that do not re-run with the flag.
  • Clarify in code comments why the environment is intentionally left under operator control.
scaffold-project.py
Propagate the new guidance through generated CLI and user-facing project documentation.
  • Regenerate the --reuse-empty-repo CLI reference.
  • Add the warning and mitigation guidance to the README's failure/re-run instructions.
  • Synchronize the handbook section and update its README blob markers.
docs/cli.md
README.md
docs/handbook.html
Pin the documentation-cap and validate that the handbook remains synchronized with the README.
  • Record the seven updated README lines in the cap fixture.
tests/readme-cap.d/915.txt
Add focused regression coverage for prompt guidance placement and exact remedy text.
  • Define the expected prompt-warning suffix and include it in re-run remedy expectations.
  • Test all relevant leg/root remedies, including unread and seeded cases, while excluding non-re-run and local-remote paths.
  • Retain existing help, README, refused-push, and CLI generation assertions.
tests/test_scaffold_reuse_seeded_legs.py

Assessment against linked issues

Issue Objective Addressed Explanation
#915 Apply a consistent, decided prompt rule to every remote git command the scaffold runs, including reused-leg and root pushes and the held-seed fetch. ❌ The PR explicitly changes no runtime behavior. It does not pass the existing prompt environment to the three uncovered commands, so those commands do not run under the same rule as the probe commands.
#915 Resolve whether remote probes and pushes must avoid waiting for input, while preserving operators’ credential setups and explaining how to prevent prompts if they may still occur. ✅
#915 When an unread remote fails through an SSH rewrite, preserve the specific SSH failure instead of Git’s generic refusal and give an SSH-key-appropriate remedy. ❌ The PR does not change unread-error extraction or the SSH-rewrite remedy. Its body says U038 landed in another PR, but that fix is not present in the supplied changes.

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-21T12:46:44Z) Independent review (RV1101) of head 2c6d51a, U037 of #915. Verdict: READY. No MUST.

Measured at the head, in a pseudo-terminal, under a stub gh, with no real host reached:

  • probe, fake GIT_SSH_COMMAND asking the tty: still waiting at "Enter passphrase" after 20s, killed.
  • probe, credential helper asking the tty: still waiting after 20s, killed.
  • probe, real OpenSSH through a ProxyCommand to a fake server, no BatchMode: waits at the host-key question, killed.
  • the same with GIT_SSH_COMMAND='<fake ssh> -o BatchMode=yes': refused in 0.6s, scaffold-remote-exists, "Host key verification failed."; the push form refused in 1.7s.
    Each clause of the new sentence holds.

Tests at the head, one file at a time: test_scaffold_reuse_seeded_legs.py 90 passed; test_cli_reference.py 23 passed; test_readme_sections.py 76 passed; test_repo_hygiene.py 100 passed.
Mutants, each restored and checked with cmp: M1 (help sentence dropped) killed, 5 failed; M2 (_exists_remedy says nothing) killed, 8 failed; M4 (_login_remedy says nothing) killed, 21 failed.
Merges: clean with origin/main 16f9a0e (tree 9151e06d3b92) and with #1102 f7f596a (tree ee0e804bf80e). No other open PR touches these six files.
Body: closes [915], milestone v1.2.0, draft, Closes/Lane/robot at the end, no other closing keyword, nothing above U+007F but the robot.

SHOULD before landing:
S1. File a follow-up issue and name it in "Left out of scope". Closes #915 will close the only place it is recorded. Two parts:
(a) RULESET_HINT (scripts/shape_materialize.py:1189) sends a person into the --reuse-empty-repo re-run and does not say the prompt sentence. It is printed under --local-remote-dir too, so it needs a GitHub-only path, not just words.
(b) A push refused with "Host key verification failed." prints RULESET_HINT alone (measured with BatchMode=yes, which this PR now advises). The re-run it names fails the same way.

NIT:
N1. scaffold-project.py:529-532 and :554 say "each remedy that sends a person into that re-run"; RULESET_HINT does that and does not say it. Name it and the follow-up.
N2. The help and docs/cli.md say "A run with it, like every run, ... can still wait at an ssh ..."; not so under --local-remote-dir. Say "on GitHub".
N3. docs/handbook.html:1127: the new list item opens "That re-run" with its antecedent in the previous item. Say "The --reuse-empty-repo re-run".
N4. Body "Merges": add #1102 (templates only, merge-tree clean).

Checked and fine: appending a second sentence follows precedent (_exists_remedy's "There is no --force." and the ruleset sentence at main, the family switch -c remedy, #874's REF_RULE_WORDS). tests/readme-cap.d/915.txt is the established ledger (README 1482 -> 1489 = 1422 + 67). The handbook prose is held by the README blob markers, as everywhere else.

Lane 3's plan: S1 is filed as #1108, outside v1.2.0. W915, the writer, names #1108 in the body's "Left out of scope" and takes N1 to N4: N1 names RULESET_HINT and #1108 in the two comments, N2 says "on GitHub" in the help and regenerates docs/cli.md, N3 opens the handbook item with "The --reuse-empty-repo re-run", and N4 adds #1102 to the Merges section. RV1101 then checks the new head. #1101 stays a draft until it is READY, green and gated.

Lane: openreposhape-3 (openRepoShape-3)

…-repo` help says a run on GitHub, the comments name `RULESET_HINT` as the refusal that does not say it yet (#1108), and the handbook's item names the re-run it means (#915)

Fix round 1 of #1101, from RV1101 (READY, no MUST).

- N2: the help said "A run with it, like every run, ... can still wait at
  an ssh ...", which is not so under --local-remote-dir, where git reads
  paths on disk. It now says a run on GitHub. docs/cli.md is regenerated
  by scripts/render-cli-reference.py.
- N1: the comments over `REMOTE_ENV` and `PROMPTS_STILL_ASK` named each
  remedy that sends a person into the re-run; `RULESET_HINT` sends one
  there and does not say it yet, which #1108 follows up.
- N3: the handbook's new list item opened "That re-run" with its
  antecedent in the item before; it now opens "The `--reuse-empty-repo`
  re-run". README.md is unchanged, so its section blob and the handbook's
  markers stand.

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

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-21T12:54:20Z) Independent review (RV1101) round 2 of head 2cbf54b. Verdict: READY. No new items.

Lane 3's plan: #1101's landing pilot is armed on 2cbf54b, chained behind #1102's, and released now. After #1102 lands, it waits for tests and SonarCloud on this head, gates the predicted squash over the new main, takes the HOLD in turn under the rule that the first PR green and gated goes first, and lands. Follow-up #1108 stays outside v1.2.0.

Lane: openreposhape-3 (openRepoShape-3)

@sourcery-ai

sourcery-ai Bot commented Oct 21, 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 4 hours and 16 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 7cd6690 into main Oct 21, 2026
5 checks passed
@brettheap
brettheap deleted the l3-915-prompt-words branch October 21, 2026 14:18
This was referenced Oct 21, 2026
brettheap pushed a commit that referenced this pull request Oct 21, 2026
…kip-tests

Main at 4ed7c7b (#1091, #1101, #1102). tests/test_adopt_root_shape_paths.py
conflicted where both sides added a test after
test_the_materializer_never_writes_a_copy_over_what_is_there: main's
test_the_materializer_stops_at_a_link_at_the_copy (#904) and this branch's
test_the_materializer_names_a_bad_link_at_the_copy_by_its_own_finding
(#1089, N4). Both are kept whole, main's first. adopt-project.py merged
by itself. On the merge, tests/test_adopt_root_shape_paths.py gave 122
passed and tests/test_adopt_symlink_target_bytes.py 133 passed.

Part of #1089

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 23, 2026
…amily-root (#1005)

One file conflicted, docs/handbook.html, and only at the seven
`reads README.md, section Starting a project in a new organisation: blob ...`
markers (the masthead, `start`, `partway`, `ci`, `legs`, `doublepin` and
`park`), the known serialization of two README pull requests:

- this branch changed that README section's "Windows, natively" sentence, which
  now names `remove` among the lines `family.py` quotes for the shell they are
  printed in, so its markers carried blob 2085e8ac35de0506c5ea51774a105e0e400956d7;
- main's #1101 (7cd6690) added a paragraph to the same section, about the
  ssh, helper and askpass prompts a `--reuse-empty-repo` re-run can still wait
  on, so its markers carried blob ffaedeefe61720051098400cbf2212de50b923f3.

README.md and the handbook's prose merged without a conflict, and the merged
README holds both edits. Each of the seven markers is now the hash of that
section of the merged README, b102a1cd7ec797cf9aa1daa80fbf3a5e60ae3b72,
computed with the repository's own `readme_segments` and `readme_fingerprint`
(tests/test_repo_hygiene.py). No other README section changed on either side,
so no other marker moved, and no count, list or table in a test needed a new
value: main adds no `shell_quoted` call to `family.py` or `setup-project.py`,
and README's line count is unchanged by this branch (+2 -2).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 23, 2026
…to check that key against GitHub's fingerprints, not the ruleset hint, and the hint on GitHub says what its re-run can still wait at (#1108) (#1130)

This follows #1101 (U037 of #915). When ssh refused a push for its host
key (`Host key verification failed.`), `scaffold-project.py` printed the
ruleset hint. ssh refused before git sent anything, so no ruleset was
asked, and the hint's re-run fails the same way. On GitHub the ruleset
hint also did not say what that re-run can still wait at.

A new `_host_key_remedy` is printed in place of `RULESET_HINT` where
git's output for a push holds that phrase (`HOST_KEY_FAILED`) in a line
of ssh's own. `_without_remote_lines` skips lines that start with
`remote:`, so words a ruleset's message or a hook carries cannot switch
the remedy. There are two remedies, an unknown key and a changed one
(`REMOTE HOST IDENTIFICATION HAS CHANGED`), each giving the cause, what
to do, then the re-run. What to do is
`ssh -T -o BatchMode=no -o StrictHostKeyChecking=ask`, both options
first so that they win over ssh_config and any later `-o`, which shows
the fingerprint and asks, and to answer yes only to a fingerprint GitHub
publishes
(`gh api --hostname github.com meta --jq .ssh_key_fingerprints`). The
changed-key remedy never says to accept the key as it comes. The re-run
tail the login remedies use is now shared as `_then_re_run`, and their
output is byte for byte what it was. No refusal code is added.

On GitHub, `_refused_push` now prints `RULESET_HINT_PROMPTS`
(`PROMPTS_STILL_ASK`) after `RULESET_HINT`, as a paragraph of its own,
and not where a login remedy before the hint has already said it. Under
`--local-remote-dir` the hint is printed alone, as before. It is an
append at the scaffold's one call site, with a new required `local`
argument, and the shared constant is unchanged: the hint is printed
under `--local-remote-dir` too, and a third format field would break the
siblings' pins.

`tests/test_scaffold_reuse_seeded_legs.py` has 121 passed, 117 before
and 4 new ids. `test_what_a_refused_push_prints` has 18 rows, each on
GitHub and on disk, with the host-key rows built from OpenSSH's measured
words. They include a ruleset whose `remote:` lines carry the host-key
words after a carriage return and after a form feed, which keep the
ruleset hint.
`test_a_push_ssh_refused_the_host_key_for_names_the_key_not_the_ruleset`
(4 ids) runs the scaffold against a fake ssh, and
`tests/test_ruleset_hint_tracking_branch.py` (23 passed) holds the hint
last under `--local-remote-dir`.

Closes #1108

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 23, 2026
…git dies on read the `.gitmodules` git reads, the index's where the work tree's is gone, and every section's lines end `git add -- .gitmodules`, so a plain `git commit` after them takes the section out of HEAD (#1077) (#1124)

## What this does

RV1069's review of #1069 (the PR for #1036) left four follow-ups, recorded in #1077's body (NIT 1, NIT 2) and in issuecomment-6303768948 (NIT 3, NIT 4). Each was measured on main before this change. The only script changed is `templates/family-root/scripts/validate-family.py`, which has no pinned copy.

- **NIT 1, `gitmodules-path-twice` read the work tree's `.gitmodules` alone.** A second section for `members/IRRS`, staged in the index's file with the work tree's deleted, was `family ok` in every mode, and a clean clone of the commit after it failed its update and its gate.
  - It reads the sections as git reads them now (`_sections_as_git_reads`), as `gitmodules-path-unnamed` does since #1036.
  - Where the work tree's file is gone, it names the line that puts back the file git reads first. That put-back is recorded, so no later finding puts the file back over the hand edit.
  - It ends "then stage and commit .gitmodules".
- **NIT 2, the value check read the work tree's file and HEAD's, never the index's.** An `update = !true` staged in the index's file alone, in a named member's section, was `family ok` while git's own `submodule status` exited 128.
  - It reads the file git reads (`_listed_as_git_reads`), and says "the index's file, which git reads here" where that is the one read.
  - Its lines put that file back, not HEAD's. They end "then stage", or "then stage and commit" where HEAD's gives the value too.
  - Where HEAD's file alone gives the value and the work tree's is gone, it says "Commit the index's .gitmodules, which git reads here and does not give it", with no line, so a fix staged there is kept.
  - `member-gitmodules-absent` then puts the file back itself: `_check_gitlinks_registered` asks `Report.put_back` alone.
  - Git is asked in the order it was before (the work tree's file, then HEAD's, then the one read in its place), so with no git to run, `git-not-runnable` names what it named.
- **NIT 3, a plain `git commit` after a section's lines.** Where HEAD's file held the section, `gitmodules-path-unnamed`'s lines ended "then commit .gitmodules" and staged nothing. A plain `git commit` after them left the section in HEAD, and a clone's gate failed. Every branch (in `members/`, outside it, and in another case) now prints `git add -- .gitmodules` after the keys, and says "then stage and commit" where HEAD holds the section.
- **NIT 4, a test gap.** No row covered the case-variant branch's stage line where HEAD holds no section of the name. RV1069's probe `test_r2_stray_not_in_head` is that row now.

## New rows: `tests/test_validate_family_index_gitmodules.py`

Every row types only the printed lines and does by hand only what the text says in words, then makes a plain `git commit`. The gate and a plain clone then pass.

- N1: a second section in the index's file alone.
- N2: a value in a named member's section, in the index's file alone.
- N2b: HEAD's file gives the value, and the index's is mended.
- N2c: as N2b, with OTHER's section out of both files, so `member-gitmodules-absent` puts the index's file back first.
- N3: a section HEAD holds (a stale one, or `tools/x` with its gitlink), with the file on disk, deleted, or its deletion staged.
- N4: the stray `ghost` (RV1069's probe), on disk and deleted.
- Fix round 1: path-twice's put-back is recorded, so `gitmodules-path-unnamed` after it prints none (RV1124's P7, on `deleted` and `deletion-staged`); and the value's url is written back from the file git reads, not HEAD's (RV1124's P10). See the fix-round section below.

**Before and after.** On main's script the file gives 10 failed and 2 passed. The 2 are N4's rows, which pass there by design (NIT 4 is a test gap) and kill the case-variant mutant M8 below. On this head it gives 12 passed. After fix round 1 the file has 15 rows: on main's script it gives 13 failed and 2 passed (the 2 are N4's), and on this head 15 passed.

## Older rows changed, and why

Each one pinned the behaviour #1077 names as the defect.

- **"then commit" with no stage line (NIT 3).**
  - `SECTION_REMEDY` and a new `STAGE` constant (`test_validate_family_unnamed_mounts.py`), and its rows (b) and (c), plus the two-sections row.
  - `THEN_COMMIT` and `section_first`, which always ends with the stage line now (`test_family_unnamed_gitlink.py`), and its two-sections and control rows.
  - Two `removal(...)` assertions in `test_validate_family_remedies_typed.py`.
  - `ghost_out`, R1's replace anchor and R6 in `test_family_hand_mount_git_rm_lines.py`.
  - Each section in these rows is committed, so HEAD holds it: a plain commit after the old lines left it there.
- **Path-twice's ending (NIT 1 and NIT 3).** `test_family_add_refusals.py`'s path-twice row ends "then stage and commit": the commit records the index's file, not the work tree's hand edit.
- **HEAD's file put back where git reads the index's (NIT 2).**
  - `taken_out(put_back=...)` and the two gone-file value rows in `test_family_gitmodules_values.py`. These now read `HERE_AND_HEAD`; one is renamed `..._put_it_back_once`, since its old name said HEAD's file is put back.
  - The first line of `test_round1_nit_6` in `test_family_holder_remedies_compose.py`.
  - The value row in `test_family_holder_bootstrap_texts.py`, renamed `test_a_value_finding_that_put_the_file_back_is_not_put_back_over`.
  - These are now `git checkout -- .gitmodules`, the index's file.
- **#1036's R5 control (`test_1036_control_a_value_git_dies_on_keeps_what_it_read`).**
  - Round 1 put back HEAD's file, which dropped IRQS's section that only the index's file gave.
  - The value's lines put back the index's file now, so the commit after round 1 records that section, as it does with the file on disk. The next run names it, in every mode.
  - The row now types round 2 as well, then a plain `git commit`; nothing is left, and the gate and a plain clone pass.
  - What the control pins is unchanged: where git reads a file that gives a value git dies on, `gitmodules-path-unnamed` reads nothing from it.

## Gates

Every run used `/usr/bin/python3 -m pytest -p no:cacheprovider` with:
- `EMAIL` set;
- `GIT_ALLOW_PROTOCOL`, `GIT_SSH_COMMAND` and the `LANES_*` variables unset;
- an empty `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_NOSYSTEM=1`;
- test author and committer identities;
- a stub `gh` first on PATH, `GH_TOKEN=bogus` and an empty `GH_CONFIG_DIR`.

- **Targeted set: 78 files.**
  - The files that name `gitmodules-path-twice`, `gitmodules-value-refused`, `gitmodules-path-unnamed` or `validate-family` in the head's tree, plus every test file that imports from a test module this changes.
  - This includes `test_pinned_copy_text`, `test_repo_hygiene`, `test_cli_reference` and `test_agents_md_codes`.
  - Result: 0 failed in all 78 files, 4390 passed and 34 skipped.
  - Shards 0 to 4 and 6 to 9 (70 files) were run at `0cd1c3f5`, the three WIP commits this head squashes (the same tree, and the same sha256 of the script): 3379 passed, 25 skipped.
  - Shard 5 (8 files) was run last, at this head (`672845cc`): 1011 passed, 9 skipped, 0 failed. Two earlier runs of it were cut by checkpoints, at 49% and at 28%, with 0 failures both times. Its files are `test_doctor_legs_members_next.py`, `test_family_add_spelling_leftovers.py`, `test_family_docs_one_spelling.py`, `test_family_makefile_siblings.py`, `test_family_remove_gitmodules.py`, `test_shape_doctor.py`, `test_update_shape_kept_helper.py` and `test_validate_family_unreadable.py`.
  - The PR's CI `tests` job runs the full suite.
- **Older rows.** The 33 rows the change moved, which failed on the new script before their update, now pass with the new rows file (45 passed).

## Mutants

Each mutant was run against the new rows file and the older rows that pin the changed paths. The file was restored after each one, and its sha256 checked against the head's.

| Mutant | Rows that failed |
|---|---|
| M1: the value check reads the work tree's file alone | 8 (N2, N2b, N2c, two gitmodules_values rows, round1 NIT 6, the bootstrap_texts row, R5) |
| M2: path-twice reads the work tree's file alone | 1 (N1) |
| M3: no "commit the index's" branch where HEAD's alone gives the value | 2 (N2b, N2c) |
| M4: the value's put-back is HEAD's, as before | 6 |
| M5: the value's text does not say to stage the file | 1 (N2) |
| M6: no stage line in `members/` | 7 |
| M7: no stage line outside `members/` | 3 |
| M8: no stage line in the case-variant branch (NIT 4) | 2 (N4 on disk, deleted) |
| M9: "then commit" where HEAD holds the section | 10 |
| M10: path-twice names no put-back | 1 (N1) |
| M11: path-twice ends "then commit" | 3 |
| M12: `member-gitmodules-absent` puts nothing back after any value finding | 1 (N2c) |
| M13: the file git reads is asked before HEAD's | 3 (the without_git rows) |

## Merge-trees

`git merge-tree --write-tree` against `origin/main` 823624d is clean, and was clean against 6da757c, the base the gates ran on. Main has since taken #1101 and #1102; neither touches a file of this PR. No open PR touches these files. #1109, the closest in subject, merges clean too.

## Follow-up, not in this PR

RV1124 measured the suspected counterpart of NIT 3 (its N1) and two more older defects (N2 and N6). All three are older than this PR and self-correcting or known limits; the lane files them as the follow-up #1150, and none is fixed here.

- N1: the work tree's `.gitmodules` is on disk, and both it and the index's give a value HEAD's does not. The value's lines say nothing of staging, so a plain `git commit` after them records the index's value; the next run names it in HEAD. Main prints the same text and gives the same result.
- N2: an index blob with a line git cannot parse after a value is `family ok` (`_blob_listed` returns nothing on git's exit 128).
- N6: an unmerged `.gitmodules` (stages 1 to 3), which v1.1.1's release notes list as a known limit.

## Fix round 1: RV1124's review of `672845cc` (S1, N3, N4, N5)

RV1124's review of this PR at `672845cc` found one SHOULD and six NITs. This round fixes S1, N3, N4 and N5, in one commit on top of it (`8dfa32bd`), with no amend and no force. N1, N2 and N6 are left to the follow-up #1150.

- **S1 (SHOULD): #1111's `test_path_guard_hygiene.py` failed the new guard.** `_listed_as_git_reads` opened with a bare `os.path.lexists(root / GITMODULES)`. Its one caller, `_check_gitmodules_values`, asks it only where the work tree's file is gone (`if present else _listed_as_git_reads(root)`), and `_gitmodules_put_back`, which the function calls next, asks the same `lexists` and is on the test's allowlist. The guard is dropped; the function is `spec = _gitmodules_put_back(root)[0]` and the blob read. On the scratch merge of main `038dafba9` and this head, `test_path_guard_hygiene.py` gives 49 passed, with no failure for this file and no stale `BENIGN` entry.
- **N3: two behaviours had no row.** Mutants A and B survived all 439 rows of the PR's nine files. Two rows, written from RV1124's probes P10 and P7:
  - `test_1077_a_url_git_dies_on_is_written_back_as_the_file_git_reads_gave_it` (P10): the index's file gives a url that is not HEAD's, then a `url` with no value; the line that writes the url back takes it from the index's file.
  - `test_1077_path_twice_records_its_put_back_for_the_next_finding` (P7, on `deleted` and `deletion-staged`): HEAD's file holds `second` and a stale `members/Old`; path-twice's put-back is recorded, so `gitmodules-path-unnamed` after it prints none, and typed in order then a plain `git commit`, HEAD loses both.
- **N4: path-twice's wording.** "back from HEAD's, as the index holds none first" put "first" on the wrong phrase. It says "back first from {whose}, `{put_back}`" now. Only `TWICE_PUT_BACK` in the new rows file pinned the old phrase.
- **N5: a stale docstring.** `_gitlink_out_first` said its text ends "then commit .gitmodules"; it now says it ends as `_section_then` does, "then stage and commit" or "then stage".

**Rows.** The new rows file has 15 rows. On main's script (`6da757cb`) it gives 13 failed and 2 passed (the 2 are N4's, by design). On `672845cc` it gives 14 passed and 1 failed: the first row (`test_1077_a_second_section_the_index_alone_gives_is_path_twice`) fails on N4's old wording. The P7 and P10 rows pass on `672845cc`, whose code was right; they kill the mutants below. On this head, 15 passed.

**Mutants.** Each ran in its own worktree over the new rows file, and the script was restored after each and its sha256 checked.

| Mutant | Rows that failed |
|---|---|
| A: the value's write-back from HEAD's where the work tree's file is gone | 1 (the P10 row: the last line wrote the HEAD url, not the staged one) |
| B: path-twice names its put-back without recording it (`_file_put_back(root, None)`) | 2 (the P7 rows: `gitmodules-path-unnamed` printed the put-back again) |

**Gate.** On a scratch merge of main `038dafba9` and `2d4cb64b`, whose tree is this head's: the usual environment (`EMAIL`, an empty `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_NOSYSTEM=1`, test identities, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`). 0 failed, 3458 passed and 27 skipped in all.
- `test_path_guard_hygiene.py`: 49 passed.
- The 64 files of every `tests/test_family*.py` and `tests/test_validate_family*.py` (the PR's nine among them), plus `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_pinned_copy_text.py` and `test_doctor_validator_rows.py`: 2964 passed and 24 skipped, in four shards (685; 997 and 20 skipped; 557 and 4 skipped; 725). Those four shards were cut by a checkpoint and re-run on the same merge with the same lists.
- `test_shape_doctor.py`, which runs `validate-family.py`, in 8 shards by test id, each list non-empty: 445 passed and 3 skipped.

**Merge-trees.** `git merge-tree --write-tree origin/main HEAD` at `8dfa32bd` against main `038dafba9` is clean (tree `23c7194f0b6a08f56b93bdc80a05924e90e703a4`).

Closes #1077

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 23, 2026
…40` end in `\Z`, not `$`, so a pin's `commit:` or `tree_sha256:` that ends in a line break is refused as not 40 or 64 hex by every validator and `adopt-project.py` (#1017) (#1126)

`scripts/repo_shape.py` ends `COMMIT_RE` and `SHA256_RE` in `$`, and every caller reads them with `.match`. In Python `$` also matches just before a trailing line feed, so a pin's `commit:` or `tree_sha256:` that ends in a line break passed as well formed. This makes both patterns refuse it, wherever a value is decided to be valid, and changes nothing else about them.

Measured on main (`2a119f28`, then re-checked on `6da757cbe`; the use sites of both patterns are the same on `823624d93`, and no new copy of either pattern was added). Python 3.12.3:

| pattern | value | `.match` on main |
|---|---|---|
| `COMMIT_RE` / `SHA256_RE` | 40 / 64 hex | a match |
| same | hex and a line feed | a match (the defect) |
| same | hex and a carriage return and line feed | None |
| same | hex and a space | None |

A line feed reaches the patterns through YAML: the double-quoted escape `"<hex>\n"` and a block scalar both read as the hex and a line feed. On main:

- `validate-manifest.py`: `shape.commit` and `shape.digests.tree_sha256` ending in a line feed -> `manifest ok`, exit 0.
- `validate-family.py`: `shape.digests.tree_sha256` in `family.yaml` ending in a line feed passed (`family ok`, exit 0). `shape.commit` did not pass: it exited 1 with `shape-pin-manifest-disagree`, because `contracts/shape-pin.yaml` and `family.yaml`'s `shape.commit` differ only by the line feed, so the same 40 hex is printed on both sides; the finding that names the commit as not 40 hex, `family-shape-commit`, was not raised. A member pin's `tree_sha256` ended in a line feed gave `member-digest-mismatch`, not the malformed finding, and a member pin's `commit` gave `member-commit-unresolvable`, not `member-pin-commit`.
- `validate-pins.py`: a leg pin's commit gave exit 2 `pin-commit-unresolvable`, not `pin-tag-only`; a leg digest gave `pin-digest-mismatch`, not `pin-digest-malformed`; a shape pin's commit gave only `shape-pin-manifest-disagree`, not `shape-pin-commit`.
- `adopt-project.py check` (`_pin_row_problem`): the row read as well formed, with no `plan-pin-malformed`.
- `templates/assembly-root/scripts/bootstrap.py` holds a copy of the commit pattern, `COMMIT_40`, with the same defect: `shape_upstream_notice` printed its notice for a pin ending in a line feed.
- `shape-doctor.py` `pin_commit` returned `"<hex>\n"` for such a leg pin.

Checked and not affected: `bump-leg.py --to` and `family.py --at/--to` go through `checked_value` (a `fullmatch` that already refuses a line feed); `bump-leg.py` line 2212 strips first; `update-shape.py` line 558 reads `git_out`, which is stripped; `shape-doctor`'s `recorded_gitlink` splits fields; `src/openreposhape/standard.py` uses `fullmatch`. The `@(?P<sha>[0-9a-fA-F]{40})` patterns in `bump-leg.py` and `validate-pins.py` are unanchored workflow-ref patterns.

- `scripts/repo_shape.py`: `COMMIT_RE` and `SHA256_RE` end in `\Z`, not `$` (comment added). `\Z` matches only at the very end of the value, so each caller's `.match` refuses a line feed as it already refused a carriage return and line feed, and a space. 40 and 64 digits, either case, are unchanged.
- `templates/assembly-root/scripts/bootstrap.py`: `COMMIT_40` likewise (comment added).
- Nothing else in production changes. No README, doc, handbook or `AGENTS.md` text pins the old pattern bytes (grepped), and `tests/test_pinned_copy_text.py` and every `tests/test_bootstrap*.py` pass unchanged (shard tB and shard tA).
- Rows:
  - new `tests/test_pin_pattern_line_break.py` (46 rows: 42 at first, and 4 added in fix round 1, below): the three patterns x case, length, after-suffix (line feed, carriage return and line feed, space, two line feeds, carriage return, tab) and before-prefix; the holder's gate (four rows and a baseline); the bootstrap upstream notice (two rows); the doctor's `pin_commit` (two rows);
  - `tests/test_pin_validator.py`: 3 rows (leg commit, leg digest, shape-pin commit);
  - `tests/test_manifest_validator.py`: 2 rows (`shape.commit`, shape digest) and a helper `shape_of`;
  - `tests/test_adopt_pins.py`: 2 `malformed()` cases (`commit-line-break`, `digest-line-break`);
  - `tests/test_shape_doctor.py`: 1 `ROOT_PIN_FAULTS` param (`a-commit-ending-in-a-line-break`).

Run with the two production files of main checked out, on a head with the same tree as this one (git 2.43.0, `EMAIL=service@opensoft.dev`):

- the new file, `test_pin_validator.py`, `test_manifest_validator.py`, `test_adopt_pins.py`: **16 failed, 307 passed** in 294.83s. The 16:
  - new file (9): `line-feed-COMMIT_RE`, `line-feed-SHA256_RE`, `line-feed-bootstrap-COMMIT_40`, `test_a_member_pin_commit_that_ends_in_a_line_break_is_refused`, `test_a_member_pin_digest_that_ends_in_a_line_break_is_malformed`, `test_a_family_shape_commit_that_ends_in_a_line_break_is_a_finding`, `test_a_family_shape_digest_that_ends_in_a_line_break_is_a_finding`, `test_the_upstream_notice_is_silent_over_a_pin_that_ends_in_a_line_break`, `test_the_doctor_reads_a_pins_commit_as_the_validators_do[40-hex-and-a-line-feed]`;
  - `test_pin_validator.py` (3): a commit, a digest and a shape-pin commit that end in a line break;
  - `test_manifest_validator.py` (2): a shape commit and a shape digest;
  - `test_adopt_pins.py` (2): `plan-pin-malformed-commit-line-break`, `plan-pin-malformed-digest-line-break`;
- `test_shape_doctor.py -k a_root_pin_that_does_not_read`: **1 failed, 12 passed, 436 deselected** in 57.84s (`a-commit-ending-in-a-line-break`).

With the change the same files pass (see the runs).

| mutant | failed | passed |
|---|---|---|
| M1: both `repo_shape` patterns back to `$` | 11 | 35 |
| M2: only `COMMIT_RE` back to `$` | 7 | 39 |
| M3: only `SHA256_RE` back to `$` | 4 | 42 |
| M4: only the bootstrap `COMMIT_40` back to `$` | 2 | 44 |

Re-measured in fix round 2, at `4c1f80cf`, on the 46-row file. The first round measured its 42 rows: 7/35, 4/38, 3/39 and 2/40. M1 is M2 and M3 together.

Each is killed by at least one row, and each pattern is killed by a row of its own. Every mutant was undone: the production files are byte for byte (`cmp`) their backups of this change.

git 2.43.0 (`/usr/bin/git`):

- shard tA, 35 files: **2782 passed, 8 skipped** in 4138.49s, rc=0;
- shard tB, 39 files: **2630 passed, 6 skipped** in 4532.76s, rc=0;
- trio (`tests/test_repo_hygiene.py tests/test_agents_md_codes.py tests/test_cli_reference.py`): **170 passed** in 37.48s, rc=0.

The two shards are 74 targeted files: every file that names a pin code (`pin-tag-only`, `pin-digest-*`, `shape-pin-*`, `manifest-shape-*`, `family-shape-*`, `member-pin-*`, `update-pin-*`, `neutral-pin-*`, `plan-pin-*`, `not 40/64 hex`) or one of `COMMIT_RE`, `SHA256_RE`, `COMMIT_40`, every `tests/test_bootstrap*.py` (13 of 13), `test_shape_doctor.py`, 6 of the 19 `test_update_shape*.py` files, `test_family.py`, `test_scaffold_e2e.py`, and the pin tests. They replaced a wide run (226 importer files, about 27k tests) that was killed by PID under load (load average 72 on 32 cores, 2% in 11 minutes); that run is not evidence. The new file and the four edited files are all inside the shards. The other 13 `test_update_shape*.py` files, which name no pin code, ran as a third run (480 passed, 3 skipped in 441.06s, rc=0), and `tests/test_scaffold_leg_path_collisions.py` as a fourth (104 passed in 4.35s, rc=0). No full suite was run.

git 2.47.1 (`git --version` printed first in each log):

- the new file, `test_pin_validator.py`, `test_manifest_validator.py`, `test_adopt_pins.py`: **323 passed** in 208.37s, rc=0.
- `test_shape_doctor.py -k a_root_pin_that_does_not_read`: **13 passed, 436 deselected** in 52.85s, rc=0.

Refreshed after the fix round, against the re-fetched origin/main:

- **Fix round 2, 2026-10-22:**
  - origin/main `038dafba9dbedd9405b316cc749a04dffe02bb36` (#1111) is merged into the branch as `c7da27dc2`, cleanly.
  - The head `4c1f80cf4db6215d8861bd43cd57f72df957d82a` merges with main at its own tree, `9884a99aeba2a55e486a355ae3483d8c932b4f68`: the seven files of this PR.
  - Of the 28 open pull requests, #1125 (head `2a53a8e6`) still touches `tests/test_adopt_pins.py`, and its merge-tree with this head is clean, tree `a026a192669a86abfaed59fbcce2fd1179bb0894`.
  - The bullets below are fix round 1's.
- `git merge-tree --write-tree origin/main HEAD` against `4ed7c7bf581dceb75f816f30f76b776c2b8917f0` (merge base `6da757cbe`), with this PR's head `03b2b97b9d3cccc42b4703cb2f7c978693488266`: clean, rc=0, tree `95d242489e6a8d6ea2be8c454a5e6655922d5712`. The difference between that tree and origin/main is exactly the seven files of this PR.
- The branch stays on `6da757cbe`. Since then three PRs landed. #1101 and #1102 touched `README.md`, `docs/cli.md`, `docs/handbook.html`, `scaffold-project.py`, `templates/assembly-root/project.yaml`, `templates/family-root/family.yaml`, `tests/readme-cap.d/915.txt`, `tests/test_scaffold_reuse_seeded_legs.py` and `tests/test_visibility_field_words.py`. #1091 (`4ed7c7bf5`) touched `adopt-project.py`, `tests/test_adopt_root_shape_paths.py` and `tests/test_adopt_root_symlinks.py`. None is a file of this PR, and `adopt-project.py` holds the same 8 uses of `COMMIT_RE` and `SHA256_RE` as before.
- Of the 23 open pull requests (listed with `gh pr list --state open --json number,headRefName,headRefOid,files`, this one included), one other touches a file of this PR: #1125 (head `2a53a8e64a0cce17ab84cd1bf5949bcb6fbd4484`) touches `tests/test_adopt_pins.py`. `git merge-tree --write-tree 2a53a8e HEAD` is clean, rc=0, tree `5fbd1eaafdf94004ead7142ae9132dc79dc4d1e5`.

One new commit on top, `03b2b97b9d3cccc42b4703cb2f7c978693488266`, after the independent review of `ae30dbd0e0eecfb7092c5ef257da2367d114fc62` (not ready on one must). It changes `tests/test_pin_pattern_line_break.py` and nothing else; no production file changes.

- **Composite asserts (SonarCloud `python:S9073`, the two issues at lines 164 and 173).** `member_pin` and `holder_shape` ended in `assert commit and digest, text`. Each value now has its own assert, with a message that names it (`fixture drift: no member pin commit`, `no member pin tree_sha256`, `no shape commit`, `no shape tree_sha256`).
- **Two callers had no row of their own,** so a `.rstrip("\n")` before the match in either survived every row. The new rows (the file now has 46):
  - `test_a_neutral_pin_commit_that_ends_in_a_line_break_is_refused`: the neutral product's pin in `validate-pins.py`, exit 2, `REFUSED neutral-pin-tag-only`. On main the commit passed `COMMIT_RE` and the run exited 1 with `neutral-pin-source-unreadable`, git refusing the name;
  - `test_a_neutral_pin_digest_that_ends_in_a_line_break_is_malformed`: the same pin's `tree_sha256`, exit 1, `neutral-pin-digest-malformed`. On main it was recomputed and compared (`neutral-pin-digest-mismatch`);
  - `test_a_recorded_source_commit_that_ends_in_a_line_break_is_a_finding`, two rows (`submodules`, `answered-paths`): a record row's `source_commit:` in `validate-manifest.py`, exit 1, `manifest-submodule-commit` and `manifest-answered-commit`. On main: `manifest ok`.
- **The four rows fail on main.** The new file with main's two production files checked out: **13 failed, 33 passed** (the nine of the first round, and these four).
- **Mutants,** each applied alone to the committed head, the file restored afterwards (sha256 `94f125c5f5e4b8a0` for `validate-pins.py` and `260c4742fe422597` for `validate-manifest.py`, the same as before the mutant, and clean against HEAD; `repo_shape.py` and `bootstrap.py` unchanged at `623af12131447f95` and `4102d375f4c9bc38`). The new file only:

  - S1, `.rstrip("\n")` on the neutral pin's commit before the match (`validate-pins.py` line 1790): **1 failed, 45 passed**, killed by the neutral-commit row;
  - S1b, the same on the neutral pin's recorded digest before the match (line 1800): **1 failed, 45 passed**, killed by the neutral-digest row;
  - S2, `.rstrip("\n")` on `source_commit` before the match (`validate-manifest.py` line 1264): **2 failed, 44 passed**, killed by both `source_commit` rows;
  - before this round S1 and S2 survived.
- **Runs, at head `03b2b97b9d3cccc42b4703cb2f7c978693488266`, worktree clean at each START:**
  - git 2.43.0: the new file, `tests/test_pin_validator.py` and `tests/test_manifest_validator.py`: **175 passed** in 58.03s, rc=0;
  - git 2.43.0: the trio: **170 passed** in 39.29s, rc=0;
  - git 2.43.0, extra: `tests/test_neutral_pin_validator.py` (the new rows use its helpers): **39 passed** in 99.62s, rc=0;
  - git 2.47.1 (`git --version` printed first): the new file: **46 passed** in 19.32s, rc=0.
  - The shards, the other git 2.47.1 runs and the extras above ran at `ae30dbd0e`; the two production files and the four other test files are the same at this head.

This round is one merge of main and one new commit, after rc1126's re-check: READY at `03b2b97b`, with two prose NITs (#1126 issuecomment-6340973564).

- **Merge of main:** `038dafba` (#1111), as `c7da27dc2`, with no conflict.
- **NIT 1:** `4c1f80cf4` changes only the module docstring of `tests/test_pin_pattern_line_break.py`. It now says what main did:
  - `validate-manifest.py` passed both a `shape.commit` and a `shape.digests.tree_sha256` that end in a line feed;
  - `validate-family.py` passed only the digest. Its commit gave `shape-pin-manifest-disagree` with the same hex on both sides, because `contracts/shape-pin.yaml` holds the commit without the line feed.
  - No code or row changes.
- **NIT 2:** the Mutants table above is re-measured on the 46-row file.
- **Runs at `4c1f80cf4`, git 2.43.0:**
  - the file and `tests/test_path_guard_hygiene.py`: **95 passed** (46 + 49);
  - with main's two production files checked out: **13 failed, 33 passed**, as before.

Other `^...$` patterns are used with `.match` on values read from files or arguments: `PROJECT_ID_RE`, `DATE_RE` and `QUALIFIED_RE` (`validate-manifest.py`, `validate-family.py`), `REPOSITORY_RE` (`update-shape.py`), `PIN_ARG_RE` (`shape_materialize.py`), and `NAME_RE`, `REF_RE` and `PATH_RE` (`setup-project.py`). They have the same `$` property and are outside the two patterns this issue names, so they are left for #1139, filed from this PR's review.

Review: lane 5's independent re-check, by a reviewer that wrote none of #1126: READY at `4c1f80cf`, the head that lands (issuecomment-6344730185), with no MUST, SHOULD or NIT. It checked the delta from `03b2b97b`, which rc1126 passed (issuecomment-6340973564). CI (Brett Heap's ruling 27): H0 is the head 4c1f80c itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 038dafb; and the local gate passed on its predicted squash tree df676e7 over main 8679c80: the adopt_pins, manifest_validator, pin_pattern_line_break, pin_validator, shape_doctor, repo_hygiene, agents_md_codes, cli_reference sets, 944 passed, 3 skipped. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green".

Closes #1017

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 23, 2026
…nt of the commit `--to` names, so a tag above it reads as one that reaches it and a tag below it keeps the plain words, and `--points-at` for `--contains` no longer survives (#1019) (#1107)

No test held that a tag at a DESCENDANT of the refused commit counts as a tag that reaches it, so `--points-at` in place of `--contains` survived in `bump-leg.py` and `family.py` (mutant M9 of #988's review, filed as #1019). This adds the rows the issue asks for to the two reachability test files, and no production code changes. Opened as a draft with no milestone: Brett Heap's ruling of 2026-10-21T10:32:04Z ("leave the follow-ups out of v1.2.0") held it until the v1.2.0 tag, and it is now on the v1.2.1 milestone.

Measured on main `2a119f28` before any change, and checked again on `16f9a0e81` and `7e00834c` (the tag questions did not change, only their line numbers):

- `scripts/bump-leg.py` `_remote_tag_reaches` and `scripts/family.py` `_origin_tag_reaches` list the tags with `git for-each-ref --contains <commit> --format=%(objectname) refs/tags/`. No other copy of either exists (the `--contains` in `on_a_remote_branch` and `_on_a_remote_branch` asks about branches and is not this question).
- The mutant (`--contains` -> `--points-at` on that one line in each script) SURVIVES every row on main: `test_bump_leg_reachability_words.py` 34 passed, `test_family_bump_reachability_words.py` 20 passed, 54 rows, as the issue says.
- The production code is right: the new rows pass on unmutated main. So they do not "fail on main"; they fail on the mutant, which is the issue's own acceptance ("With `--points-at`, that row must fail"). No defect was found.

`tests/test_bump_leg_reachability_words.py`:
- `made_in_the_leg(project, name, on="HEAD")` takes an optional `on`, the commit the new commit is made on top of. Every existing call is unchanged.
- `test_a_tag_at_a_descendant_of_the_commit_reaches_it`: a commit `made` and a child `above` on top of it, on no branch of the leg's remote. The leg and the remote each tag `above` (`v1-w1019`), made apart, in three kinds: both annotated (the reviewer's case J), leg lightweight and remote annotated, and the reverse. Both runs (dry run and real) refuse `made` `bump-leg-commit-local-only` in the TAG words, through `origin` and through `--local-remote-dir` (3 kinds x 2 routes = 6 rows), with nothing moved and the leg's own tag as it was. The premises say the tag names the child, that no tag is AT `made` (`for-each-ref --points-at`), and, through #1087's `only_a_tag_reaches`, that a tag of the remote reaches it, no branch does and no remote-tracking ref of the leg does.
- `test_a_tag_at_an_ancestor_of_the_commit_alone_keeps_the_plain_words`: the control (x 2 routes). The commit named is the child, and the leg and the remote tag only its parent. The plain words stay, "a pin the rest of the world cannot fetch" included.

`tests/test_family_bump_reachability_words.py`:
- `made_on(holder, env, parent, name)`: a commit never pushed, made in the mount on top of `parent`, through `commit_in_mount` (which checks `parent` out first), so the holder stays clean.
- `test_a_tag_at_a_descendant_of_the_commit_reaches_it`: the same three kinds (3 rows), both `bump --dry-run` and the real `bump` refuse `member-to-local-only` in the tag words, with nothing moved and the mount's own tag as it was.
- `test_a_tag_at_an_ancestor_of_the_commit_alone_keeps_the_plain_words`: the control (1 row).

No assert joins conditions with `and` or `or`; each premise is its own assert. No README, docs, AGENTS.md or handbook text names these tests or their row counts (grep), so none changes.

| Mutant | Rows before (main's) | With these rows |
| --- | --- | --- |
| `--points-at` in `bump-leg.py` | survives: 34 of 34 pass | killed: 6 failed, 36 passed (all six descendant rows; the two controls pass, as they should) |
| `--points-at` in `family.py` | survives: 20 of 20 pass | killed: 3 failed, 21 passed (all three descendant rows) |
| `--merged` in `bump-leg.py` (extra: a check read the other way round) | survives: 34 of 34 pass | killed: 8 failed (6 descendant rows and both controls) |
| `--merged` in `family.py` (extra) | survives: 20 of 20 pass | killed: 4 failed (3 descendant rows and the control) |

The failure is the one the issue names: the plain words ("is reachable from no branch of ...") where the tag words ("and a tag of ... reaches it, but no branch ... does") are expected. Logs: `r-mut-points-at-bump.log`, `r-mut-points-at-family.log`, `r-mut-merged-bump.log`, `r-mut-merged-family.log` (and `mut-main-*.log` for the 34 and 20 on `2a119f28`).

On the pushed head `01fcb259`:
- git 2.43.0 (`/usr/bin/git`): the two test files, 66 passed (42 + 24; 34 + 20 before).
- git 2.43.0: the trio `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` and `tests/test_update_shape_kept_helper.py`, 329 passed.

On `1201680f` (the same two files on top of `16f9a0e8`, before `7e00834c`, whose three files no row of mine touches or runs):
- git 2.43.0: the trio alone, 170 passed; the two test files that import helpers from mine (`test_bump_leg_fetch_refspecs.py`, `test_family_bump_fetch_refspecs.py`), 102 passed; the two test files, 42 and 24 passed.
- git 2.47.1 (`$Q/v115/w764/git247/bin/git` first on PATH, `git --version` printed `git version 2.47.1`): the two test files, 66 passed.

No script changed, so no other test runs a changed script.

- `git merge-tree --write-tree HEAD origin/main` against a re-fetched `origin/main` (`7e00834c`, which is also the merge base): exit 0, tree `3a852e87fcfda41766ece8c1771e47689c622ad6`, equal to this head's tree. Against `16f9a0e8` before the last rebase: exit 0 too.
- Open PRs read with `gh pr list --repo opensoft/openRepoShape --state open --json number,headRefName,isDraft,files`: 8 (#1102, #1101, #1098, #1097, #1091, #1090, #1088, #1075). None touches `tests/test_bump_leg_reachability_words.py` or `tests/test_family_bump_reachability_words.py`, so there is no merge-tree to run against any of them.

- **The NIT.** The docstrings of the two descendant rows said "Every row before this one tagged the commit itself, so ... survived all 54 of them". 54 counts the two files together (34 + 20), several earlier rows tag nothing (the no-`origin` and prune rows), and a count goes stale with the next row.
- **The fix, docstrings only.** Each now says only what holds: "No row before this one tags a descendant of the commit: the tags in them are at the commit itself or below it, so `--points-at` in place of `--contains`, the mutant M9 of #988's review, survived every row of this file." No row, assert or helper changed. The 54 in "What still held on main" above is the two files together, as it says.
- **One commit on top of `01fcb259`, a fast-forward push, no rebase:** head `ee48b5fe9693ebda099d1c0aadaca7bdc16b91c1`, read back with `git ls-remote`. Its tree is `615e571bb62fd1cf481e4152278b0e3628bab059`.
- **Runs** (git 2.43.0, through `run9.sh`, `EMAIL=service@opensoft.dev`): `tests/test_bump_leg_reachability_words.py` 42 passed, `tests/test_family_bump_reachability_words.py` 24 passed. They ran on `7186f3cf`, the same tree as the pushed head; only the commit message was shortened after.
- **Merge-tree** against a re-fetched `origin/main` (`6da757cb`, merge base `7e00834c`): exit 0, tree `453566df33c886cf7fc2b1c39af02acbbff365dc`. Main moved by nine files, none of mine; its `scripts/bump-leg.py` change (9 lines added) leaves `_remote_tag_reaches` and its `--contains` line alone (now line 1214).

Review: rv1104-1107, lane 5's independent opus reviewer, which wrote none of #1107: READY at `ee48b5fe`, the head that lands (issuecomment-6318124100), with no MUST or SHOULD and one optional test NIT that needs no round. CI (Brett Heap's ruling 27): H0 is the head ee48b5f itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 7e00834; and the local gate passed on its predicted squash tree 2f7f587 over main 422a590: the bump_leg_reachability_words, family_bump_reachability_words, repo_hygiene, agents_md_codes, cli_reference sets, 237 passed. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green".

Closes #1019

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Nov 8, 2026
…s message spells a path byte that is not UTF-8 as `_shown` does, an escape, and no longer says it names it with a `?` in its place, which #900 replaced (#1018) (#1104)

`env_commit`'s docstring in `scripts/shape_materialize.py` says what the split commit's message does with a `root` or `drop` path that holds a byte that is not UTF-8, as the code does since #900. It said the message names such a path "with a `?` in place of that byte, a path that does not exist". That was true before #900, and it is not true now. A reader of the docstring is told about a `caf?.md` the tool no longer writes.

Found by lane 1's writer on #900 and filed as #1018. Lane 5 took it outside v1.2.0, under Brett Heap's ruling of 2026-10-21T10:32:04Z.

Measured on main `2a119f281` and again on `16f9a0e81` (main after #1087), with a small script that calls: `adopt-project.py`'s own `_split_message` and `_shown`, then `env_commit` and a real `git commit`, read back with `cat -A`.

- The issue's claim held: the docstring (about line 2103 when filed, line 2268 on `2a119f281`, line 2349 on this branch) still said the message names the byte with a `?`.
- The code does not. `_shown("caf\udce9.md")` is `` `caf\udce9.md` ``, and the split message for a `root` and a `drop` entry named that holds no lone surrogate. Through `env_commit` and `git cat-file commit HEAD`, `cat -A`:

  ```
  DROPPED (in no leg and no longer here):$
    `caf\udce9.md`$
  STAYS IN THE ASSEMBLY ROOT:$
    README.md$
    `caf\udce9.md`$
  ```
- What stays true of `env_commit` itself: a message that holds the lone surrogate, handed to it directly, is still written as `?`. `cat -A` reads `Name: caf?.md$`. So the `errors="replace"` paragraph is kept, and what changes is who it describes.
- #1087 (main `16f9a0e81`) changed `scripts/shape_materialize.py` by 128 lines, none of them in `env_commit`, `COMMIT_COMMAND` or the message, and changed nothing in `adopt-project.py` (0 diff lines). The rebase onto it was clean and the docstring needed no other change.

One file, `env_commit`'s docstring, in two paragraphs: the `errors="replace"` paragraph (round 1, 11 lines added and 7 removed) and the sentence above it (round 2, below; 3 added and 2 removed). Together 14 added and 9 removed against main `6da757cb`. The `errors="replace"` paragraph now says:

- encoding, a character UTF-8 cannot carry is written as `?`, and nothing raises after both legs are pushed, as before;
- `adopt-project.py` refuses a lone surrogate in a path it extracts (`entry_path_problem`), and a `root` or `drop` entry that holds one is listed in the split message but never raw: the message names each path as `plan`'s report does (`_shown`, #900), so the byte 0xE9 reads as an escape with no surrogate in it, `` `caf\\udce9.md` ``, the path the source holds;
- written raw it would be a `?`, `caf?.md`, a path that does not exist, which is what `env_commit` still writes for a lone surrogate it is handed.

The backslash is doubled in the source, as the neighbouring docstrings write `\\udcc3`. A single `\u` in a non-raw docstring would be an escape that makes a lone surrogate. Measured: the docstring holds no character from U+D800 to U+DFFF and holds the six characters `\udce9`. The longest docstring line is 78 characters.

**The code is unchanged.** A script compares the AST of `scripts/shape_materialize.py` with every docstring blanked: the branch's file is identical to `origin/main`'s after the rebase onto `16f9a0e81` (round 1), and the merge of this head into `origin/main` `6da757cb` is identical to `6da757cb`'s file (round 2).

**No pin moves.** `scripts/shape_materialize.py` is not a pinned copy. `COPIED_VERBATIM`, `COPIED_FROM_SHAPE`, `FAMILY_COPIED_VERBATIM` and `FAMILY_COPIED_FROM_SHAPE` do not list it, and `templates/assembly-root/scripts/validate-pins.py` says of it "which no project carries". So no `upstream-changed` row, no digest, no assembly copy and no `tests/test_pinned_copy_text.py` entry changes.

**No other text pins these words.** Searched in `tests/`, `README.md`, `docs/`, `docs/handbook.html`, `AGENTS.md` and `templates/`: no test reads `env_commit`'s docstring (`__doc__`, `getdoc`), and none of them holds "in place of that byte" or "a path that does not exist" for this. The other places that name the `?` are about this function's behaviour or about history, and are still true: `_split_message`'s docstring says `caf\\udce9.md` "was `caf?.md`" (before #900), `_refused_by_policy` already says the message "lists it as `_shown` does (#900)", and `tests/test_adopt_spelled_names.py` quotes the message as it was on `c7a3ff16`. The `?` in `argv_text`, `trailer_line` and the commit-trailer text is about a `--trailer`, not the split message.

No rows and no mutants: the change is a docstring, the code is the same AST, and no test pins the docstring. So there is no row to fail on main and nothing to mutate.

All with `$Q/v110/run9.sh`, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, the stub `gh`, a fake token, git 2.43.0 (`/usr/bin/git`).

Two shards, run before the rebase, at the `2a119f281` base with this one-file change in the tree. Together they are every `tests/*.py` that names `shape_materialize` or `env_commit` (86 files, including `tests/test_adopt_spelled_names.py` and `tests/test_adopt_entry_paths.py`, which run `env_commit` and the #900 message) and `tests/test_agents_md_codes.py`:

| run | files | result |
|---|---|---|
| shard A | 43 | 2873 passed, 4 skipped in 1707.51s, rc=0 |
| shard B | 44 | 4611 passed, 31 skipped in 2462.01s, rc=0 |

After the rebase onto `16f9a0e81`, on the committed head `e32b2703d733f6c60c1b2cba2d62d164d93d7b9a` (`dirty=0`): the trio, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`, **170 passed in 55.67s**, rc=0. The docstring-only change needed no new shards: the shards' counts are runs at the `2a119f281` base, and #1087 did not touch `env_commit`.

Round 2, on the committed head `de89aa81b1533e32f1007f991aeae05cd915b36d` (`dirty=0`): the same trio, **170 passed in 61.77s**, rc=0. The round 2 commit is one docstring sentence, so it needed no new shards either.

git 2.47.1: no run. The change adds no test file of its own, so there is none to run on it.

`git merge-tree --write-tree HEAD <other>` with HEAD at `de89aa81b1533e32f1007f991aeae05cd915b36d`, after a re-fetch of `origin main` with `-c gc.auto=0 -c maintenance.auto=false` (round 2, about 14:03Z on 2026-10-21):

| against | result |
|---|---|
| `origin/main` `6da757cbe9a4eb66d60c34f38dcc2b332d175e8b` | clean, rc 0, tree `15c11b6c959fc4c7540105d5d62e3f961c8e27fd` |
| #1105 `fix/1048-makefile-follow-up-gnu-read-order` `5a4de7f763b5a3c32adce9614a3a539508416eb4`, an open PR that touches `scripts/shape_materialize.py` | clean, rc 0, tree `db05aead67052643942e35a9689d9dd4a0873fda` |
| #1118 `fix/1089-named-link-skip-tests` `c60066cbeec18b8b6c926e254ed4bee6b9a486c2`, the other open PR that touches `scripts/shape_materialize.py` | clean, rc 0, tree `a40df3b63c32c2a3d8f4c40587b094940c8d6567` |

Read with `gh pr list --state open --json number,headRefName,headRefOid,files`: 16 open PRs, three of them (this one, #1105 and #1118) touch `scripts/shape_materialize.py`, the only file this change touches. The other 13 (#1120, #1119, #1117, #1111, #1109, #1107, #1106, #1102, #1101, #1098, #1097, #1091, #1088) touch no file this change touches.

The same command with HEAD at the reviewed head `e32b2703` gives rv1104's trees: `de31d857` against `6da757cb` and `195ffe19` against #1105. The round 1 merge against #1090 is moot: #1090 has landed in `6da757cb`.

Held as a draft until the v1.2.0 tag; it is now on the v1.2.1 milestone.

- **NIT 1, `scripts/shape_materialize.py` lines 2340-2343, a line round 1 did not change.** The paragraph above the reworded one said the adoption's split message "names each entry path as the source spells it". Since #900 that is true of a printable path alone: `_split_message` lists any other through `_shown`, as the escape the next paragraph describes. It now says "as the source spells it (a printable one; see below)". One new commit on top of `e32b2703`, `de89aa81b1533e32f1007f991aeae05cd915b36d`, 3 lines added and 2 removed, docstring only, pushed as a fast-forward (no rebase, the PR is open).
  - Measured: the AST with docstrings blanked is identical to `e32b2703`'s and, merged into `6da757cb`, to `6da757cb`'s. The docstring holds no lone surrogate, still holds the six characters `\udce9`, and its longest line is 78 characters.
  - Searched `tests/`, `README.md`, `docs/`, the handbook mirror, `AGENTS.md`, `templates/` and the scripts for "names each entry path", "as the source spells" and the new words: only `tests/test_adopt_entry_paths.py` line 233 matches, a test's docstring about `execute` handing each path on as the source spells it, which is another sentence and pins nothing here.
- **NIT 2, this body.** It named main as `16f9a0e81` and #1090 as the one open PR that touches the file. #1090 has landed. Main is `6da757cb`, and the merge-trees above are redone against it, against #1105 and against #1118, a draft that opened since and also touches the file.

Review: rv1104-1107, lane 5's independent opus reviewer, which wrote none of #1104: READY at `de89aa81`, H0 of this landing (issuecomment-6318111413), with no finding; NIT 1 and NIT 2 of the round before are fixed. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 de89aa8 passed tests, tests-git-bash and SonarCloud; the head 92285d4 is a clean update-branch merge of main b66d8f0, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 6bd2128 over main b66d8f0: the repo_hygiene, agents_md_codes, cli_reference sets, 171 passed. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green".

Closes #1018

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Nov 8, 2026
… and the `add` that `bump`'s `member-unknown` names carry `--family-root <holder>`, quoted for the host, so each runs as printed (#1005) (#1117)

## What

`remove` and `add` require `--family-root`, as `bump` does. #1002 gave every remedy that names `family.py bump` the holder and left three that name another verb without it. Typed as printed, each ended in `family.py <verb>: error: the following arguments are required: --family-root`. Each now names `--family-root` and the resolved `family.root`, spelled by `shell_quoted`, as #1002's `bump` line and `_add_command` are.

- **`scripts/family.py`:**
  - `add`'s `member-duplicate-id` (`_checked_member_id`): the `remove` it names is now `family.py remove --family-root <holder> --member <row>`. Its `bump` line was already right.
  - `bump`'s own `member-duplicate-id` (`_checked_bumped_id`): the `remove` it names is now `family.py remove --family-root <holder> --member <held row>`.
  - `bump`'s `member-unknown` (`cmd_bump`): the `add` it names is now `family.py add --family-root <holder> --member <org>/<P>`. `<org>` stays a blank for the person to fill in, as `<commit>` does.
- **`README.md` and `docs/handbook.html`:** the "Windows, natively" sentence now names `family.py`'s `remove` line beside `bump`, since the new lines quote the holder for the shell they are printed in (`add` was already named). The handbook's seven `reads README.md` markers for the README section carry its new hash.
- **`tests/test_readme_windows_quoting_sentence.py`:** the `NAMED` table counts the new host-dialect calls: `_checked_member_id` 2, `_checked_bumped_id` 1, `cmd_bump` 1. CI's full suite is where that table fails first, as it did on #1002, so it is in the gate here.

The lines start with a bare `family.py`, as #1002's `bump` line does and as the lines in the same remedy sentence do. `validate-family.py`'s template copy already prints `--family-root` and the interpreter and script path, and is unchanged.

## Tests

All runs used `/usr/bin/python3 -m pytest` under a stub `gh`, a bogus `GH_TOKEN`, an empty `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL` an empty file, `GIT_CONFIG_NOSYSTEM=1` and no `GIT_SSH_COMMAND`.

- **`tests/test_remove_add_remedies_family_root.py`, 45 rows:** each of the three refusals, over a holder in a plain folder, under `my work`, under `Dana's work`, reached by a relative `--family-root`, and reached through a symlink (skipped on Windows). Each checks three things:
  - the printed line is the expected text, and the host's own word-splitting gives the holder back as one word;
  - `family.py`'s own parser accepts it, with `<org>` filled in;
  - typed as printed from a folder that is not the holder's, it exits 0, leaves the holder clean and its gate green, and does what the remedy says: the refused `add` then goes through, or the refused `bump` then moves the row, or the member the refused `bump` named is in the family.
- **Against main `2a119f281`:** all 45 fail and all 45 pass on this head. The `parses` and `runs` rows fail on `the following arguments are required: --family-root`, and the `carries the holder whole` rows fail on the printed line.
- **`tests/test_family_add_member_id.py` and `tests/test_family_bump_to_checks.py`** follow the new words. The former's `remove` row now runs the printed line as it is, with no `--family-root` added by the test.
- **Gate, at head `10dc011c9`, 1293 passed and 0 failed in three runs:** 927 (the new file, the doc and quoting files, and the family files that run `add`, `bump` and these codes), 178 and 188 (the validate-family, remedy and holder-state files).
  - Doc files in the gate: `test_readme_windows_quoting_sentence`, `test_repo_hygiene`, `test_readme_sections`, `test_setup_by_hand_commands`, `test_agents_md_codes`, `test_update_add_codes_documented`, `test_family_docs_one_spelling`, `test_family_printed_quoting` and `test_git_bash_dialect`.
- **Mutants, 16 of 16 caught, each restored with its sha256 checked:** each remedy without `--family-root`; each with the holder unquoted; the wrong `--member`; `<org>/` dropped; each `shell_quoted` call told `'posix'` (the `NAMED` table); README or the handbook without `remove`; the handbook marker with the old hash; and the `NAMED` table without the new counts.

## Merges

- **Main** `6da757cbe`: clean.
- **#1098** (`scripts/family.py`): clean.
- **#1101** (README and handbook, a lane 3 draft): conflicts in `docs/handbook.html` only, at the seven `reads README.md` markers of its `Starting a project in a new organisation` section, because both change that README section and so its hash. It is the known serialization of two README pull requests: the second to land recomputes the hash from the merged README.

## Notes

- Outside this issue: `member-already-present`'s remedy says "`remove` takes it out" as a bare verb with no line to type. It is not one of the issue's three, so it is unchanged.

Closes #1005

Lane: openreposhape-4 (openRepoShape-4)
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