Skip to content

Pinned shape copies: one bump, so AGENTS-shape.md is true of every root, the scaffold's chain WARNING offers only its own sources and spells --pin-source, the root's not-used note spells it too, and THE LOCKSTEP RULE's bump runs (#610) - #634

Merged
brettheap merged 10 commits into
mainfrom
fix/610-pinned-text
Oct 16, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-2 (openRepoShape-2)
Claim: #610 (comment)

Issue #610 puts the pinned shape copies' text into one shape bump (the checklist triage's D6, which Brett Heap approved at 2026-10-15T16:49:38Z), so every project re-pins once for v1.1.0. The branch was made from origin/main 396c0ff4, the squash of #570. #575 and #576 are in.

Rounds. 40a85e1c was rv610's review head. This head adds a merge of origin/main f6376d74 (6e3dd1d7: #625, #583, #596 and #614; no file in common with this PR, and git merge-tree was clean), and the fix commit 030fde07 for rv610's findings F1, F3, F4, F5 and F6 and the writer's extra find (a). F2 waits (see "Overlap with open PRs").

Round of 2026-10-16 (f634s): rv634r's R1. rv634r re-verified 030fde07 and cleared everything the fix round spelled. One way through remained for a path the root's validate-pins.py prints (a nit, older than this PR, not a regression): the neutral-pin-source-unreadable finding ends in exc.detail, and for a git-failed that detail is built in repo_shape.git_out with the folder git ran in as it was, followed by git's own stderr as it came. This head is the merge of origin/main 4cf5fb07 (226fe7ea, clean, tree 912b92da, as rv634r predicted) and ONE fix commit, e5ea529b. The re-verify of that round (rv634s, MERGEABLE WITH NITS) kept the one-line rendering for every caller: it found no code that parses git_out's detail, most callers embed it mid-sentence, and spelling only the echoed path cannot work, because git also echoes a gitdir read from a .git file's contents. Its nits were wording: the comments said git 2.43 replaces "a control character", where 2.43 replaces an ESC, a CR and a BEL and passes a line break and a C1 control through. 6dad64bc corrects the git_out docstring and the test comment, with no code line changed, and this body and the squash message say the same.

  • The repro, on 030fde07. A scaffolded root with a neutral pin on openXdox, and validate-pins.py --pin-source <folder> where <folder> exists, is no repository, and is named bare-x<ESC>[31mRED: the finding printed `git ls-tree -r -z <commit>` in <folder> exited 128: fatal: not a git repository with the ESC raw. A name holding a line break and WARNING forged-line: pwned began a forged line; a CR and a BEL went through raw too. rv634r's driver (probe_esc.py, 25 prints) found 4 raw on 030fde07 and 0 on its prototype; on this head it is 0 of 25.
  • The fix, in scripts/repo_shape.py's git_out only. The folder is named with shown_path (as it is, repr where it is not printable), and what git printed is spelled with spelled as ONE line: a control character, a line break and a C1 control are the escape Python spells them, and printable text, UTF-8 included, is unchanged. No pinned script changes and repo_shape.py was already in this bump, so the re-pin is still one (D6 below).
  • Why git's stderr as well. The measured answer on git 2.43.0 (driver git_stderr_echo.py, four hostile names, a printable one, and nine ways a source fails): for a source that is no repository, one that lacks the commit and a bare repository that lacks it, git's message holds no path (not a git repository (or any of the parent directories): .git, not a tree object). A bare repository that has the commit answers, and a file instead of a folder is refused before git runs (it is a file, not a directory, measured on this head with an ESC in the file's name: clean). But git echoes the folder it reads when .git is no gitfile (invalid gitfile format: <folder>/.git), when a gitfile names a missing gitdir (not a git repository: <gitdir>) and for a dubious-ownership repository (detected dubious ownership in repository at '<folder>'). 2.43.0 replaces an ESC, a CR and a BEL in that echo with ?, and passes a line break and a C1 control (U+0080 to U+009F) through (the C1 case measured by rv634s), so a folder named with a line break still began a forged line from git's own words, and a C1 control reached the terminal raw. A git that does not replace an ESC, a CR or a BEL passes those through too (not measured: only 2.43.0 was at hand). So spelled escapes every control character, a line break and a C1 control included, whatever git the machine has.
  • What the claim is, and is not. The detail is one line: a line break git itself printed between two lines of its message is \n now, so a person cannot be handed a line the tool did not print, and a real two-line message reads on one line. That is git_out's detail for EVERY caller of git_out (for example the pin-commit-unresolvable text, validate-family.py's, family.py's, adopt-project.py's), not only the validate-pins.py finding. git_out's arguments are named as the caller wrote them (here a 40-hex commit the pin check has validated). tree_digest_from_gh's reason, which is gh's own, is not built there and is untouched.

Round of 2026-10-16 (f634u): main with #638, the remedies keying fix. Three merge commits, and no other commit:

The first two used commits already in the shared store. The third came from one plain fetch with no maintenance, made once #639 landed.

The shape bump

This repository holds no digest for a pinned copy. Each root's contracts/shape-pin.yaml records the sha256 of each copy as it was written. update-shape.py check marks every copy whose upstream bytes changed as upstream-changed, and apply re-copies and re-pins it. So the bump is this: every pinned copy the issue changes moves in this one PR. No test pins a 64-hex digest (git grep -E '[0-9a-f]{64}' tests/ finds none). #615 bumped the same way.

pinned copy assembly root family holder
AGENTS-shape.md (assembly root's) changed —
scripts/repo_shape.py changed changed
scripts/validate-pins.py changed —
scripts/validate-repository-naming.py changed (F6, fix round) —
scripts/validate-family.py — changed

Measured (re-measured on 030fde07 in the fix round, and again on e5ea529b in the R1 round: see "Tests"). An assembly root was scaffolded at 396c0ff4, a family holder was inited at 396c0ff4, and so were two adopted roots (legs/spec, impl/code): one whose source has no Makefile and one whose source keeps its own. Each was then run through this branch's update-shape.py check and apply (--upstream <clone> --at <this head>):

  • Assembly root (scaffolded, and both adopted). check lists upstream-changed for scripts/validate-pins.py, AGENTS-shape.md, scripts/repo_shape.py and scripts/validate-repository-naming.py, and 7 unchanged; exit 1. The fourth row is new in the fix round: F6 edits the naming gate, which is a pinned copy, so the set grew from three files to four, still in this one re-pin. apply copies the four and re-pins contracts/shape-pin.yaml and project.yaml. The root's own validate-pins.py, validate-manifest.py and naming gate pass, and apply exits 0. Each copy is then byte-identical to this head's template. A second check lists unchanged 11 and says nothing to do; exit 0. In the adopted root whose source kept a Makefile, the root's Makefile holds no CONTRACTS_DIR, shape/Makefile holds it, and make contracts-dir says No rule, as the new AGENTS-shape.md sentence says.
  • Family holder. check lists upstream-changed for scripts/validate-family.py and scripts/repo_shape.py, and 8 unchanged; exit 1. apply copies the two, re-pins shape-pin.yaml and family.yaml, and rewrites the README's Shape: line. family ok, and apply exits 0. A second check lists unchanged 10 and says nothing to do.
  • Re-measured on e5ea529b (R1 round), same drivers, roots made at 396c0ff4. git diff --stat origin/main...HEAD lists the same 11 files as 030fde07's (scripts/repo_shape.py is +19 lines more), and the six other non-test files are byte-identical to 030fde07's. The assembly roots (scaffolded, and both adopted) list upstream-changed 5, unchanged 6: this PR's four, and scripts/validate-manifest.py, which origin/main moved after 396c0ff4 (adopt-project.py, shape-doctor.py, validate-manifest.py: execute records the plan's answers under answered_paths: and a moved submodule names its row, so an answered examples/ is not asked again and a deliberate move names no plan (#525) #617, merged here as part of 4cf5fb07) and this PR does not touch. apply copies the five, re-pins, the roots' own gates pass and each copy is byte-identical to this head's; a second check lists unchanged 11. The family holder is the same: upstream-changed 2, unchanged 8, apply rc 0, family ok, unchanged 10 after. No row is a conflict, and no pinned file is added by this round.

No row is a conflict. An adopted root's Makefile, the one copy that is not verbatim, is not touched here.

The lines

L2-33. AGENTS-shape.md said that "the root Makefile exports $(CURDIR)/spec/contracts once". That is false for a scaffolded root, whose Makefile exports nothing (make contracts-dir: No rule to make target 'contracts-dir'). Since #572 it is also false for an adoption whose spec leg is not at spec, which exports the spec leg where it is mounted. The sentence now names no path:

Where this root was ADOPTED, the shape's Makefile exports CONTRACTS_DIR once, as the contracts/ of the spec leg at the path project.yaml mounts it, and make contracts-dir prints it. Where the source kept a Makefile of its own, that block waits in shape/Makefile until the two are merged. A SCAFFOLDED root's Makefile exports none: its legs start from the scaffold's seed, with no contracts/, so nothing reads across them yet.

The file has no {{PLACEHOLDER}}, so it is still a verbatim copy.

rv610's F1 and F5 changed this sentence in the fix round. The first version said an adopted root's Makefile exports CONTRACTS_DIR. A source that keeps a root Makefile of its own keeps it AT THE ROOT (root-project-makefile), and the shape's Makefile, with the block, lands beside it as shape/Makefile; there make contracts-dir says No rule to make target until a person merges the two (main's sentence was false there too). The second version names that case. It also drops "its legs start empty", which is loose: the scaffold seeds both legs (a README, AGENTS.md, CLAUDE.md, a .gitignore and requirements/ or src/), and seeds no contracts/.

576/2. The scaffold's declared-unverified WARNING offered "a sibling checkout, SHAPE_PIN_SOURCE_, or --link-source =". Those are the naming validator's sources. The scaffold has no --link-source and reads no sibling checkout (link_pins_from_trees(chain, None, ...)).

L2-30. link_declarations named the manifest it reached exactly as it was written, and the scaffold prints that reason in the WARNING. A --pin-source holding x\x1b[31mRED therefore put a raw ESC on stderr. A path holding a line break also started a line of its own (WARNING forged-line: …).

  • Every reason now names the manifest with the new repo_shape.shown_path: repr where the path is not printable, and the same bytes where it is.
  • That includes the YAML reader's own yaml-unparsable and not UTF-8 details, and Permission denied.
  • shown_path is the spelling pin-source-unreadable gives a --pin-source. shape_materialize.shown_source now returns it, so the scaffold's two lines about one path cannot spell it two ways.

L2-31. The root's validate-pins.py printed an unused --pin-source exactly as typed in its "not used" note. It did the same for the later value that replaced it, and for the key of a value read as a PATH. Each is now spelled the same way, by a local _shown. It is written in validate-pins.py and not imported, because a root whose repo_shape.py is older has no shown_path, and the gate would end in an ImportError.

The writer's extra find (a), folded in (fix round). The same file, validate-pins.py, printed two more values raw: the neutral-pin-source-unreadable finding named its source as typed, and _also_a_directory, the line for a keyed value that is also a directory whole, named the value as typed twice, in the sentence and in the ./ command. Each is spelled by the local _shown, so an ESC in the value no longer reaches the terminal at those three prints and a line break no longer starts a line of the value's choosing there. A printable value is named byte for byte as before. The finding's last words, exc.detail, are not part of that: for a git-failed they were built in repo_shape.git_out, raw, until rv634r's R1 (the round above).

F6, the naming gate (fix round). link-source-unreadable named the tree it asked raw, beside a reason that names the manifest with shown_path: one line, two spellings, and a raw ESC still on stderr. The path is now spelled by a local _shown in scripts/validate-repository-naming.py, written there and not imported for the reason validate-pins.py gives (a root's older repo_shape.py has no shown_path, and the gate would end in an ImportError). One line is changed. Its twin for an unanswered source, and the naming gate's other raw prints of a --link-source, stay raw: they are finding (c) below.

L2-39. THE LOCKSTEP RULE in the holder's validate-family.py named scripts/family.py bump --member <Project> --to <commit>. That line has no --family-root, which bump requires. It also has no branch, and since #487 a real bump refuses the holder's tracking branch. _lockstep(root, project, id) now gives the rule:

Each member-gitlink-mismatch and member-digest-mismatch names its own member, using the row's id: in the branch name, as family.py bump suggests. The closing count says <Project> in both places, because more than one member may be out of step.

Tests

New file tests/test_pinned_copy_text.py. Tests were also added to tests/test_validate_family_branch_step.py (L2-39) and tests/test_adopt_contracts_dir.py (L2-33, adopted half). One test in tests/test_naming_policy.py was changed honestly: test_scaffold_keeps_its_old_warning_for_a_pin_source_with_no_manifest pinned the old remedies. It is now test_scaffold_offers_its_own_sources_for_a_pin_source_with_no_manifest, and also asserts that neither --link-source nor sibling checkout is printed.

The fix round's tests (030fde07):

  • F1: test_the_pinned_agent_file_says_where_a_kept_makefile_leaves_the_block adopts a source with a root Makefile, then asserts the sentence, the root's Makefile, the block in shape/Makefile, and what make says in each. F5: test_a_scaffolded_roots_legs_start_from_the_seed_with_no_contracts; and test_a_scaffolded_root_is_told_its_makefile_exports_none pins the new sentences.
  • F3: test_the_rule_names_the_rows_id_through_the_gate goes through the gate with mount_twice's second row, project IRRS2 recording the id irrs, whose gitlink moved alone: the branch is family/bump-irrs, and where the row records no id:, family/bump-irrs2.
  • F4: test_a_shared_variable_is_offered_to_neither_link is parametrized with a mixed-case pair, Open-X and open_x.
  • F6 and (a): test_the_naming_gate_spells_the_link_source_as_it_spells_the_manifest (ESC and a forged line) and its printable control; test_an_unreadable_pin_source_and_its_directory_twin_are_spelled (ESC and a forged line) and its printable control.
  • test_the_pinned_scripts_spell_a_path_as_the_library_does: each script's _shown agrees with shown_path over eight values. test_no_pinned_script_imports_what_an_older_library_lacks: none of validate-pins.py, the naming gate and validate-family.py imports shown_path, which an older repo_shape.py lacks.

The R1 round's tests (e5ea529b), in tests/test_pinned_copy_text.py, with the names SOURCE_NAMES (an ESC, a forged line, a CR, and an OSC title ended by BEL):

  • test_a_pin_source_that_is_not_a_repository_is_named_spelled runs the root's validate-pins.py over a --pin-source folder that exists and is no repository, and asserts that no character but a line break that str.isprintable refuses reaches stdout or stderr, that no line begins with the forged text, and that the finding names the folder as shown_path spells it. (run_script reads text with universal newlines, so a raw CR arrives as a line break: the forged line it began is what shows it.)
  • test_what_git_says_of_a_pin_source_is_spelled_too runs it over a folder whose .git is no gitfile, where git itself echoes the folder.
  • test_git_out_names_the_folder_it_ran_in_spelled and test_git_out_spells_what_git_printed_as_one_line call git_out against a stub git that prints given bytes and exits 128 (a sh script, so not on Windows), so they do not depend on the git on the machine: four hostile folders, and seven stderr texts (ESC, a forged line, CR, BEL with an OSC title, a C1 CSI, git's own two-line message, and a printable one with UTF-8 and backslashes that is unchanged).
  • Controls: test_a_printable_pin_source_that_is_not_a_repository_is_named_as_typed (plain and caf\u00e9).

The R1 round's runs (2026-10-16, on e5ea529b's content unless said; the working tree was the commit, and the two changed files' sha256 prefixes were the same before and after the run):

run result
ONE pytest process over 49 files: rv634r's item-6 set (test_pinned_copy_text, test_validate_pins*, test_neutral_pin_validator, test_template_text_placeholders, test_update_shape*); every tests/*.py naming git-failed or exited 128; test_naming_policy, test_validate_family_branch_step, test_adopt_contracts_dir, test_manifest_validator, test_shape_doctor, test_repo_hygiene, test_cli_reference; and eight more files that assert git's fatal: words (test_family_dry_run, test_adopt_filter_repo_failure, test_family_bump_to_checks, test_adopt_relative_submodule_url, test_adopt_submodule_url_answers, test_family_add_excludes, test_adopt_git_rm_bytes, test_family_printed_quoting) 2770 passed, 6 skipped, 0 failed in 19m56s
bite: scripts/repo_shape.py put back to 030fde07's bytes (sha256 3758504d), the file's 82 tests run, the fix restored (sha256 bb118134, checked) 18 failed, 64 passed: every one of the 18 is an R1 row, and the 3 printable controls pass
rv634r's probe_esc.py driver (25 prints) on this head 25 clean, 0 raw (4 raw on 030fde07)
the brief's repro: validate-pins.py --pin-source "$PWD/bare-x<ESC>[31mRED", read with cat -v no ^[; the folder is '...bare-x\x1b[31mRED'
D6, re-measured with update-shape.py check, apply, check (above) the same pinned files as before this round, plus validate-manifest.py from main's #617
f634u, the a5245427 merge as staged before its commit b7472dff: ONE pytest process over 26 files. These are the brief's set (test_pinned_copy_text, test_validate_pins*, test_neutral_pin_validator, test_template_text_placeholders, test_naming_policy, test_link_source_chain_defaults, test_manifest_validator, test_validate_family_branch_step, test_adopt_contracts_dir and #638's test_chain_link_fold), every tests/*.py naming git-failed or exited 128, and #635's and #640's own new files (test_yaml_indicator_openers, test_clone_url_bytes) 1697 passed, 0 failed in 12m48s
f634u, 551ba4da (the 95780dd0 merge, committed and not yet pushed): ONE process over 54 files. These are the 26 above, #637's test_family_switch_branch_exists, every test_validate_family* and test_family*, and the five files that read the holder's THE LOCKSTEP RULE (test_doctor_gitlink_repairs, test_doctor_legs_members_next, test_doctor_next_is_a_command, test_doctor_validator_rows and test_pin_validator) 2795 passed, 19 skipped, 0 failed in 41m32s
the same 54 files on that merge BEFORE it was committed, stopped at 33% 3 failed, all in test_family.py's update-shape rows for a family root. The cause was the test setup, not the code: family.py init pins HEAD and copies the working tree, so check read the uncommitted validate-family.py as not verbatim (both). All 3 pass on the commit, in the row above
#637's guard file, test_family_switch_branch_exists, on the 95780dd0 merge, before and after _lockstep took BRANCH_LEFT before: 2 failed, 23 passed (the guard and its partner); after: 25 passed
f634u, 8f5716ae (the #639 merge, committed and not yet pushed): ONE process over 10 files. These are test_validate_family*, test_family_switch_branch_exists, #639's three test files (test_family_head_lost_gitmodules, test_family_remove_gitmodules and test_validate_family_unnamed_mounts), test_pinned_copy_text and test_template_text_placeholders. No test file names _check_member_gitlink 441 passed, 0 failed in 5m50s
f634u, 8f5716ae after the push: ONE process over the broad set, 33 files (test_scaffold*, test_update_shape*, test_pin_source*, test_shape_doctor, test_cli_reference and test_repo_hygiene) 2022 passed, 6 skipped, 0 failed in 21m10s

The tests ran with git-filter-repo on PATH, a stub gh first on PATH that exits 97, GH_TOKEN bogus and an empty GH_CONFIG_DIR. The earlier targeted runs of this PR (below) were on 030fde07; this round's one process (49 files) covers the files that name git's failure text, and rv634s's re-verify ran 43 files, 15 of them beyond those 49, that assert refusals built from git_out (2685 passed, 3 skipped). Neither was a re-run of all 131 files.

Targeted run, on 030fde07. 131 files: every test_update_shape*, test_scaffold*, test_validate_pins*, test_family* and test_adopt_* file; test_link_source_chain_defaults, test_pin_source*, test_template_text_placeholders, test_shape_doctor, test_cli_reference and test_repo_hygiene; test_neutral_pin_validator, test_naming_policy, test_bump_leg*, test_git_output_decoding and test_doctor_gitlink_repairs; and every test file naming AGENTS-shape.md, declared-unverified, LOCKSTEP, shape-pin.yaml, the not used note, link_declarations, LinkPins, validate-family.py, validate-pins.py or validate-repository-naming. The run was split in two halves:

  • half A: 3600 passed, 4 skipped;
  • half B: 4533 passed, 24 skipped;
  • total: 8133 passed, 28 skipped, 0 failed.

(On 40a85e1c, before the merge and the fix round: 122 files, 7718 passed, 27 skipped, 0 failed.)

Main, R1 round. 4cf5fb07 merged as 226fe7ea: git merge --no-edit, clean, tree 912b92da (rv634r's prediction), 13 files changed (adopt-project.py, shape-doctor.py, validate-manifest.py, AGENTS.md, and tests), none of them this PR's. No fetch was made this round, so the overlap list below is not re-run: this round's one hunk in scripts/repo_shape.py is in git_out, and rv634r read it as clear of #638's hunks (_chain_link_findings and the end of link_pins_from_trees).

Main. The merge is f6376d74 (the squash of #614; #625, #583 and #596 came with it). It changes none of this PR's files, git merge-tree --write-tree was clean (tree 30b0fbfa), and the merge commit's tree is that tree. Before the merge, the predicted merge with de428f52, committed in a scratch tree, passed the overlapping set (520 passed).

Overlap with open PRs (git merge-tree --write-tree of this head with each open branch, read-only). Clean with #620, #621, #622, #623, #627 and #628 (the lane's own), and with fix/434-validate-pins-workflows-dir (which also edits validate-pins.py), fix/443-validators-non-list, fix/342-doctor-validator-site, fix/392-393-yaml-flow-scalars, fix/432-doctor-gitmodules-bytes, fix/607-family-switch-branch-exists (lane 5's #637), fix/family-verbs-refuse-up-front, fix/585-git-bash-windows and pr/617. #628 (fix/600-adopt-leg-keys) edits the import block of tests/test_adopt_contracts_dir.py, and this PR's import was put below from repo_shape import load_yaml to keep out of its hunk. Two conflict, and both are older than the fix round:

F2 waits (rv610; done in f634u's round: #637 landed first, and _lockstep now ends with {BRANCH_LEFT}): THE LOCKSTEP RULE's switch -c family/bump-<id> has no sha12, so a second bump of the same member fails with a branch named … already exists while the earlier branch is still there. Lane 5's #637 adds BRANCH_LEFT for exactly this and documents that every remedy naming _switch_line ends with it. Whichever of the two PRs lands second appends {BRANCH_LEFT} to _lockstep's text. THE LOCKSTEP RULE edit is kept local: _lockstep stands where the LOCKSTEP constant stood, both checks take their member's rule, and _run_checks returns the root for the closing count. #622 and #621 share scaffold-project.py (and #622 shape_materialize.py), and both merge clean with this head.

Red on main. The new and changed test files were run at 396c0ff4, with a scratch-only stub for the missing shown_path: 48 failed, 49 passed. The failures that test behaviour:

  • the scaffold WARNING with a raw ESC, and with a forged WARNING forged-line line (test_the_scaffold_spells_a_pin_source_it_could_not_read[esc, forged-line]);
  • the old naming-validator remedies (test_scaffold_offers_its_own_sources_…, test_a_later_link_is_offered_its_variable_alone);
  • a manifest it cannot open named raw (test_a_manifest_it_cannot_open_is_named_spelled[esc, forged-line]);
  • a raw ESC in validate-pins.py's note, in the replacing value, and in the key (test_an_unused_pin_source_is_named_spelled[esc, forged-line], test_a_replaced_pin_source_…, test_a_key_read_as_a_path_is_spelled);
  • $(CURDIR)/spec/contracts in AGENTS-shape.md, in the template, in a scaffolded root and in an adopted one (test_the_pinned_agent_file_names_no_leg_path_and_no_placeholder, test_a_scaffolded_root_is_told_its_makefile_exports_none, test_the_pinned_agent_file_is_true_of_the_adopted_makefile);
  • no --family-root and no switch in THE LOCKSTEP RULE (test_the_rule_s_bump_runs_as_printed_from_the_tracking_branch, test_a_digest_s_rule_names_its_own_member, test_the_count_s_rule_names_the_holder_and_no_member).

The rest fail on main only because the new names do not exist there: _link_remedies, remedies=, _lockstep and shown_path. Three controls pass on main as they should: make contracts-dir has no rule in a scaffolded root, the offered variable does verify a later link, and a printable --pin-source is named as typed.

Red in the fix round. The round's new tests, with the three test files that hold them copied onto the unfixed merge 6e3dd1d7 (the previous head plus main): 15 failed, 103 passed. The behavioural reds: the new AGENTS-shape.md sentences (test_a_scaffolded_root_is_told_its_makefile_exports_none, test_the_pinned_agent_file_is_true_of_the_adopted_makefile and the new kept-Makefile test); the naming gate's raw link-source path (test_the_naming_gate_spells_the_link_source_as_it_spells_the_manifest[esc, forged-line]); validate-pins.py's raw source and directory twin (test_an_unreadable_pin_source_and_its_directory_twin_are_spelled[esc, forged-line]); and the new _shown in the naming gate (test_the_pinned_scripts_spell_a_path_as_the_library_does, eight rows). The F3 and F4 tests, the seed test (F5's premise), the skew test and the printable controls pass on the unfixed code: F3 and F4 close gaps in what the tests asked, and kill rv610's mutants A1 and B1 (below), and the others hold what was already true.

The new and changed tests also pass under an uncommitted pytest plugin that makes Path.write_text write CRLF (527 passed, 175 write_text calls written as CRLF). That run found one fault of the fix round's own, an assertion that compared the source's Makefile bytes with an LF string; it compares line by line now.

Mutants. 25 were tried in the first round, 18 in the fix round and 3 in the R1 round, and each was killed by the tests named. Rows M26 to M41 are the fix round's, and M42 to M44 the R1 round's (M29 and M30 each ran twice: with both tests, and with the adopt test alone); M26 and M28 are rv610's A1 and B1.

# mutant killed by
M1 AGENTS-shape.md back to the $(CURDIR)/spec/contracts sentence test_the_pinned_agent_file_names_no_leg_path_and_no_placeholder
M2 link_declarations names the manifest raw (named = str(path)) test_the_scaffold_spells_a_pin_source_it_could_not_read[esc]
M3 the YAML reader's own detail left unspelled test_every_reason_names_the_manifest_spelled[esc-does-not-parse]
M4 the not-a-list reason alone names it raw test_the_scaffold_spells_a_pin_source_it_could_not_read[esc]
M5 shown_path always repr test_every_reason_names_the_manifest_spelled[plain-not-a-mapping]
M6 shown_path spells only an ESC test_the_scaffolds_two_lines_spell_a_pin_source_one_way[a\nb]
M7 _chain_link_findings ignores remedies test_a_later_link_is_offered_its_variable_alone
M8 --pin-source offered to a later link (if index:) test_a_later_link_is_offered_its_variable_alone
M9 a shared variable still offered test_a_shared_variable_is_offered_to_neither_link
M10 the chain's end link given a remedy test_each_holder_is_offered_the_scaffolds_own_sources
M11 remedies alone returns a plain dict test_a_caller_s_remedies_reach_the_warning_and_no_one_else_s
M12 remedies alone keeps the reasons test_remedies_without_reasons_remember_no_reason
M13 the scaffold passes no remedies test_a_later_link_is_offered_its_variable_alone
M14 the note names typed raw test_an_unused_pin_source_is_named_spelled[esc]
M15 the note names the later value raw test_a_replaced_pin_source_and_its_replacement_are_spelled
M16 read_as_path names the key raw test_a_key_read_as_a_path_is_spelled
M17 _shown always repr test_a_printable_pin_source_is_named_as_typed
M18 the branch word ignores the row's id: test_the_rule_quotes_what_it_names[My Proj-my-proj-my-proj]
M19 bump names no --family-root test_the_rule_s_bump_runs_as_printed_from_the_tracking_branch
M20 the digest finding's rule names no member test_a_digest_s_rule_names_its_own_member
M21 --member not shell-quoted test_the_rule_quotes_what_it_names[My Proj-my-proj-my-proj]
M22 the closing count names a member test_the_count_s_rule_names_the_holder_and_no_member
M23 shown_source returns str(source) test_the_scaffolds_two_lines_spell_a_pin_source_one_way[x\x1b[31mRED]
M24 the Permission denied reason names it raw test_a_manifest_it_cannot_open_is_named_spelled[esc] (added after M24 survived the first run)
M25 the switch line dropped from the rule test_the_rule_s_bump_runs_as_printed_from_the_tracking_branch
M26 (rv610's A1) _check_member passes the project, not the row's id: (_lockstep(root, project, project)) test_the_rule_names_the_rows_id_through_the_gate[the-rows-id]
M27 the call site drops the or project fallback test_the_rule_names_the_rows_id_through_the_gate[no-id-so-the-project]
M28 (rv610's B1) the shared check uses the raw link (link in shared) test_a_shared_variable_is_offered_to_neither_link[mixed-case]
M29 the kept-Makefile sentence dropped from AGENTS-shape.md test_a_scaffolded_root_is_told_its_makefile_exports_none, and alone test_the_pinned_agent_file_says_where_a_kept_makefile_leaves_the_block
M30 the adopted root's Makefile is "its" again the same two
M31 "its legs start empty" back test_a_scaffolded_root_is_told_its_makefile_exports_none
M32 a contracts/ folder in the spec leg's seed (templates/spec-root) test_a_scaffolded_roots_legs_start_from_the_seed_with_no_contracts
M33 the naming gate names the link-source raw test_the_naming_gate_spells_the_link_source_as_it_spells_the_manifest[esc]
M34 the naming gate's _shown always repr test_the_naming_gate_names_a_printable_link_source_as_typed
M35 the naming gate's _shown never repr test_the_naming_gate_spells_the_link_source_as_it_spells_the_manifest[esc]
M36 the naming gate imports shown_path test_no_pinned_script_imports_what_an_older_library_lacks[validate-repository-naming]
M37 neutral-pin-source-unreadable names its source raw test_an_unreadable_pin_source_and_its_directory_twin_are_spelled[esc]
M38 _also_a_directory names its value raw the same
M39 _also_a_directory spells the sentence but not the ./ command the same
M40 validate-pins.py's _shown always repr test_a_printable_pin_source_is_named_as_typed
M41 validate-pins.py imports shown_path test_no_pinned_script_imports_what_an_older_library_lacks[validate-pins]
M42 git_out names the folder it ran in raw again test_a_pin_source_that_is_not_a_repository_is_named_spelled, test_what_git_says_of_a_pin_source_is_spelled_too and test_git_out_names_the_folder_it_ran_in_spelled (12 rows)
M43 git_out passes git's stderr through again test_what_git_says_of_a_pin_source_is_spelled_too[forged-line] and six test_git_out_spells_what_git_printed_as_one_line rows (7)
M44 git's stderr is spelled, then its \n put back as line breaks test_what_git_says_of_a_pin_source_is_spelled_too[forged-line], test_git_out_spells_what_git_printed_as_one_line[forged-line] and [two-lines] (3)

Not in this PR

Edge cases outside #610, for the area's Tracking parent. The neutral-pin-source-unreadable source and _also_a_directory that this section listed in the first round are folded in (above).

Closes #610

🤖 Generated with Claude Code

Summary by Sourcery

Align pinned shape copies and diagnostics with the sources, paths, and workflows users actually encounter.

New Features:

  • Tailor scaffolded chain warnings to offer only sources the scaffold actually reads.
  • Provide consistent safe escaping for paths and Git failure details in user-facing diagnostics.
  • Make the family lockstep guidance executable by including the family root and branch setup.

Bug Fixes:

  • Correct pinned documentation for scaffolded and adopted Makefile behavior.
  • Prevent chain-link remedies from being assigned to the wrong links when product names differ by case folding.
  • Fix pinned validation messages that could emit control characters or misleading source details.

Enhancements:

  • Propagate caller-specific remedies through chain-link resolution while preserving existing validator behavior for other callers.
  • Keep pinned scripts compatible with older repository-shape libraries by implementing local path rendering.

Documentation:

  • Update pinned AGENTS and family validation guidance to reflect actual scaffold, adoption, and bump workflows.

Tests:

  • Add comprehensive coverage for pinned-copy text, source-specific warnings, escaped diagnostics, Git stderr rendering, and executable lockstep commands.

Chores:

  • Repin all affected assembly and family shape copies in a single shape bump.

…py: one shape bump, so a scaffolded root is no longer told its Makefile exports `CONTRACTS_DIR`, the scaffold's chain WARNING offers only sources it reads and spells an unprintable `--pin-source`, an unused one is spelled in the root's note, and THE LOCKSTEP RULE's `bump` names the holder and its branch (#610)

Issue #610 gathers the pinned shape copies' text into ONE shape bump (the
checklist triage's D6, approved by Brett Heap at 2026-10-15T16:49:38Z), so
every project re-pins once for v1.1.0. Branch made from origin/main
396c0ff (#570's squash; #575 and #576 are in).

The bump. A pinned copy has no digest to edit in this repository: each
root's contracts/shape-pin.yaml holds the sha256 of each copy as it was
written, and `update-shape.py check` calls every copy whose upstream bytes
moved `upstream-changed`, which `apply` re-copies and re-pins. So every
pinned copy this issue changes moves in this one commit:
templates/assembly-root/AGENTS-shape.md, scripts/repo_shape.py and
templates/assembly-root/scripts/validate-pins.py (assembly roots), and
scripts/repo_shape.py and templates/family-root/scripts/validate-family.py
(family holders).

L2-33. AGENTS-shape.md said "the root Makefile exports
`$(CURDIR)/spec/contracts` once". A scaffolded root's Makefile exports
nothing (`make contracts-dir`: no rule), and since #572 an adoption exports
the spec leg where its plan mounts it. The sentence is path-free: an
ADOPTED root's Makefile exports `CONTRACTS_DIR` as the `contracts/` of the
spec leg at the path `project.yaml` mounts it, and a SCAFFOLDED root's
exports none, since its legs start empty. The file stays placeholder-free.

576/2. The scaffold's `declared-unverified` WARNING offered "a sibling
checkout, SHAPE_PIN_SOURCE_<LINK>, or --link-source <link>=<path>", the
naming validator's sources. The scaffold has no `--link-source` and reads
no sibling checkout. `LinkPins` now carries a caller's `remedies`, which
`_chain_link_findings` uses for a tree that is not reachable, and the
scaffold's `_link_remedies` names what it reads: the variable (unless
another link shares it, #275) and, for the first link alone (#441), a
`--pin-source` clone. The naming validator and `validate-manifest.py` pass
none and keep their words.

L2-30. `link_declarations` named the manifest it reached raw, and the
scaffold prints that reason in the WARNING, so a `--pin-source` holding an
ESC put a raw `\x1b[31m` on stderr. Every reason now names it with
`repo_shape.shown_path`, the spelling `pin-source-unreadable` gives a
`--pin-source` (`shape_materialize.shown_source` is now that function):
`repr` where it is not printable, the same bytes where it is.

L2-31. The root's `validate-pins.py` named an unused `--pin-source`, the
later value that replaced it, and a key read as a PATH as typed. Each is
spelled the same way, by a local `_shown`: a root whose repo_shape.py is
older has no `shown_path` to import.

L2-39. validate-family.py's THE LOCKSTEP RULE named `scripts/family.py bump
--member <Project> --to <commit>`, with no `--family-root`, which `bump`
requires, and no branch, though a real `bump` refuses the holder's tracking
branch (#487). It now names the holder by its path and the `git -C <holder>
switch -c family/bump-<member>` that makes a branch, as #575's
`_switch_line` does for `add` and `remove`: each finding for its member
(the row's `id:` in the branch), and the closing count with `<Project>`.

Tests: tests/test_pinned_copy_text.py (new), and in
tests/test_validate_family_branch_step.py, tests/test_adopt_contracts_dir.py
and tests/test_naming_policy.py, whose
test_scaffold_keeps_its_old_warning_for_a_pin_source_with_no_manifest
pinned the old remedies and is now
test_scaffold_offers_its_own_sources_for_a_pin_source_with_no_manifest.

Closes #610

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

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

@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates the pinned shape copies in one coordinated bump: generated guidance now reflects scaffolded and adopted root behavior, scaffold warnings advertise only usable sources, all affected paths are safely rendered, and THE LOCKSTEP RULE provides executable family bump instructions with comprehensive regression tests.

Sequence diagram for safe scaffold chain warning generation

sequenceDiagram
    participant Scaffold as scaffold-project.py
    participant RepoShape as repo_shape.py
    participant Materialize as shape_materialize.py
    participant Source as Pin source

    Scaffold->>RepoShape: link_pins_from_trees(..., remedies=_link_remedies(chain))
    RepoShape->>Source: link_manifest_lookup(path)
    Source-->>RepoShape: manifest or read error
    RepoShape->>RepoShape: shown_path(path)
    RepoShape-->>Scaffold: LinkPins with findings and remedies
    Scaffold->>Materialize: shown_source(pin_source)
    Materialize->>RepoShape: shown_path(source)
    RepoShape-->>Materialize: printable path or repr(path)
    Scaffold-->>Scaffold: declared-unverified WARNING with scaffold sources only
Loading

Sequence diagram for executable family lockstep guidance

sequenceDiagram
    participant Validator as validate-family.py
    participant FamilyRoot as Family holder
    participant Git as Git
    participant FamilyTool as scripts/family.py

    Validator->>FamilyRoot: _lockstep(root, project, member_id)
    Validator->>Validator: _check_member_gitlink(..., rule)
    Validator->>Validator: _check_member_tree(..., rule)
    Validator-->>FamilyRoot: finding with holder, --family-root, member, and commit
    FamilyRoot->>Git: switch -c family/bump-<member>
    Git-->>FamilyRoot: branch created
    FamilyRoot->>FamilyTool: bump --family-root <holder> --member <Project> --to <commit>
Loading

Flow diagram for coordinated pinned shape copy bump

flowchart LR
    Templates[Updated shape templates] --> Check[update-shape.py check]
    Check --> Apply[update-shape.py apply]
    Apply --> Root[Assembly root copies and pins]
    Apply --> Family[Family holder copies and pins]
    Root --> Verify[Validation and second check]
    Family --> Verify
    Verify --> Clean[All copies unchanged]
Loading

File-Level Changes

Change Details Files
Refine scaffold chain diagnostics to advertise only sources the scaffold actually supports.
  • Add caller-provided remedies to LinkPins and chain finding generation.
  • Offer per-link environment variables and the first link’s --pin-source, while omitting sibling checkout and --link-source suggestions.
  • Add coverage for later links, shared variables, and remedy propagation.
scaffold-project.py
scripts/repo_shape.py
tests/test_naming_policy.py
tests/test_pinned_copy_text.py
Standardize path rendering in diagnostics to prevent control characters from corrupting output.
  • Add shared shown_path formatting that preserves printable paths and repr-escapes non-printable paths.
  • Use the formatter for manifest parsing, decoding, and permission errors.
  • Reuse the formatter for shape-materialization --pin-source messages and validate-pins unused-source notes.
scripts/repo_shape.py
scripts/shape_materialize.py
templates/assembly-root/scripts/validate-pins.py
tests/test_pinned_copy_text.py
Correct pinned documentation and family validation instructions so generated guidance matches actual repository behavior.
  • Update AGENTS-shape.md to distinguish scaffolded roots from adopted roots without hard-coding a spec path.
  • Generate THE LOCKSTEP RULE with the holder path, required --family-root, and a branch-creation command.
  • Pass member-specific rules into mismatch findings and holder-specific rules into summary output.
templates/assembly-root/AGENTS-shape.md
templates/family-root/scripts/validate-family.py
tests/test_adopt_contracts_dir.py
tests/test_validate_family_branch_step.py
tests/test_pinned_copy_text.py
Add regression coverage for the coordinated pinned-copy text bump.
  • Test documentation text, warning remedies, path escaping, validate-pins notes, and lockstep command execution.
  • Verify printable values remain unchanged and non-printable values cannot inject terminal or forged warning lines.
  • Exercise the full pinned-copy behavior across scaffolded and adopted roots.
tests/test_pinned_copy_text.py
tests/test_adopt_contracts_dir.py
tests/test_naming_policy.py
tests/test_validate_family_branch_step.py

Assessment against linked issues

Issue Objective Addressed Explanation
#610 Correct the pinned AGENTS-shape.md text so it describes CONTRACTS_DIR accurately for both adopted and scaffolded roots without hard-coding a spec-leg path. ✅
#610 Make the scaffold's declared-unverified warning offer only sources the scaffold can actually use, and safely spell non-printable --pin-source paths in that warning and in validate-pins.py's unused-source note. ✅
#610 Update THE LOCKSTEP RULE in the pinned family validator so its bump instructions include the required family root and the branch-creation step. ✅

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

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-16T00:20:58Z)

This lane reviewed its own PR #634 at 40a85e1, read-only, in its own detached worktrees. It authored nothing.

MERGEABLE WITH NITS. The _lockstep change must be settled against #637, and the scripts/repo_shape.py conflict with #638 (two hunks, simple) belongs to whichever PR lands second.

D6 holds. Roots made at 396c0ff were run through update-shape.py at the head:

  • a scaffolded root and an adopted root (legs/spec) each show upstream-changed for exactly AGENTS-shape.md, scripts/repo_shape.py and scripts/validate-pins.py;
  • a holder shows it for exactly scripts/validate-family.py and scripts/repo_shape.py;
  • there is no conflict, apply copies them, and a second check has nothing to do.

Half-updated roots run in both directions with no traceback. The naming gate's words are byte-for-byte unchanged.

Findings:

  1. P3, templates/assembly-root/AGENTS-shape.md:80-82. The ADOPTED sentence is false when the source keeps its own Makefile. The block then sits in shape/Makefile, and make contracts-dir says "No rule". Fix it in this PR.
  2. P3, templates/family-root/scripts/validate-family.py:129. switch -c family/bump-<id> fails on a second bump while the earlier branch is still there. Merged with family.py and validate-family.py: where an earlier act left the branch, the switch -c remedy says to give it another name, or go back only if it holds work not merged yet, and the docs say family/add-<Project> (#607) #637, _lockstep lacks BRANCH_LEFT. Whichever lands second appends it.
  3. nit, tests/test_validate_family_branch_step.py. Mutant A1 survives: validate-family.py:529 ignores the row's id:.
  4. nit, tests/test_pinned_copy_text.py:203. Mutant B1 survives: the shared check works on the raw link.
  5. nit, AGENTS-shape.md:83. "its legs start empty" is loose: the scaffold seeds both legs.
  6. nit, scripts/validate-repository-naming.py:330. The line spells the manifest but leaves the path in front of it raw.

Writer's extra finds:

Tests on 40a85e1, 125 targeted files: 7861 passed, 28 skipped, 0 failed. The new tests pass under CRLF (338). Of 15 mutants, 13 were killed.

Merges:

Report: opensoft/brett-wip, handoffs/xFactory/attachments/lane-openRepoShape-2/review634-40a85e1/final-report.md.

…d-copy text is tested and pushed on the current main (#610)

Merges f6376d7 (the squash of #614) and the main commits between this
branch's base 396c0ff and it: #625 (family.py `bump --to`), #583
(adopt-project.py branch names), #596 (README, handbook and the
tests-git-bash job) and #614 (adopt-project.py `git rm` bytes). No file
this PR changes is among the files main changed since 40a85e1, and
`git merge-tree --write-tree` was clean (tree 30b0fbf), so no hunk was
resolved by hand. The pinned shape copies still move as one re-pin.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ACTS_DIR` block, and the naming gate and `validate-pins.py` spell the paths they name, so `make contracts-dir` is not promised where it has no rule and an ESC in a path no longer reaches stderr (#610)

rv610's review of #634 at 40a85e1. Each change is in a pinned copy this PR
already moves, or in a test, so every project still re-pins once for
v1.1.0.

F1, F5. The pinned AGENTS-shape.md said an ADOPTED root's Makefile exports
`CONTRACTS_DIR` and `make contracts-dir` prints it. A source that keeps a
root Makefile of its own keeps it AT THE ROOT (`root-project-makefile`),
and the shape's, with the block, lands beside it as shape/Makefile, so
`make contracts-dir` there says "No rule to make target". The sentence now
says the SHAPE's Makefile exports it, and that where the source kept a
Makefile of its own the block waits in shape/Makefile until the two are
merged. "Its legs start empty" is now "start from the scaffold's seed,
with no `contracts/`": the scaffold seeds both legs. Tests: an adoption of
a source with a Makefile, asserting the sentence, the root Makefile, the
block in shape/Makefile and what `make` says in each; a scaffolded root's
legs hold the seed and no `contracts/`.

F3. A test through the gate with a row whose `id:` differs from its project
lowercased (`mount_twice`'s IRRS2 records `irrs`), and one with no `id:`,
so `_lockstep(root, project, project)` at the call site no longer passes.

F4. A mixed-case shared pair (`Open-X`, `open_x`) for `_link_remedies`, so
`link in shared` no longer passes for `pin_product_key(link) in shared`.

F6. The naming gate's `link-source-unreadable` WARNING named the tree it
asked raw, beside a reason that names the manifest with `shown_path`: one
line, two spellings. It spells the tree with a local `_shown`, as
validate-pins.py does and for the same reason (a root's older
repo_shape.py has no `shown_path` to import), with a test that each
script's `_shown` agrees with `shown_path`, and that none of the three
pinned scripts imports `shown_path`.

Extra find (a). validate-pins.py named the source of a
`neutral-pin-source-unreadable` finding, and a value that is also a
directory (twice: in the sentence and in the `./` command), as typed. Both
are spelled with `_shown`, as L2-31's note is.

The naming gate is a pinned copy, so an assembly root now sees a fourth
`upstream-changed` row for it, in the same one re-pin.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…h, the `switch -c` remedy puts another name first and going back second, for a branch that holds work not merged yet, so neither a branch behind a moved main nor a detached commit is the first advice (#607)

Fix round 1 on #637, from the review of 6212c31.

S1. The sentence sent a person back to the old branch first. That is
right only where the branch holds work not merged yet. Probed in a real
local holder: with no commits on the old branch and `main` moved on
since (a later member squash-merged), going back and running `add`
exits 0, but the branch then conflicts with `main` (`CONFLICT (add/add)`
in .gitmodules and `CONFLICT (content)` in family.yaml, `git merge-tree
--write-tree` exit 1); with a commit made on a detached HEAD, `git
switch <branch>` leaves that commit behind (`git branch --contains`
names no branch). Another name starts where the holder stands: `add`
exits 0, the merge-tree exits 0, and the detached commit stays on the
new branch. `BRANCH_LEFT` now reads: "If `git switch -c` says that
branch already exists, an earlier act left it: give the branch another
name, which starts it where the holder stands, or, only if that branch
holds work not merged yet, the same command with `switch` in place of
`switch -c` goes back to it and keeps every commit on it." It is word
for word the same in scripts/family.py, in the pinned
templates/family-root/scripts/validate-family.py and in the pinned
constant in tests/test_validate_family_branch_step.py, and the comments
that described the old order are rewritten. The sentence still prints no
second command and no `||`.

New tests in tests/test_family_switch_branch_exists.py, each run as
printed in a real local holder:

- `test_another_name_works_where_main_has_moved_since`: an earlier act's
  branch is left behind a `main` that a later member moved. The printed
  line fails with `already exists`, the same line with another name
  works, `add` exits 0, and `git merge-tree --write-tree main <branch>`
  exits 0.
- `test_another_name_keeps_a_commit_made_on_a_detached_head`: after the
  printed line with another name, `git branch --contains <commit>` names
  the new branch.

S2. #610 adds a fourth `_switch_line` to the validator (in
`_lockstep`) with no sentence.
`test_every_remedy_that_names_a_switch_line_ends_with_the_sentence`
reads the template's source with `ast`, finds every call of
`_switch_line` however its string is passed on (a `Refusal`, a
`report.finding`, or the `return` of a helper), and requires the prose
of the string it stands in, up to the first line break, to end with
`BRANCH_LEFT`. `test_the_guard_sees_a_remedy_left_without_the_sentence`
shows it fails on the source with the sentence taken out of any one
remedy, and on a fourth remedy built by a helper as `_lockstep` builds
its. Whichever of #637 and #634 lands second must end `_lockstep`'s
prose with `BRANCH_LEFT`, and CI fails until it does.

N6. The init NEXT STEPS test read the lines with a regex whose
`--family-root \S+` breaks on a root with a space in it; it now reads
the words with `host_words`, as the NEXT STEPS test beside it does.

A correction to the first commit's body, which says neither tool can
print the one line that works by asking first: that holds for the
validator, whose report is read in CI where the checkout holds none of
the person's branches. `family.py` could ask, and says the validator's
words so the two tools keep one rule.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-16T02:11:06Z) Re-verify of the round at 030fde0 (rv634r): every fix checks out, apart from one pre-existing nit, R1, which a round now folds in. No merge word has been given.

  • The merge. 6e3dd1d7 is git's own clean merge of main f6376d74. The PR's diff is the same before and after it: equal patch-ids, and no hand-resolved hunk.
  • F1 and F5 (AGENTS-shape.md). The sentence is true of all three kinds of root made at the head:
    • a scaffolded root: no CONTRACTS_DIR;
    • an adopted root with no Makefile of its own: make contracts-dir prints .../legs/spec/contracts;
    • an adopted root that keeps its own Makefile: the block waits in shape/Makefile.
  • F6 and (a). The values the PR names no longer reach the terminal raw: the neutral-pin-source-unreadable source, the _also_a_directory sentence and command, and the naming gate's unreadable path.
    • The two local _shown functions agree with repo_shape.shown_path on every code point.
    • The pinned scripts import nothing new. A swap in either direction, with an old library or with old copies, gave 0 tracebacks.
  • Mutants and tests.
    • A1 and B1 are killed, and so are rv634r's own six.
    • 977 passed and 3 skipped in test_pinned_copy_text, test_validate_pins*, test_neutral_pin_validator, test_template_text_placeholders and test_update_shape*.
    • On a predicted merge with main 4cf5fb07, 1096 passed and 3 skipped in the 9 overlapping files.
  • D6: still one re-pin. Roots made at 396c0ff4 see 4 upstream-changed rows: validate-pins.py, AGENTS-shape.md, repo_shape.py and validate-repository-naming.py. A holder sees 2. Each applies cleanly, and the next check finds nothing to do.
  • R1 (a nit, pre-existing). One path still reaches stderr raw: validate-pins.py prints exc.detail from repo_shape.git_out, which formats the cwd as given. So a --pin-source that exists but is not a repository, or lacks the commit, can carry an ESC or a forged line to the terminal.
    • The PR's "(a)" claim is wider than the code.
    • Round f634s now merges main 4cf5fb07, spells that cwd with shown_path inside the library (repo_shape.py is already in this PR's bump, so it is still one re-pin), adds rows, and makes the claim exact.
    • That makes a new H0 for this PR.

For lane 3's v1.1.0 notes, rv634r found one more thing: against main, an assembly root also sees scripts/validate-manifest.py as upstream-changed. That comes from #617, not from this PR, and belongs in the list of files the release re-pins.

Brett Heap and others added 2 commits October 16, 2026 02:46
…` and what git printed with `spelled`, as one line, so a `--pin-source` that is no repository, named with an ESC, a CR, a BEL or a line break, no longer reaches stderr raw or begins a line of its own in `validate-pins.py`'s `neutral-pin-source-unreadable` finding (#610)

rv634r's R1 on #634, a nit older than the PR: the finding ends in
`exc.detail`, and for a `git-failed` that detail was built in `git_out`
with the folder git ran in as it was, followed by git's stderr as it came.
`--pin-source <folder>` where `<folder>` exists, is no repository and is
named `bare-x<ESC>[31mRED` put the ESC on stderr; a CR and a BEL went
through raw; a name holding a line break and `WARNING forged-line: ...`
began a forged line.

The folder is now named with `shown_path` (as it is, `repr` where a
character in it is not printable), and git's stderr is `spelled`, as ONE
line: a control character, a line break and a C1 control are the escape
Python spells them, and printable text, UTF-8 included, is unchanged.
Git's stderr is spelled as well because it can carry the folder: on git
2.43.0 a source that is no repository, one that lacks the commit and a
bare repository that lacks it print no path, but `invalid gitfile format:
<folder>/.git`, `not a git repository: <gitdir>` and a dubious-ownership
line do. 2.43.0 replaces an ESC, a CR and a BEL in that echo with `?` and
passes a line break through, so a forged line still began from git's own
words; a git that replaces nothing hands the control characters over too. A line break git itself printed between two lines of its message
is `\n` now, since it cannot be told from a path's; this is `git_out`'s
detail for every caller, not `validate-pins.py`'s alone. `git_out`'s
arguments, which a caller writes, are named as they were.

The fix is in the library, so no pinned script changes and `repo_shape.py`
was already in this PR's one shape bump: the re-pin is still one.

Tests in tests/test_pinned_copy_text.py: `validate-pins.py` over a
`--pin-source` that is no repository and over one whose `.git` is no
gitfile, each named with an ESC, a forged line, a CR and a BEL, asserting
that no control character but a line break and no line beginning with the
forged text reaches stdout or stderr; `git_out` against a stub `git` that
prints given bytes, so they do not depend on the git on the machine; and
printable controls. With `repo_shape.py` at the previous head 18 of the
file's 82 tests fail.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ces and which it passes through (#610)

git 2.43 replaces an ESC, a CR and a BEL with `?` and passes a line break and a C1 control through; comment-only, no code line changed.

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

brettheap commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-2, 2026-10-16T03:23:18Z) R1 round (f634s), re-verified MERGEABLE WITH NITS (rv634s). The nits are fixed, and the head is 6dad64bc. No merge word has been given.

6dad64bc is a new H0 and needs Linux and SonarCloud green. Reports are in opensoft/brett-wip, handoffs/xFactory/attachments/lane-openRepoShape-2/review634-e5ea529/.

…ies keyed by pin_product_key, the key the chain readers use (#444 x #610)

#638 keys every reader of a chain by `pin_product_key` (`lower()`), and
`_chain_link_findings` now finds a holder's remedy by it too, as
`_link_remedies` keys it. Keyed by `casefold()`, the chain
`openß, openss, openInk` wrote one entry for its two links, so the first
link, `openß`, was offered the later link's words (that the scaffold
reads no source for it) instead of its own `--pin-source`. New test:
test_two_pin_files_one_casefold_are_each_offered_their_own. The one
conflict, in `scripts/repo_shape.py`, keeps both `_chain_link_findings`
docstring paragraphs and #638's key with this branch's `LinkPins` return.
R444's N2: the naming gate's `LinkSources` docstring cited
`validate-pins.py` by line numbers that no longer held the lines, and now
names `PinSources.__init__` and `resolve_neutral_pin_source`.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
…h, the `switch -c` remedy says to give it another name, or go back only if it holds work not merged yet, and the docs say `family/add-<Project>` (#607) (#637)

### Fix round 1 (rv607)

Review rv607 of 6212c31 said CHANGES NEEDED: 0 blockers, 2 should, 7 nits. This round is one new commit, 66b1043, on top of 6212c31. No amend, and main is not merged in. Each point:

- **S1, the sentence.** It sent a person back to the old branch first. That is right only where the branch holds work not merged yet. `BRANCH_LEFT` now puts another name first and going back second (the text is under "L2-46" below). It is word for word the same in `scripts/family.py`, in the pinned `validate-family.py` and in the pinned test constant, and the comments that described the old order are rewritten. Two new tests type the printed line in the states the review probed: `main` has moved since the earlier act, and a commit was made on a detached HEAD.
- **S2, a guard for every `_switch_line` remedy.** A new test reads the validator's source with `ast` and fails any remedy that names `_switch_line` whose prose does not end with `BRANCH_LEFT`. On a merge of this head with #634 it fails at `_lockstep`, which is the point: whichever of #637 and #634 lands second adds the sentence there. #634's lines are not on this branch and are not touched.
- **N1.** Under "Left out".
- **N2.** The commit body of 6212c31 said neither tool can print the one line that works by asking first. That holds for the validator only. `family.py` could ask; it says the validator's words so the two tools keep one rule. Decision 1 says so, and the new commit's body corrects the old one.
- **N3.** The merge table is refreshed: current origin/main, #620, #622, #626, #630 to #636, #638, #639 (lane 5's #611) and #634 (S2).
- **N4.** Decision 1 no longer says the typed line works "in every shell". It says what a person types is "the printed line with one word changed (the branch name, or `-c` taken out)", so it keeps the printed quoting, and that the tests type both changes as printed.
- **N5.** Decision 3 now says the case of the branch name "does not matter to `add`", and adds the note about case-insensitive disks.
- **N6.** The `init` NEXT STEPS test read the lines with a regex whose `--family-root \S+` breaks on a root with a space in it (`C:\Users\Jane Doe`, or any `tmp_path` with a space). It now reads the words with `host_words`, as the NEXT STEPS test beside it does. Run with a space in the base temp directory, the old test fails with `AttributeError: 'NoneType' object has no attribute 'group'` and the new one passes.
- **N7.** "Three existing tests take the new text" was wrong. It is two test functions plus `GITLINK_REMEDY`, a constant four cases share. Fixed under "Tests". The one new test that passes on BASE is disclosed there too.

### Fix round 2 (rv607b)

The re-verify rv607b of 66b1043 said APPROVE WITH NITS: 0 blockers, 0 should, 5 nits. This round is one new commit, a277695, on top of 66b1043. No amend, and main is not merged in. It changes comments and docstrings only, so no behaviour moves and no mutation row changes.

- **Nit 1.** Two comments called another name "right in every state". It is not: where the old branch holds work not merged yet, another name leaves that work behind. The comment above `BRANCH_LEFT` in `scripts/family.py` and the one in the pinned `validate-family.py` now say a new branch starts where the holder stands now, "which works in every state". The three `BRANCH_LEFT` texts are unchanged and still equal.
- **Nit 3.** The guard's docstring said it finds each `_switch_line` call "however it is passed on". A switch line moved into a variable would defeat that. It now says "wherever the call stands inside the string expression that is passed on".
- **Nit 4.** The N4 line above now quotes decision 1 as written.
- **Nit 5, the docstring half.** The docstring of `test_another_name_works_where_main_has_moved_since` said going back to the old branch conflicts, but the test does not assert that. It now says the test shows the other name works, and that the conflict on the old branch is shown by rv607's probe and decision 2's table. No assertion is added.
- **Nit 2, left as written.** The guard's control counts stay: each case fails loudly with a clear message.

Checks on a277695: the diff to 66b1043 adds ten lines and removes seven, in three files, all comments and docstrings, and the `ast` of each file is equal once docstrings are blanked. The four files `test_family_switch_branch_exists.py`, `test_validate_family_branch_step.py`, `test_validate_family_unnamed_mounts.py` and `test_family_tracking_branch.py` give `96 passed in 51.34s`, and `test_repo_hygiene.py` gives `65 passed in 1.39s`.

### L2-46, the fix

`family.py add`, `bump` and `remove` refuse the holder's tracking branch with `git -C <holder> switch -c family/<act>-<member>` (#487). The holder's `validate-family.py` names the same line before each `add` and `remove` it prints (#539, landed as 54041cc). If an earlier act left that branch, the line fails with `fatal: a branch named 'family/add-atlas' already exists` (exit 128), and neither tool said what to do next.

Both tools now add one sentence, in the same words (`BRANCH_LEFT` in each file):

> If `git switch -c` says that branch already exists, an earlier act left it: give the branch another name, which starts it where the holder stands, or, only if that branch holds work not merged yet, the same command with `switch` in place of `switch -c` goes back to it and keeps every commit on it.

- `scripts/family.py`: the sentence comes on its own line, right under the `switch -c` line, in the `family-on-tracking-branch` and `family-holder-detached` refusals (`_checked_branch`), and in the dry run's `branch` lines (`_branch_said`).
- `templates/family-root/scripts/validate-family.py`: the sentence ends the remedy text of `member-gitlink-absent`, `member-duplicate-id` and `member-gitlink-unnamed`, the three remedies that print `_switch_line`. The command lines under them are unchanged.

### L2-45, rides along

AGENTS.md, the holder's README, its AGENTS-shape.md and `init`'s NEXT STEPS spelled the branch `family/add-<project>`, right beside `--member <org>/<Project>`. All four now say `family/add-<Project>`, the one placeholder the validator already uses for both (`PROJECT_PLACEHOLDER`, #539).

**1. What the remedy prints when the branch exists: one sentence that names the way out, not a second command line.**

- *One line that works in both states* (`git switch X 2>/dev/null || git switch -c X`): rejected. On Windows, `family.py` quotes its lines for PowerShell (`shell_line` uses `os.name`; README.md:155-158 says so). Windows PowerShell 5.1 has no `||`; `&&` and `||` arrived in PowerShell 7, and `family.py`'s own `_print_next_steps` docstring says 5.1 rejects `&&`. PowerShell also has no `/dev/null`. And `2>/dev/null` would hide the real reason a `switch` fails, such as a changed file it would overwrite.
- *The tool checks and prints the one line that works*: rejected for the validator. Its report is read in CI: the holder's `validate.yml` runs `python3 scripts/validate-family.py` (and `--no-members`) in a runner's checkout, which holds none of the person's branches. `family.py` could check, but then the two tools would follow two rules, and one rule in the same words was the aim. So `family.py` says the validator's words; it is not that it cannot ask.
- *Two printed lines, one per state*: rejected. In the validator, each indented line is a step to run in order, and going back to the branch replaces step one; it is not another step. In `family.py`, a new `git switch` line would also be quoted for PowerShell on Windows. README.md:155-158 lists exactly which `family.py` lines are quoted that way (`NEXT`, `switch -c`, `add`, `git rm -r --cached`). That sentence would become false, and fixing it would mean editing README § "Starting a project in a new organisation". Seven handbook parts record that section's hash (`grep -c` gives 7).
- *Chosen*: a sentence that names both ways out. It prints no new command, so nothing new needs quoting, and the text is the same in both tools. What a person types is the printed line with one word changed (the branch name, or `-c` taken out), so it keeps the printed quoting. The tests type both changes as printed (tests below); none of them runs a shell, and Windows and macOS are not checked (see "Left out").

**2. Another name first, going back second (S1).** The reviewer's probes, and mine, in real local holders (a holder `Orbit`, members `Atlas` and `Beacon`, local bare remotes, the `gh` stub first on PATH):

| State of the old branch `family/add-atlas` | `git switch family/add-atlas` (going back) | `git switch -c family/add-atlas-2` (another name) |
|---|---|---|
| No commit on it, `main` has not moved (rv607) | works | works |
| Holds a commit of its own, not merged yet (mine: `probe-atlas.sh`, `probe-b.sh`) | works; `add` then commits on top of it, and the earlier commit is the parent | works, but the earlier commit stays only on the old branch (`git branch --contains` names `family/add-atlas` alone) |
| `main` has moved: a later member (`Beacon`) was squash-merged (mine: `probe-d.sh`; test `test_another_name_works_where_main_has_moved_since`) | `add` exits 0, but `git merge-tree --write-tree main family/add-atlas` exits 1 (`CONFLICT (add/add)` in `.gitmodules`, `CONFLICT (content)` in `family.yaml`), and an untracked `members/Beacon` is left (`warning: unable to rmdir 'members/Beacon'`) | `add` exits 0, `git merge-tree --write-tree main family/add-atlas-2` exits 0, and the validator says `2 member(s)` |
| Merged, then the member removed and that merged too; the old branch is still local (rv607's probe, and mine: `probe-c.sh`) | `add` says `member-already-present`, whose remedy points at `bump` and `remove`: a dead end | `add` exits 0, once the object store `remove` leaves behind is deleted, as its NOTE says (either choice needs that) |
| Detached HEAD with a commit made on it (mine: `probe-detached.sh`; test `test_another_name_keeps_a_commit_made_on_a_detached_head`) | `Warning: you are leaving 1 commit behind, not connected to any of your branches`, and `git branch --contains` names no branch | the commit stays: `git branch --contains` names the new branch |

So going back is right only where the branch holds work not merged yet, and another name is right in every state but that one. The sentence says so, in that order.

**3. The person's earlier work on that branch is never discarded.** `git switch <branch>` keeps the branch's commits. It refuses to switch when that would overwrite a changed file. The act then commits on top of what the branch holds; the tests check that the earlier commit is the parent of the act's commit. "Give the branch another name" leaves the old branch alone. No printed line names `switch -C` (which resets a branch), `branch -D`, `reset` or `--discard-changes`; a test checks that too.

**4. The placeholder: `<Project>` everywhere.**

- The validator already said `family/add-<Project>` beside `--member <org>/<Project>` (#539's fix round, pinned by `tests/test_validate_family_unnamed_mounts.py`). It is the only spelling that is one placeholder for both values.
- The other option, a sentence saying the branch word is the name in lowercase, does not fit: AGENTS.md is at its cap, 492 of 492 lines (`tests/test_repo_hygiene.py:633`). This change keeps it at 492.
- The case of the branch name does not matter to `add`. `_checked_branch` takes any branch but the tracking one, so `family/add-IRQS`, the doc line filled in as written, is a branch `add` commits on (test). Where the tools know the name, they still print it lowercased (`_suggested_branch`). On a case-insensitive disk (macOS, Windows) `family/add-Atlas` and `family/add-atlas` can be one loose ref there. That is untested: CI is Linux only.

**5. Pinned copies.** This changes two pinned holder files: `templates/family-root/scripts/validate-family.py` and `templates/family-root/AGENTS-shape.md` (`scripts/shape_materialize.py` lists the pinned set). Each holder takes them as two `upstream-changed` rows on its next `update-shape.py` run. The holder's README is rendered once and is not pinned. No test pins those files' bytes; the tests that compare them read the template at run time. Fix round 1 changes only `validate-family.py` of the two.

New file `tests/test_family_switch_branch_exists.py`, 25 tests (21 before the fix round). Four are new in the fix round:

- `test_another_name_works_where_main_has_moved_since` (S1, state (d)): `family/add-irqs` is left at the `main` of its day; a later member (`IRXS`, scaffolded in the test) is added on a branch and squash-merged onto `main`, so `main` has one new commit; the refusal's `switch -c`, typed as printed, fails with `already exists` (exit 128); the same line with another name works; `add` exits 0, `git merge-tree --write-tree main <branch>` exits 0, and the validator passes.
- `test_another_name_keeps_a_commit_made_on_a_detached_head` (S1): the refusal is `family-holder-detached`; after the printed line with another name, `git branch --contains <the commit>` names the new branch and no other.
- `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence` (S2, the guard) and `test_the_guard_sees_a_remedy_left_without_the_sentence` (its control: the source with the sentence taken out of any one remedy fails it, and so does a fourth remedy built by a helper as `_lockstep` builds its, until the sentence ends it).

The other 21:

- `test_the_refusal_typed_as_printed_puts_the_act_on_its_branch` [add, bump, remove] x [no-branch, branch-left]: the refusal, then its line typed as printed. With no branch, the `switch -c` makes one. With a branch an earlier act left, holding a commit of its own, the `switch -c` fails with `fatal: a branch named '<branch>' already exists` (exit 128) and changes nothing, and the same command with `switch` in place of `switch -c` goes back to that branch. The act then commits on top of the earlier commit, and NEXT pushes that branch.
- `test_a_dry_run_on_the_tracking_branch_says_it_too` [add, bump, remove].
- `test_a_missing_gitlinks_remedy_typed_as_printed`, `test_a_duplicate_ids_remedy_typed_as_printed` (with the `remove` run as printed) and `test_an_unnamed_gitlinks_remedy_typed_as_printed`, each in both states.
- `test_the_two_tools_say_the_same_words`.
- `test_each_doc_spells_the_branch_as_its_member` [AGENTS.md, AGENTS-shape.md, holder README], `test_init_next_steps_spell_the_branch_as_its_member` (read with `host_words` now, N6), and `test_the_branch_filled_in_as_written_is_one_add_commits_on`.

Two existing test functions take the new text: `test_init_next_steps_make_the_branch_before_the_add` (`<Project>`) and `test_a_missing_gitlink_remedy_makes_the_branch_and_names_the_holder` (the remedy line). `GITLINK_REMEDY` in `test_validate_family_unnamed_mounts.py`, a constant four cases share (`test_a_member_mounted_by_hand_is_two_findings` x 3 and `test_a_removed_row_is_named_by_its_gitlink_and_its_section`), ends with the sentence. The sentence is pinned once, as text, in `tests/test_validate_family_branch_step.py`.

Before and after, with the four changed test files copied in (`run9.sh`, the `gh` stub first on PATH):

- On BASE 396c0ff: `30 failed, 66 passed` in the four files. 24 of the 25 new-module tests fail. The one that passes, `test_the_branch_filled_in_as_written_is_one_add_commits_on`, is the evidence for decision 4: it shows behaviour this PR keeps.
- On 6212c31: `23 failed, 56 passed` in the three files that pin the sentence. Both new S1 tests fail there, where they assert the new wording. The two guard tests pass there: they check where the sentence stands, not its words.
- On the head 66b1043: the new module alone, `25 passed`.

### Targeted runs on the head 66b1043, BASE 396c0ff

- Set A, 29 files: every `tests/test_family*.py` and `tests/test_validate_family*.py`, plus `test_bump_leg.py`, `test_bump_leg_dry_run_tracking.py`, `test_bump_leg_dry_run_truth.py`, `test_bump_leg_paste_lines.py` and `test_update_shape_branch_any_directory.py`. Result: `1016 passed, 20 skipped in 511.01s`.
- From set B: `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_readme_sections.py` and `test_cli_reference.py`. Result: `210 passed in 15.73s`. The fix round changes no template other than `validate-family.py`'s text, so the rest of set B (the 27 files of the first round) is not re-run.
- Every skip is a platform skip that was there before this change (a case-ignoring disk, a Windows default, powershell.exe), or a template-quoting skip in `test_family_init_values.py`.

A real holder `Orbit`, made with `family.py init`, and a member `Atlas`, scaffolded with `scaffold-project.py --local-remote-dir`.

- **On BASE 396c0ff** (first round): `add` refuses with `switch -c family/add-atlas`. Typed as printed, that gives `fatal: a branch named 'family/add-atlas' already exists`, exit 128, and the remedy says nothing more. The validator's `member-gitlink-absent` remedy prints the same line, with the same result.
- **On the head, an unmerged act** (`probe-atlas.sh`, a branch holding a commit of its own): the refusal prints the new sentence. The same command with `switch` for `switch -c` gives `Switched to branch 'family/add-atlas'`, exit 0, at the earlier commit. `add` then exits 0, its NEXT pushes `family/add-atlas`, and its commit's parent is the earlier commit. The validator carries the same sentence.
- **On the head, `main` moved** (`probe-d.sh`): the printed line fails (128). With another name: `add` exits 0, `git merge-tree --write-tree main family/add-atlas-2` exits 0, `family ok: Orbit (orbit), 2 member(s)`. Going back instead: `add` exits 0, and `git merge-tree --write-tree main family/add-atlas` exits 1 with the two conflicts in decision 2.
- **On the head, a detached HEAD with a commit** (`probe-detached.sh`): the printed line fails (128). With another name, `git branch --contains` names `family/add-atlas-2`. Going back with `switch`: git warns it is leaving 1 commit behind, and no branch contains it.

No row needs real GitHub.

Each change below was made alone in a scratch clone of the head, then the new module was run (and, for the validator changes, the two other files that pin the sentence), and the file was put back. Every change that alters behaviour makes at least one new test fail:

| Change | Result |
|---|---|
| `family.py` `BRANCH_LEFT` back to the first round's words | 12 failed: the six refusal cases, the three dry-run cases, both new S1 tests, `test_the_two_tools_say_the_same_words` |
| `family.py` `BRANCH_LEFT` one clause dropped (`only if`) | 12 failed, the same tests |
| `_checked_branch` remedy with no sentence | 8 failed: the six refusal cases and both new S1 tests |
| sentence printed above the `switch -c` line, not under it | 8 failed: the same eight |
| `_branch_said` (dry run) with no sentence | 3 failed: `test_a_dry_run_on_the_tracking_branch_says_it_too` [add, bump, remove] |
| `_switch_command` prints `switch -C` | 11 failed, both new S1 tests among them: the printed line no longer fails `already exists` |
| validator `BRANCH_LEFT` back to the first round's words | 12 failed in the three files, `test_the_two_tools_say_the_same_words` among them |
| validator `member-gitlink-absent` with no sentence | 5 failed: its two typed-as-printed cases, the remedy test, the guard and its control |
| validator `member-duplicate-id` with no sentence | 4 failed: its two typed-as-printed cases, the guard and its control |
| validator `member-gitlink-unnamed` with no sentence | 8 failed: its two typed-as-printed cases, the four `GITLINK_REMEDY` cases, the guard and its control |
| **S2:** a fourth remedy added that names `_switch_line` and does not end with the sentence (built by a helper, as #634's `_lockstep` does) | 2 failed: the guard and its control, and nothing else in the three files |
| **S2:** the same fourth remedy ending with `{BRANCH_LEFT}` | 79 passed |
| `init` NEXT STEPS branch back to `<project>`; its `--member` lowercased | 1 failed each: `test_init_next_steps_spell_the_branch_as_its_member` |
| AGENTS.md, AGENTS-shape.md, holder README: branch back to `<project>` (and AGENTS.md's `--member`) | 1 failed each: `test_each_doc_spells_the_branch_as_its_member` for that doc |

The other hunks change only docstrings and comments (`_checked_branch`, `_branch_said`, `_print_next_steps`, `_switch_line`, and the comments above the two constants). They change no behaviour, so no test can catch reverting them.

| Ref | Result |
|---|---|
| origin/main f6376d7 (#614 landed; main has moved since BASE through #625, #583, #596) | clean, tree eb91b600. A committed merge of the head with it, e26dca8c, ran set A and #625's `test_family_bump_to_local_only.py`: `1024 passed, 20 skipped in 500.52s` |
| #620 a2b4f89 | clean (it conflicted in `adopt-project.py` in the first round; it no longer does) |
| #622 227bc3b (lane 2, #609, `scripts/family.py`) | clean |
| #626 207d4e1 (lane 4, `scripts/family.py`) | clean |
| #630 82f9403, #631 9733547, #632 86c6b9c, #633 f7f5de5, #635 2c0b157, #636 4aade9f | clean |
| #638 64e6621 | clean |
| #634 40a85e1 (lane 2, #610: `_lockstep` prints a fourth `switch -c`) | clean, and S2 fires on it. A committed merge, 4d1a7161: the new module, `test_validate_family_branch_step.py` and #634's `test_pinned_copy_text.py` give `2 failed, 86 passed`. The two failures are the guard, `assert [124] == []` (line 124 of the merged file is `_lockstep`'s return), and its control. With one line added to `_lockstep`, `f"moving one alone is what this refuses. {BRANCH_LEFT}"` in place of the plain string, the same merge gives `306 passed` over the nine files that read the validator or the lockstep rule (`test_validate_family*.py`, `test_family.py`, `test_family_tracking_branch.py`, `test_pinned_copy_text.py` and the new module). So the second PR to land needs that one line and nothing else. |
| #639 6f938ed (lane 5, #611) | conflicts in the same one sentence, in the same two places: the end of the `member-gitlink-unnamed` remedy text in `validate-family.py` (`_check_gitlinks_named`), and the `GITLINK_REMEDY` constant in `tests/test_validate_family_unnamed_mounts.py`. #611 adds "and commit that removal, since `family.py add` refuses ..." and this PR appends `BRANCH_LEFT`. Resolved as in the first round, keeping #611's clause and appending ` {BRANCH_LEFT}` in the validator and `+ BRANCH_LEFT` in the test constant, in a committed merge (92602308): `scripts/family.py` merged cleanly, and the six test files that either PR changes or pins gave `157 passed`. Neither branch was resolved. |

- No compound `||` line and no state check: see decision 1.
- No README.md edit: no README sentence becomes false, because no new kind of line is printed.
- The docs do not mention the exists case. AGENTS.md is at its cap, and the tools print the sentence where the failure happens.
- The `switch -c` in `init`'s NEXT STEPS gets no sentence: a holder `init` just made has no branch an earlier act could have left.
- **A branch checked out in another worktree (N1).** Where `family/add-atlas` is checked out in another worktree of the holder, `git switch family/add-atlas` gives `fatal: 'family/add-atlas' is already used by worktree at '<path>'` (exit 128), so the going-back half of the sentence does not work there. Git names the worktree, and another name works (`probe-n1.sh`: `switch -c family/add-atlas-2` exits 0). The sentence is not made longer for it.
- I could not run Windows PowerShell or macOS here. The tests read `family.py`'s lines with `host_words`, which parses PowerShell's quoting on Windows. They make the earlier branch with `commit-tree` and `update-ref`, move `main` with the same two, write no file with `write_text` and compare nothing CRLF-sensitive. `test_another_name_works_where_main_has_moved_since` runs `git merge-tree --write-tree`, which needs git 2.38 or newer. No CI job checks Windows or macOS.

- **bump-leg.py: the `git switch -c bump/<role>-<sha12>` remedy fails `already exists` when an earlier bump left that branch.** `_switch_command` (scripts/bump-leg.py:1124) prints the line for the two refusals and the dry run's closing hint. Like #607, it says nothing about a branch that is already there.
- **setup-project.py: the family `git -C <holder> switch -c family/add-<project>` line has the same gap.** The NEXT lines a scaffold into a family folder ends with (setup-project.py:2358) print it with no word on an existing branch. It matters only when an earlier attempt left that branch.

- L2-46 ([#459 comment](#459 (comment)))
- L2-45 ([#459 comment](#459 (comment)))

Review: rv607 found 0 blockers, 2 should-fix and 7 nits on 6212c31 (CHANGES NEEDED), fixed in 66b1043; the re-verify rv607b found 5 nits on 66b1043 (APPROVE WITH NITS), of which nits 1, 3 and 4 and the docstring half of nit 5 are fixed in a277695, and nit 2 is left. CI (Brett Heap's ruling 27, with his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a277695 passed tests (Linux), tests-git-bash and SonarCloud; the head 62263d2 is a clean update-branch merge of main a524542 (over e041867, a clean merge of 5b29ee1), with SonarCloud, tests-git-bash and package green on it, and the local gate passed on the head itself: the family and validator set, 1016 passed (20 skipped), and the hygiene, AGENTS.md-code, README-section and CLI-reference sets, 222 passed. Landed on Brett Heap's word of 2026-10-16T11:21:49Z, "Merge #637 when its turn comes".

Closes #607

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 16, 2026
…modules: the gitlink remedy keeps #611's commit-the-removal clause and ends with BRANCH_LEFT (#611 x #607)

This merges origin/main at 95780dd, which
holds #637's squash (95780dd, "family.py and validate-family.py: where an
earlier act left the branch ...", #607). It adds `BRANCH_LEFT` to
scripts/family.py and templates/family-root/scripts/validate-family.py, and
the guard `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence`
in tests/test_family_switch_branch_exists.py. Lane 2's #634 (#610) is not in
main yet, so `_check_member_gitlink` is untouched here.

Two hand resolutions, one sentence in two places, as #639's body named it:

- templates/family-root/scripts/validate-family.py, the end of
  `member-gitlink-unnamed`'s remedy: #611's "and commit that removal, since
  family.py add refuses a path under another spelling of members/ that HEAD
  holds and the index no longer does, then mount it with the third, ..."
  clause is kept, and #607's `BRANCH_LEFT` ends the prose, as
  "... if one is still there. {BRANCH_LEFT}\n".
- tests/test_validate_family_unnamed_mounts.py, `GITLINK_REMEDY`: the same
  clause, ending ".git/modules if one is still there. " + BRANCH_LEFT.

No other file conflicted. #637's guard test passes on this merge without a
further change: the remedies #611 added name no `_switch_line`, so no
BRANCH_LEFT is appended beyond the one resolved above.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…-text: remedies keyed by pin_product_key, the key the chain readers use (#444 x #610)

The remedies keying fix is in the merge below, b7472df: `_link_remedies`
keys a link's remedy by `pin_product_key`, and `_chain_link_findings`
finds it by that key, as #638's chain readers do. Keyed by `casefold()`,
the chain `openß, openss, openInk` wrote one entry for its two links, so
the first link was offered the later link's words. This merge adds #623
and #637, and git merged them clean. One more change rides with it
(#607 x #610): `_lockstep`'s remedy now ends with #637's BRANCH_LEFT
sentence, as each remedy that names `_switch_line` does. Without it,
#637's guard, test_every_remedy_that_names_a_switch_line_ends_with_the_sentence,
and its partner test fail on the merge.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…import beside #728's FREE_PLAN_HINT import

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 16, 2026
…eside another leg's digest or workflow reference names the step that comes first, that gitlink put back by hand, and then the other leg's `bump-leg.py` line, so neither finding says "First clear that finding" over a finding no line can clear first (#677) (#702)

From #460's triage, on Brett Heap's word of 2026-10-16T12:24:41Z ("give #460 to
lane 3"). It lands on his standing word, "standing word covers the #460 PRs"
(12:58:08Z), after #634 as planned. Closes #677.

Where one leg's gitlink is off its pin beside a finding of the other leg's (its
digest or a workflow reference), no `bump-leg.py` line could clear either
first. Both findings now name the hand step that comes first:

- Put that gitlink back at its pin and commit it, with every workflow
  reference to its repository at that commit too. Or undo the commit that
  moved it and run `git submodule update -- <legs>` in this root.
- Then the gate names the other leg's line. If the move was meant, bumping the
  leg to the full 40-character commit after that line keeps it.
- Where the off leg's own digest is also wrong, the text says no line comes
  while that finding stands.
- #478's two-gitlink text gains the same reference clause and the same
  `git submodule update` step.

`templates/assembly-root/scripts/validate-pins.py` is a pinned copy, so
projects see it as `upstream-changed`. The doctor mirrors the texts. RV-b's N2
(the "agree" note counting mismatched references) is #739, in v1.1.1.

Lane 3's independent reviewer (RV-b) found it READY in round 3. Every printed
line was pasted and run through to COMPLIANT. Its gate on the squash
predicted onto main is in the landing line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 16, 2026
…member THE LOCKSTEP RULE rides on `Gitlinked`, so the two-pass gate (#648) passes it to the digest finding (#648 x #610)

One file conflicted, `templates/family-root/scripts/validate-family.py`,
in two regions, both where this branch split `_check_member` in two
(the gitlink first, the checkout once every row's gitlink is asked) and
#634 (#610) made THE LOCKSTEP RULE a per-member `_lockstep(...)` value
passed to the gitlink and tree checks:

1. `_check_member`'s gitlink call. #634 computes
   `rule = _lockstep(root, project, str(row.get("id") or project))`
   and passes it to `_check_member_gitlink`; this branch returns a
   `Gitlinked` there. Resolved: the call passes `rule`, as #634 has it,
   and `Gitlinked` gains a sixth field, `rule`, which the return
   carries.
2. `_check_member`'s tail, which this branch moved into
   `_check_member_checkout`. #634 passes `rule` to `_check_member_tree`.
   Resolved: `_check_member_checkout` passes `asked.rule`.

So each member's gitlink-mismatch and digest-mismatch findings end at
the same rule #634 gives them, naming that member, and the closing
count's `_lockstep(root)` is #634's, merged clean. `Gitlinked`'s
docstring names the new field. No other file conflicted. The
resolution is the one rv648 built and tested in its scratch clone
(the same blob of #634's file, 519b71f), plus that docstring.

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 16, 2026
…`execute` hand git and gh each name, url and branch and the leg description as the UTF-8 bytes it spells, so none of them ends in a `UnicodeEncodeError` before any leg exists (#653) (#727)

Under `LC_ALL=C PYTHONUTF8=0`, `adopt-project.py` handed git and gh the names, urls and branches it read from git, and the leg description it writes, as text. So `plan`, `check` and `execute` ended in a `UnicodeEncodeError` traceback, exit 1, before any leg existed. This is #653 (#457's L1-33 to L1-35), in scope under Brett Heap's answer of 2026-10-16 ("In scope, file D4") and his 2026-10-12 ruling for #527: "pass git the exact bytes the tree holds, under any locale".

Now each goes to git and gh as the UTF-8 bytes it spells, as #614 did for `git rm`:
- `gh repo create --description` and `gh pr create --body`, whose em dash stopped every `execute` against GitHub at the first leg;
- `plan`'s `git grep` for code naming a spec directory such as `Ärger/`;
- the submodule urls, remotes and remote names `check` and `execute` resolve, with the `realpath` and #418's `git ls-remote` after them;
- `Source.open`'s default branch, such as `café`.

Each place now says what UTF-8 mode says.

Review: the adversarial review found one blocker, F1. `_git_out_exact`'s `git-failed` refusal no longer read as `git_out`'s, after #634 changed that wording on main. The fix round merged main and brought the refusal into line, with a test. The re-verification found it PR-READY. Its edge cases outside #653 go to #457's checklist.

The branch merged main once before its last head (217a8f7), and GitHub's update-branch merged main once more, a clean merge. The branch was never rebased. It lands under the fast-refresh ruling of 2026-10-15T22:48:35Z.
- The head 0274b68 is green on Linux, Git Bash and SonarCloud.
- Every commit after it is a clean merge of main.
- The head 4fa9f52 is green on SonarCloud and Git Bash.
- The full suite passed on its tree. Lane 1's gate ran it on that tree, predicted before main reached it, with 197 test files: 11,411 passed and 33 skipped.

Closes #653

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 16, 2026
…eck_member_gitlink` takes both `repository` (#681) and THE LOCKSTEP RULE's `rule` (#610), and the gate-order test keeps `import re`, which the merge dropped with no conflict (#681 x #648)

#682 (fix/648-bootstrap-and-gate-order) moved on from 8b39cd1 to
45c512e: main 2fac979 merged in (bad6c00, which brings #634's
per-member THE LOCKSTEP RULE onto `Gitlinked`), then 1a69505 and
45c512e. Two files conflicted; every other file merged clean.

templates/family-root/scripts/validate-family.py, three regions:
- WHAT IS CHECKED: #682's narrower wrong-case sentence, then #681's
  staged-removal sentence after it.
- `_check_member_gitlink(root, path, commit, project, repository,
  gitlinks, report, rule)`.
- `_check_member` passes `row.get("repository"), gitlinks, report, rule`
  and returns `Gitlinked(row, project, path, pin, commit, rule)`.

tests/test_family_bootstrap_gate_order.py, two regions:
- the module docstring keeps both sentences, the guarded `mv` (#681) and
  the clone of a stray (rv648's S1);
- the import from test_family_head_lost_gitmodules is the union of both
  sides (GITMODULES and committed_gitmodules from #682, GUARDED_MOVE and
  typed_in_sh from #681).
- `import re` is put back by hand: this branch had dropped it, and #682's
  side uses `re.compile` and `re.findall` again (rv681's restack note).
  `import subprocess` stays dropped; neither side uses it.

The resolution equals rv681's model (_lane5-scratch/rv681-restack2, built
on d91fd15) with this branch's fix round d91fd15..dbca113 applied
(`git merge-file`, byte for byte), and the gate-order test is the model's
byte for byte.

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 16, 2026
…ink no tree answers for is offered what each caller reads, so the root's validator and setup's step (4) no longer offer `--link-source`, which neither takes, and the gate spells every `--link-source` value and path it prints (#661) (#744)

A chain link whose own tree nothing could read is declared-unverified,
and its WARNING offers what would let that tree be read. Since #634 the
scaffold offers its own sources (`link_pins_from_trees(..., remedies=)`).
The root's validate-manifest.py and setup's step (4) still printed the
naming gate's words, "a sibling checkout, SHAPE_PIN_SOURCE_<LINK>, or
--link-source <link>=<path>", and neither takes `--link-source`.

L2-68: validate-manifest.py offers what it reads,
SHAPE_PIN_SOURCE_<LINK> (unless another link shares it, #275, when the
checkout alone is offered) and a checkout beside the root, for each link
as `repo_basename` names it, keyed by `pin_product_key`. It passes them
as `remedies=` only where the root's repo_shape.py signature takes the
keyword (`inspect.signature`), so beside an older library (pre-#634,
v1.0.1) it runs and keeps that library's words; an unguarded `remedies=`
there was a TypeError.

L2-75: the naming gate takes `--link-source-label=FLAG`, the caller's
name for the bare source it passes, and `--link-remedy LINK=TEXT`, the
caller's words for a holder, passed on as `remedies=` under the same
guard. With a label the link-source-unreadable WARNING says `(FLAG)`,
the not-used NOTE names the value by FLAG, neither says a value holding
`=` was read as a PATH, and the NOTE lists no keyed `--link-source`; the
help says which lines take the label, and the docstring names the two
that keep the gate's flag, which setup never reaches. Setup's step (4)
passes `--link-source-label=--pin-source` and one `--link-remedy` per
holder of a chain of two links or more, in the scaffold's own words: its
`_link_remedies` moves, unchanged, to
shape_materialize.scaffold_link_remedies, which both load. The test
helper's `re.sub` of the gate's offer is gone.

L2-69: the gate spells with its own `_shown` the key `read_as_path`
names, `_also_a_directory`'s value, the path of a variable or a checkout
beside the project that could not answer (and that checkout's `how`),
`no_path_refusal`'s value, and the not-used NOTE's value and the value
that replaced it. Its docstring says so, and that a caller's FLAG and
words, a chain link's name and the --project and --policy paths are
printed as given.

docs/cli.md is regenerated for the gate's two options. Because of this
change, assembly roots re-pin scripts/validate-manifest.py and
scripts/validate-repository-naming.py; family holders re-pin nothing new;
against v1.0.1 both already differ at main, so no file is added to a
project's v1.1.0 update. scripts/repo_shape.py is untouched.

Lands after #634.

Closes #661

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
main gained 25 pull requests since de92614: #634, #699, #671, #719,
#701, #717, #700, #702, #722, #720, #725, #721, #728, #705, #727, #718,
#744, #743, #749, #740, #737, #734, #742, #751 and #736. No conflict:
AGENTS.md, adopt-project.py and docs/cli.md merged as text, and
README.md and docs/handbook.html are unchanged by the merge. README.md
is 1473 lines, its cap; AGENTS.md 494, its cap; every handbook marker is
current; `render-cli-reference.py --check` is ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…es every reason THIS checkout cannot answer, and the notes are current to main bf1f099 with the 25 pull requests since de92614 (#563, #683)

- W683 and RV-f's N4 on #742: README's exit-3 list (Keeping a project's
  shape current, :1208-1220) and its handbook twin (`doctor`) named only a
  pin whose commit this checkout does not carry. They now say, as
  shape-doctor.py's EXIT CODES does: a pin's commit it lacks, a partial
  clone missing what is compared (#683), a read that fails
  (`update-upstream-unreadable`), a crashed validator of its own (#278,
  #410), or its own naming policy not loading (#678). +1 line, with
  tests/readme-cap.d/683.txt; README.md is 1474, its cap (1422 + 2 + 5 +
  12 + 22 + 10 + 1). The two `Keeping a project's shape current` markers
  move: `doctor` text and marker, `current` marker only.
- The notes, "What changed since v1.0.1", one entry per pull request from
  `git log --first-parent de92614..bf1f099`, each from its squash commit and
  the issues GitHub says it closes:
  - adopt-project.py: #699, #719, #749, #727, #717, #728 (with #621's
    hint), #743 + #720 + #718, #722, #721;
  - update-shape.py, bump-leg.py and a project's own scripts: #702 (with
    the pinned validators), #634;
  - scaffold, setup, family and the shared readers: #725, #671, #744;
  - shape-doctor.py: #742, #701, #737, #751, #736, #734, #700 + #705,
    #740;
  - install, CI and the docs: the exit-3 sentence above.
- Counts (99 pull requests since bfed143; 109 of 135 milestone issues
  closed at 10:28Z), the "current to main" sha and the open list (26) are
  at main bf1f099, still placeholders for tag time.

Left for round B: lane 5's eight lines (#630 issuecomment-6227129879),
whose pull requests #726, #747, #682, #724, #746, #745 and #738 are open
drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
Main's 28 new commits since 8062810 merged with no conflict. Four of them touch a file this branch changes: #744, #728, #671 and #634 edit scaffold-project.py, and #728 and #671 edit scripts/family.py too, each in other functions than the clone URL's remedy, its refusal and the README values. Nothing this branch changes is a pinned copy, so nothing was re-pinned.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
…another spelling of its row's path moves it back to that path in the index alone, so the printed remedy no longer takes the member's gitlink away from its row (#646) (#670)

**Goal (#646): no remedy `family.py` prints un-registers a member that is in the holder.** Three states are outside it, each under "Left out": a `.gitmodules` that a `git mv` made name the variant too (checklist candidate (e)); a file the index holds at or under the row's path, where #574's remedy is kept; and a file or link the index holds at `members` itself (candidate (h)).

A member's only gitlink sat at `Members/<P>`, while its row and `.gitmodules` said `members/<P>`. This is #363's state after the fact. `family.py add` of ANOTHER member refused, as #574 rules (`family-members-dir-variant`). Its remedy was #574's remedy for any path under another spelling of `members/`:

- `git rm -r --cached -- Members/<P>`, and commit the removal, where nothing is on disk in that spelling;
- `git mv -- Members/<P> <new name>`, and commit the rename, where something is;
- `git commit`, where HEAD alone holds it (the variant's removal staged, not committed).

Each one, typed as printed, left no gitlink at the row's path. The holder's validator then refused `member-gitlink-absent`. Reproduced on BASE a524542 in all five layouts below.

All in `scripts/family.py`.

- `_members_spellings` takes the rows' own paths (`_rows_paths`: the one path `_member_path` gives a row, `members/<project>`; a row it refuses is `member-path`'s).
- **Which path is the member's own.** The index must hold nothing at or under the row's path. Then a gitlink at a case variant of that path, in the index or in HEAD alone, is that member's. It gets a new remedy, `RESTORE`, placed in `REMEDY_ORDER` after the index's own remedies.
- **The remedy** is the index-only move that `validate-family.py` prints for the same state since #639. Brett Heap accepted it on 2026-10-16T11:21:49Z ("Accept; merge #639 after #637"), in place of the "committed `git mv`" of his "Refuse on every disk" ruling, because `git mv` to the row's path fails or misplaces the gitlink:
  - `git rm -r --cached --ignore-unmatch -- <variants>`. It touches no file, and it is no error where HEAD alone holds the variant. Where a sparse checkout keeps a variant off disk (`skip-worktree`), the line says `git rm -r --cached --sparse --ignore-unmatch -- …`.
  - `git update-index --add --cacheinfo 160000,<commit>,members/<P>`, one line per row, at the commit the variant entry records. For an unmerged variant, that is the commit at stage 2.
  - A commit, where one is left to make.
- **What the disk holds afterwards** gets the validator's own sentences, placed after every other remedy:
  - where nothing is checked out at the row's path, as in a fresh clone, `git submodule update --init --recursive`;
  - on a case-sensitive disk, `mv -- Members/<P> ../Members-<P>` for the directory left at the variant;
  - on macOS and Windows, leave it, since it is the row's own directory.
- A directory on disk at a gitlink moved back from HEAD is the move's to name. It is not named a second time as untracked.

The branch merges main 35133f2, which holds #639 (c6122e8), in one merge commit with no conflict.

Example: the issue's state, the checkout at `members/IRRS`:

```
REFUSED family-members-dir-variant: the family root holds a path at or under another spelling of members/, which a case-insensitive disk takes for members/ itself — in the index: `Members/IRRS` (a submodule)
Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Move each member's gitlink held only under another spelling of its row's path, `Members/IRRS` for the row members/IRRS, to that path, at the commit it records, in the index alone, which touches no file (`git rm -r --cached --ignore-unmatch -- Members/IRRS`, then `git update-index --add --cacheinfo 160000,<commit>,members/IRRS`), and commit that; where nothing is checked out at members/IRRS, as in a fresh clone, `git submodule update --init --recursive` then checks the member out there; on a case-sensitive disk, as Linux's usually is, a directory left at Members/IRRS stays there, untracked, and add refuses it as family-members-dir-variant: move each one that is there out of the holder, `mv -- Members/IRRS ../Members-IRRS`; on macOS and Windows it is the row's own directory, so leave it; then re-run.
```

No pinned file changes: `scripts/family.py` is the standard's own tool, not a copy in a holder. No `--help` text changes, so `docs/cli.md` is untouched. A `git grep` of AGENTS.md, README.md, `docs/handbook.html` and the family-root templates found no sentence about `add`'s case-variant remedies, so none became false.

1. **The member's own gitlink: one whose path folds to a row's path, where the index holds nothing at or under that path.** The next commit records the index, and every remedy here ends in one.
   - If the index holds the row's gitlink, a gitlink at a variant is a second one. Taking it out removes nothing from the row, so it keeps #574's remedy, and moving it back would overwrite the row's gitlink.
   - If the index holds a FILE at the row's path, the move would replace it without a word. If it holds files UNDER the row's path, the move's second line fails (`appears as both a file and as a directory`) after the first has already taken the variant out. Both keep #574's remedy (lane 3's review RV670, N3).
   - A file at the variant, or anything under it, also keeps #574's remedy.
   - `test_only_the_members_own_gitlink_is_moved_back` holds all of these, 9 cases.
2. **The commit named is the one the variant entry records, so the remedy is a move, not a bump.** Brett Heap decided this at 2026-10-16T14:11:42Z: "take all recommendations for #670 and #681". That word replaces his 13:07:34Z take of "the row's pin".
   - Where a row has two variants, the first found is named, the index's before HEAD's.
   - **This departs from #646's Direction line (`<pin>`) and from #639's rule:** #639's restore in `validate-family.py` names the row's pin. The two tools give different lines where the variant records a commit the row does not pin, and, since S1, where a sparse checkout keeps the variant off disk: `add` prints `--sparse` and the validator does not (candidate (f)).
   - **The four reasons, as lane 3's review RV670 measured them:**
     1. **A pure rename.** With the variant's commit, the move changes only the path. Where the commits differ, the gate then reports `member-gitlink-mismatch`, which has its own LOCKSTEP text and `bump` remedy, and exits 1, and an `add` of another member still goes through. `test_a_commit_the_row_does_not_pin_is_moved_not_bumped` types that end to end. (Where the variant equals the pin, the bytes are the same either way.)
     2. **The pin would silently bump.** With the pin, the paste changes the commit the holder records: a roll-back or an advance, a bump done outside `bump`, which the LOCKSTEP text says the gate refuses.
     3. **A row with no pin needs a fallback.** The validator never prints its remedy for such a row, because `member-pin` or `member-pin-commit` stops the row first. So #639's rule has no answer there, and a pin-based `add` would need the variant's commit anyway.
     4. **A pin absent from the clone fails.** `update-index --cacheinfo` does not check that the object exists, and the printed `git submodule update --init --recursive` then fails with rc=128 (`Fetched in submodule path 'members/IRRS', but it did not contain 3333…`). The variant's commit is already recorded, so this remedy adds no new unreachable commit.
   - **The validator is not changed.** Naming the variant's commit there too (it already prints it, as `(at <commit>)`) would edit a pinned template, and that needs its own word, which nobody has given.
3. **An unmerged variant is moved back at its stage-2 commit** (RV670, N2). `ls-files -s` lists stage 1, the merge base, first, and the PR's first head took that one.
   - Stage 2 is HEAD's entry each time, so the move is a rename relative to HEAD, and the validator also answers an unmerged path from HEAD. In a merge and in a stash pop it is the holder's own side. In a rebase it is the upstream's side, which is HEAD while the rebase runs: after `git rebase --quit` the move names the upstream's commit, not the person's own, which is stage 3 (probed by rv670b, the re-verify of this PR).
   - The removal takes out every stage, and the commit after it goes through (`$Q/v112/w646b/probes/w646b-unmerged-probe.log`).
   - An unmerged variant with no gitlink at stage 2 is no member's own: HEAD's entry holds no gitlink there, whatever stages 1 and 3 hold. It keeps #574's remedy, where stage 2 is missing and where it is a file. A gitlink at stage 1, a file at stage 2 and a gitlink at stage 3 is an `OWN` state too (rv670b's NIT-3): git lists stage 1 first, and rv670b's mutant R1, which read the first record's mode in place of stage 2's, passed all 36 tests and moved the file's blob back as a gitlink.
   - The state is reachable only with unmerged entries and no in-progress mark. A conflicted `git stash pop` leaves it, and so do `git merge --quit` and `git rebase --quit` (rv670b probed both). `add` refuses a merge, rebase, cherry-pick, revert or `git am` in progress (`family-operation-in-progress`) before it gets here.
4. **A variant a sparse checkout keeps off disk is taken out with `--sparse`** (RV670, S1). Without it, `git rm -r --cached --ignore-unmatch` exits 0 and removes nothing, so the index then held two gitlinks, and the next `add` refused again. With it, the entry goes, measured on git 2.43 with and without `core.sparseCheckout` (`$Q/v112/w646b/probes/w646b-sparse-probe-*.log`). The flag is printed only where a variant needs it, so the line stays the validator's everywhere else. Git older than 2.34 has no `git rm --sparse`: see "Left out".
5. **The commit is printed only where one is left to make.** That is:
   - HEAD holds a variant (RV670's S2 state, HEAD holding the gitlink in both spellings at one commit, is one of these);
   - HEAD's gitlink at the row's path is another commit, or there is none;
   - or a pending change is committed first.

   A move to the variant that is only staged, with HEAD still holding the row's gitlink at that commit, leaves nothing to commit, and a printed `git commit` would fail as typed.
6. **The code stays `family-members-dir-variant`** (or `member-path-taken` where some path is also a variant of the new member's mount path). The add is still refused for this path, as for any path under another spelling of `members/`: with `core.ignorecase` set, git would stage the new gitlink as `Members/<new>` (#362). Only the remedy changes.
7. **The closing sentences come last**, after #574's disk remedies.
   - Where a `members/` renamed by hand is moved back (`mv -- Members members`), the checkout goes home with it.
   - If they came before that, the `mv` out of the holder would act on `Members/<P>` while it may still be the member's checkout. And `git submodule update` would make a `members/`, inside which `mv -- Members members` would then put `Members` (`members/Members`).
   - `test_with_the_other_remedies…[members/ renamed by hand]` holds the order: it types the move back and then `git submodule update`, which checks the member out in the `members/` moved back.
8. **One text on every disk.** The move reads the index and HEAD, never the disk. The dry run and the real run print the same two lines. Asked in process of an emulated folding disk and of a Linux one (`disk_as`, as `tests/test_family_members_case_variants.py` emulates it), each layout gives the same text the subprocess gives.
9. **The disk after the move.**
   - On a case-sensitive disk the variant keeps either a checkout (15 entries, `?? Members/`) or, after a fresh clone, an empty directory. The gate passes either way.
   - If the printed `mv` is not typed, the next `add` refuses `family-members-dir-variant ... on disk and untracked: Members/Atlas` with #574's `mv -- Members/Atlas <new name>`, and passes once that is typed.
   - If the printed `mv` is typed, the next `add` passes at once (`$Q/v110/w646/leftover.log`, `repro-head.log`).

All runs used `$Q/v110/run9.sh`: a `gh` stub that exits 97 first on PATH, `GH_TOKEN=bogus-l5`, an empty `GH_CONFIG_DIR`, `/usr/bin/python3 -m pytest`, and local bare remotes only.

`tests/test_family_add_member_variant.py` is new, with 37 tests. Every printed command is typed as printed. The holder's gate then passes (or, in the one case where the variant records a commit the row does not pin, names that drift), the member is where its row says at its commit, and the same `add` goes through.

- `test_each_layout_moves_the_gitlink_back_as_printed` (4 layouts) and `test_a_fresh_clone_moves_the_gitlink_back_as_printed`.
- `test_every_disk_gives_the_same_answer` (4 layouts × folding and Linux, in process).
- `test_with_the_other_remedies_each_runs_in_its_order` (8 states):
  - beside a file under `Members/`;
  - after a pending change in HEAD;
  - with nothing left to commit;
  - `members/` renamed by hand;
  - HEAD alone, with the checkout at the variant;
  - HEAD holding both spellings at one commit, the row's removal staged (S2);
  - kept off disk by a sparse checkout (S1; skipped on git older than 2.34, as #574's sparse case is);
  - unmerged at stages 1, 2 and 3, with nothing in progress (N2).
- `test_every_member_held_in_the_wrong_case_is_moved_back` (two rows).
- `test_the_gitlink_is_recorded_at_the_commit_it_records` (4 states).
- `test_a_commit_the_row_does_not_pin_is_moved_not_bumped` (S3): typed as printed, the gate exits 1 with `member-gitlink-mismatch` alone, and the same `add` goes through.
- `test_only_the_members_own_gitlink_is_moved_back` (9 states, four of them new: a file at the row's path, a file under it, an unmerged variant with no gitlink at stage 2, and one with a gitlink at stage 1, a file at stage 2 and a gitlink at stage 3).
- `test_the_rows_that_have_a_path_of_their_own`.

Layouts that need two directories differing only in case skip on a disk that folds case. No test makes such directories there.

Two existing lines change to the new shapes:
- `Spelled` has two more fields, `recorded` and `sparse`, so a seven-field tuple compared in `tests/test_family_members_case_variants.py` gains `"", False`;
- the three-argument stand-in for `_members_spellings` in `tests/test_family_add_refusals.py` takes `rows`.

The S1 case sits in the new file, beside its other move-back states, and not in `test_family_members_case_variants.py` beside #574's sparse test: it needs the new file's helpers, and that file already imports from the other one.

Results on this head. a837a02 is 2104a3d plus one test commit: it adds one state to `OWN` and changes no code, so the runs of 2104a3d below stand for every file it does not touch.

| Run | Head | Result |
|---|---|---|
| the new file | a837a02 | `37 passed in 48.55s` |
| `tests/test_family_members_case_variants.py` | a837a02 | `60 passed in 53.83s` |
| the new file, with `scripts/family.py` put back to the merge's (bc48c2a) | 2104a3d's tests | `5 failed, 31 passed in 170.09s`: the sparse and unmerged states typed, and the OWN cases of a file at the row's path, a file under it, and an unmerged variant with no gitlink at stage 2 |
| the new file, `test_family_members_case_variants.py`, `test_family_add_refusals.py` | 2104a3d | `161 passed in 809.30s` |
| `test_family.py`, `test_family_member_path.py`, `test_validate_family_unnamed_mounts.py`, `test_readme_windows_quoting_sentence.py`, `test_repo_hygiene.py` | 2104a3d | `269 passed in 803.63s` |

The S2 and S3 cases pass on the merge too, since they pin behaviour the PR already had. Lane 3's mutant X2 fails the S2 case (below). The S3 case holds the variant's-commit rule end to end.

Earlier, at the PR's first heads (before the merge with main):

| Run | Head | Result |
|---|---|---|
| the new file, on BASE a524542 | a524542 | `29 failed in 48.75s` |
| every `tests/test_family*.py`, 21 files | 2216e1c | `851 passed, 19 skipped in 1248.90s` |
| `tests/test_validate_family*.py` (5), `test_refusal_default_remediation.py`, `test_repo_hygiene.py`, `test_siblings_family_name.py`, `test_template_text_placeholders.py`, `test_clone_url_bytes.py`, `test_update_shape_header_comments.py`, `test_update_shape_pr_base.py`, `test_shape_doctor.py` | 2216e1c | `1014 passed, 3 skipped in 1323.30s` |

Holder `Fam` with members Atlas and Nova, Atlas's gitlink moved to `Members/Atlas` in the index at its own commit and committed; then `family.py add` of Zed (`$Q/v110/w646/probes/repro.sh`), at the PR's first head.

| Layout | BASE a524542 (`repro-base.log`) | head 79754b9 (`repro-head.log`) |
|---|---|---|
| L1, nothing on disk in that spelling | `git rm -r --cached -- Members/Atlas`, commit: no gitlink for Atlas; gate `member-gitlink-absent` | rm, `update-index`, commit: Atlas at `members/Atlas` at its commit; status clean; gate exit 0 in all three modes; next `add --dry-run` exit 0 |
| L2, the only checkout at `Members/Atlas` | `git mv -- Members/Atlas renamed-1`, commit: gitlink at `renamed-1`; gate `member-gitlink-absent` | rm, `update-index`, commit, `git submodule update --init --recursive`, `mv` out: the same end state |
| L3, both directories | `git mv`, commit: gitlink at `renamed-1`; `?? members/Atlas/`; gate `member-gitlink-absent` | rm, `update-index`, commit, `mv` out: the same end state |
| L4, a fresh recursive clone (an empty `Members/Atlas`) | `git mv`, commit: gitlink at `renamed-1`; gate `member-gitlink-absent` | rm, `update-index`, commit, `git submodule update`, `mv` out: the same end state |
| L5, the variant's removal staged, not committed | `git commit`: the removal committed; gate `member-gitlink-absent` | rm (`--ignore-unmatch`, no error), `update-index`, commit: the same end state |

In every layout, on both sides, the dry run and the real run printed the same stderr, and the in-process run under a folding disk and under a Linux disk printed the same two lines as the subprocess. This round's states (S1, S2, S3, N2, N3) are reproduced by the tests above, which type each printed line.

Not reproduced: a real macOS or Windows disk. The folding disk is emulated. CI runs Linux only (#597).

**This round** (`$Q/v112/w646b/probes/w646b-mutate.py`, in throwaway clones at 2104a3d). Each probe changed one hunk or condition, and the new file was then run with `-x`. Every hunk of this round fails a test, and so do lane 3's X1 and X2 and rv670b's R1, which needed a new state (last row).

| Probe | What it undoes | Result | First failing test |
|---|---|---|---|
| M1 | a variant never marked `sparse` | 1 failed, 19 passed | `test_with_the_other_remedies…[kept off disk by a sparse checkout]` |
| M2 | `--sparse` never printed | 1 failed, 19 passed | the same |
| M3 | back to "no GITLINK at the row's path" (the first heads' rule) | 1 failed, 31 passed | `test_only_the_members_own_gitlink…[a file at the row's path]` |
| M4 = lane 3's X1 | "no entry AT the row's path", under it not asked | 1 failed, 32 passed | `…[a file under the row's path]` |
| M5 | the first record git lists (stage 1), as the first heads had it | 1 failed, 20 passed | `test_with_the_other_remedies…[unmerged, with nothing in progress]` |
| M6 | stage 0 alone: an unmerged variant is never the member's own | 1 failed, 20 passed | the same |
| M7 | the last record (stage 3) | 1 failed, 20 passed | the same |
| lane 3's X2 | `name in in_head or` deleted | 1 failed, 18 passed | `test_with_the_other_remedies…[HEAD holding both, the row's removal staged]` |
| lane 3's X3 | `'posix'` dropped from the move back's three quotings | 36 passed (survives) | none on Linux: `shell_quoted` quotes for `os.name`, POSIX here (see "PowerShell" under "Left out") |
| rv670b's R1 | `ours[0] == b"160000"` replaced by the mode of the first record git lists (`mode == 0o160000`), in place of stage 2's | 36 passed (survived) at 2104a3d. At a837a02: `1 failed, 34 passed in 44.07s` (`-x`), and `1 failed, 8 passed, 28 deselected` for `-k test_only_the_members_own_gitlink` | `test_only_the_members_own_gitlink…[unmerged, with a file at stage 2 and gitlinks at 1 and 3]` |

The S3 test types the remedy end to end and asserts the gate's finding, so it holds the variant's-commit rule itself; no mutant of this round touches that rule.

**The first heads** (`$Q/v110/w646/probes/mutate.py`, in a throwaway clone at 2216e1c). The new file was run with `-x`.

| Probe | What it undoes | Result at 2216e1c | First failing test |
|---|---|---|---|
| H1 | `RESTORE` in `REMEDY_ORDER` | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H2 | no member's own gitlink found | 1 failed | the same |
| H3 | a gitlink at the row's path not asked | 1 failed, 22 passed | `test_only_the_members_own_gitlink…[a second gitlink beside the row's]` |
| H4 | any index entry, not a gitlink alone | 1 failed, 23 passed | `…[a file at the variant]` |
| H5 | the index's branch | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H6 | the commit always printed | 1 failed, 15 passed | `test_with_the_other_remedies…[with nothing left to commit]` |
| H7 | the commit printed only where HEAD holds the variant | 28 passed (survived) | killed at 79754b9: `test_the_gitlink_is_recorded…[staged at another commit, HEAD keeping the row's]` |
| H8 | the commit read from HEAD's gitlink at the row's path first | 28 passed (survived) | killed at 79754b9, by the same case |
| H9 | HEAD's branch | 1 failed, 3 passed | `test_each_layout…[the removal staged, not committed]` |
| H10 | HEAD's file taken for a gitlink | 1 failed, 25 passed | `test_only_the_members_own_gitlink…[a file at the variant in HEAD alone]` |
| H11 | the disk named again at a gitlink moved back from HEAD | 1 failed, 17 passed | `test_with_the_other_remedies…[HEAD alone, the checkout at the variant]` |
| H12 | the commit not printed after a pending change | 1 failed, 14 passed | `…[after a pending change in HEAD]` |
| H13 | no `--ignore-unmatch` | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H14 | the last variant found names the commit | 1 failed, 20 passed | `test_the_gitlink_is_recorded…[two variants in the index]` |
| H15 | the closing sentences before the disk's remedies | 1 failed, 16 passed | `test_with_the_other_remedies…[members/ renamed by hand]` |
| H16 | the closing sentences dropped | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |
| H17 | `add` passes no rows | 1 failed | the same |
| H18 | `_checked_mount_path` drops the rows | 1 failed | the same |
| H19 | `_rows_paths` takes any row's path | 1 failed, 27 passed | `test_the_rows_that_have_a_path_of_their_own` |
| H20 | `_rows_paths` does not check the project's type | 1 failed, 27 passed | the same |
| H21 | the move's part dropped from the text | 1 failed | `test_each_layout…[nothing on disk in that spelling]` |

The branch merges main 35133f2 in bc48c2a, with no conflict: its tree, 088aa26, is the one `merge-tree` predicted. Only #637, #622 and #639 had changed `scripts/family.py` since BASE.

`git merge-tree --write-tree` of the head a837a02, last after a fetch at about 18:45Z. Every one is clean.

- origin/main 2fac979 (tree 042d309). Since the last check (main de92614) it has landed #634 (30676c9), #659/#671 (6e37d0e), #658/#699 (b618ec1), #650/#719 (3dc34b8), #678/#701 (5c392dd) and #425/#717 (2fac979). The other rows are the heads as fetched now.
- Lane 4's five branches that change `scripts/family.py`: `fix/684-family-holder-states` 7015386, `fix/686-bump-reachability` 7567e60, `fix/688-family-init-values` aa95b6b, `fix/689-next-push-refspec` fa51384 (it also changes `tests/test_family_add_refusals.py`) and `fix/693-local-remote-dir` 96ebe45.
- Lane 4's other open heads: `fix/674-update-shape-apply` d069902, `fix/690-siblings-inputs` 38a40ab, #698 b069951, #700 fca40df, #702 48bac6e, #703 (`fix/696-setup-family-folder`) 4054ffd, #705 1bc63e8 and #706 (`fix/664-surviving-mutants`) 3e408b1.
- Lane 2's #671 and #699 and lane 4's #701 are on main now, so the main row covers them.
- Lane 5's #682 (#648, 8b39cd1), `fix/692-raw-folder-paths` ab55c29 (it changes `scripts/family.py` too) and `fix/704-bootstrap-leg-moves` 4e2b211.

- **A `.gitmodules` that names the variant too** (a `git mv members/<P> Members/<P>`, committed or staged; checklist candidate (e) below). The move puts the gitlink back at the row's path, but the section still gives `Members/<P>`. So `git submodule update` fails with `No url found for submodule path 'members/Atlas'`, and the gate refuses `member-uninitialized` (`$Q/v110/w646/gitmv-head.log`). On BASE the same remedy moved the gitlink and the section to `renamed-1` (`gitmv-base.log`). #639's validator prints the same incomplete lines for this state. A fix needs a `.gitmodules` edit that the validator does not print.
- **A file the index holds at or under the row's path, beside the variant's gitlink** (decision 1). #574's remedy is kept: it takes the variant out, and the file stays at the row's path. For a file at the row's path, typed, it leaves HEAD with `100644 blob … members/IRRS`, the gate refuses `member-gitlink-absent` (exit 2), and `add` of another member then goes through (exit 0), as rv670b ran it; a file under the row's path was not run. The move would replace that file, or stop half done, so no remedy here can both keep the file and put the member back. This state predates this PR.
- **A file or link the index holds at `members` itself** (a file `members`, or a committed link `members -> Members` left after a hand rename; checklist candidate (h) below). The rule asks about entries at or under a row's path, not above it. With every member at a variant and `members` a file in the index, `add` prints the move back, and rv670b typed it line by line: `git rm` takes out both variants (exit 0), then each `git update-index --add --cacheinfo` fails with exit 128, `'members/IRRS' appears as both a file and as a directory`. The next `add` prints the same move again, now from HEAD. A committed link behaves the same. #574's remedy also un-registered the member here, so it is not a regression. Adding `or row.startswith(name + "/")` to the rule would keep #574's remedy there, which leaves the member with no gitlink, as the entry above does. `scripts/family.py` is not changed for it here.
- **Git older than 2.34 and `--sparse`.** `git rm` has no `--sparse` option before 2.34 (git v2.33.0's `builtin/rm.c`), and it skips every `skip-worktree` entry (`if (ce_skip_worktree(ce)) continue;`), so there the line without the flag was the same silent no-op. The printed `--sparse` stops with an unknown-option usage error, exit 129 (from reading that source; not run). #574's `git mv --sparse` has the same limit. The S1 test is skipped on git older than 2.34, as #574's sparse test is.
- **PowerShell.** The printed lines are quoted for a POSIX shell (#532), so paste them into Git Bash. RV670 notes (N4) that in PowerShell the bare `160000,<commit>,members/IRRS` becomes an array passed as three arguments, which `--cacheinfo` also accepts. Not run, by RV670 or here. On Linux, dropping `'posix'` from these lines changes nothing (lane 3's mutant X3), since `shell_quoted` quotes for `os.name`, which is POSIX there.
- **The closing `mv` inherits #639's N-1 and N-2** (rv611c), and its N-3:
  - N-1: where the variant directory is a live checkout with an absorbed gitdir, the `mv` leaves its `.git` file pointing nowhere;
  - N-2: the `mv`'s condition is stated by OS, not by a check a person can run, so on a case-folding Linux mount (WSL's `/mnt/c`) typing it moves the member's own checkout out, and `git submodule update --init --recursive` then puts it back;
  - N-3: an empty `../<variant>` directory that already exists is moved into.
- **A variant under `members/` itself.** `members/irrs` for the row `members/IRRS` is invisible to `add`'s `_spelled_target`, while the validator's casefold check names it (RV670, N6). This predates this PR.
- **Case folding beyond ASCII**, as in #574: project names are ASCII, and git's `icase` folds ASCII only.

- **(e) `family.py add` and `validate-family.py`: the move back of a member's gitlink leaves `.gitmodules` naming the variant after a `git mv`.** After `git mv members/<P> Members/<P>`, git rewrites the section's `path` to `Members/<P>`. The index-only move both tools print puts the gitlink back at `members/<P>` and leaves the section where it is. `git submodule update --init` then fails with `No url found`, and the conditional `mv` would move the member's only checkout out of the holder. Both could also print `git config -f .gitmodules submodule.<name>.path members/<P>` and `git add .gitmodules` where a section gives a variant's path (evidence `$Q/v110/w646/gitmv-head.log`).
- **(f) `validate-family.py`: its move back of a member's gitlink is a silent no-op where a sparse checkout keeps the variant off disk.** Its `git rm -r --cached --ignore-unmatch -- <variant>` has no `--sparse`, so it exits 0 and removes nothing, and its `git update-index --add` then leaves two gitlinks in the index. This is the validator's half of RV670's S1, which RV670 assigns to #459. The validator is a pinned template.
- **(g) `validate-family.py` and `family.py add`: a file the index holds at or under a row's path, beside the member's gitlink at a variant, has no remedy that keeps the member.** `add` now keeps #574's remedy there, which takes the variant out. The validator asks `recorded_gitlink`, which skips any entry that is not mode 160000, so by reading its code (not run) it prints its move back for this state too: `update-index --add` would replace a file at the row's path without a word, and fail on files under it after its first line has taken the variant out. This state predates #670.
- **(h) `family.py add` (and probably the validator, not run): a file or link the index holds at `members` itself is never named.** The move printed beside it stops at its `update-index` line after `git rm` has taken every variant out (above, under "Left out"). Nothing here fixes it; the coordinator places it.

Review: lane 3's RV670 found 79754b9 READY with three SHOULDs; the fix round took S1-S3, N2 and N3, rv670b's re-review found it ready with nits, and a837a02 takes Brett Heap's ruling of 2026-10-16T14:11:42Z that the restore keeps the variant's own commit. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 a837a02 passed tests, tests-git-bash and SonarCloud; the head 6d7e4ff is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree b46e560 over main 6794a9f: the family_add_member_variant, family_add_refusals, family_members_case_variants, repo_hygiene, agents_md_codes, cli_reference sets, 308 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #646

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
…ck out fails the family bootstrap, every row's gitlink is asked before any checkout, and `--no-members` prints the case-variant move (#648) (#682)

**Head f74f640.** This branch holds #639 (#611), which landed as `c6122e8`, and main 2fac979, merged in bad6c00. Its diff against main is this PR's own change. `git merge-tree --write-tree f74f640 origin/main` is clean at 2a33a56.

**README PR.** It changes one sentence of README.md § Families, which this change makes false, and its twin in docs/handbook.html. The handbook's § Families marker is the segment's new blob, b3d19092. README stays at 1459 lines, its cap, and `tests/readme-cap.d/` is not raised. It lands in lane 3's README turn.

**Pinned copies.** `templates/family-root/scripts/bootstrap.py` and `templates/family-root/scripts/validate-family.py` change. Each holder sees them as `upstream-changed` on its next `update-shape.py check`. No test pins either file's bytes or digest; the tests that copy them into a holder take them from the template. Rounds 2 and 3 change `bootstrap.py` alone. By Brett's 13:36:23Z word (R0) the family root's pinned copy is re-pinned once, in v1.1.0's shape bump, so this PR re-pins nothing.

One commit, f74f640, on 23e1105, in the same two files.

### MM1. FIXED: a `.gitmodules` a merge left unmerged

In a plain clone, a merge conflicts in `.gitmodules`, and the file is resolved in the work tree but not added. The index then holds it only at its three conflict stages (`git ls-files -u` lists 3). Git reads no `.gitmodules` at all while it is unmerged: `repo_read_gitmodules` asks `is_gitmodules_unmerged` before it reads any file. So git's update dies at the first gitlink, `No url found for submodule path 'members/Atlas'`, exit 128. On 23e1105, bootstrap read the work tree's file, found every section with its url, and ended `family bootstrap ok`, exit 0 (rv648's `p10-head.log`).

`gitmodules_sections` now asks `git ls-files -u -z -- .gitmodules` first. Where the index holds the file at a conflict stage, it returns nothing, with `Gitmodules.unmerged` set. `unfetchable` asks that before every other reason, since no credential helps while git reads no section:

```
  NOT CHECKED OUT, and not for want of a credential: members/Atlas (git reads no .gitmodules while a merge leaves it unmerged), members/Nova (git reads no .gitmodules while a merge leaves it unmerged). Resolve the merge in .gitmodules and `git add .gitmodules`, then run this again.
```

With the file added, the same run checks both members out. That is all the cure needs; committing the merge is the person's own step.

**Test.** `test_a_gitmodules_a_merge_left_unmerged_says_to_add_it` builds MM1. The two sides each give OTHER's section another `branch`: `dev`, pushed from the holder, and `main`, committed in the clone. The merge exits 1, `ls-files -u` lists 3, and the work tree takes stage 2. The run exits 1, with the last two stderr lines exact and git's `No url found ... 'members/IRRS'`. Then `git add .gitmodules`, and the run is a good holder's. Against 23e1105's `bootstrap.py` the test fails on `exit 0`.

**Mutants.** Two more, both killed:

| mutant | result | tests that fail |
|---|---|---|
| U: the unmerged check dropped, so the work tree's file is read as before | 1 failed, 33 passed | MM1 |
| U2: the file read as unmerged, but the reason not given, so the rows fall to the next one | 1 failed, 33 passed | MM1 |

Round 2's eight were re-run on f74f640's two files. All are still killed, by the same tests.

**Results on f74f640.**

- rv648's lean set: `483 passed in 313.92s (0:05:13)`. That is 482 at 23e1105, plus MM1.
- The four files that load the family `bootstrap.py` (`tests/test_update_shape_kept_helper.py tests/test_windows_paths.py tests/test_pinned_copy_text.py tests/test_cli_reference.py`): `259 passed in 41.20s`.
- rv648's `p10.sh`: A1T and H2T as on 23e1105, each typed through, with the gate passing in all three modes. MM1 now exits 1 with the line above.

**A sentence of this body was false.** "Not changed" said "A fresh clone, where bootstrap runs, has the work tree's file and no merge". MM1 is a fresh clone with a merge. That bullet now says what is left.

**Candidate 6** (what git dies on while reading `.gitmodules`) is filed as a v1.1.1 issue (#768), with the approximations below.

One commit, 23e1105, on 45c512e. It changes `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py` only. README, the handbook and `validate-family.py` are as they were.

### M1. FIXED: `.gitmodules` is read as git's own parser reads it

`init_stop` read `.gitmodules` with `git config --file --list`. That keeps entries git's submodule parser drops or overrides. So in three states git's init died and bootstrap still said ok:

| state (plain clone) | git's update | 45c512e | 23e1105 |
|---|---|---|---|
| D1: a second section `nova2` gives `members/Nova` with no url, after Nova's own | 128, `No url found ... 'members/Nova'` | `family bootstrap ok`, exit 0 | exit 1, both rows named, `--no-members` |
| Q1: Nova's section is named `../Nova` | 128, `ignoring suspicious submodule name`, then `No url found` | ok, exit 0 | exit 1, both rows named, section written back |
| O1: Nova's url is `-nowhere` | 128, `ignoring ... may be interpreted as a command-line option`, then `No url found` | ok, exit 0 | exit 1, both rows named, section written back |

**The parser rules, as built.** `gitmodules_sections` now returns a `Gitmodules`. Its `read` (`{path: name}`) and `urls` (`{name: url}`) follow git 2.43's `submodule-config.c`, entry by entry, in file order:

- A section whose name is empty, or has a `..` component (split on `/` and on a backslash), is ignored whole. This is `check_submodule_name`.
- A `path` or `url` value that begins with `-` is dropped, and the value before it stands. This is `looks_like_command_line_option`.
- Where two sections give one path, the later in the file decides (`cache_put_path` replaces the entry). A section that gives a path again lets go of the path it gave before, even where a later section gave that path too, and then git reads no section there (`cache_remove_path`).

Its `given` counts every section that gives each path, as `validate-family.py` reads them. That count is what `member-gitmodules-absent` and `gitmodules-path-twice` see, so the line's cures stay true.

`has_url` asks the section git reads for a path, then the git config under that section's name. Where git reads no section for the path, there is no url, whatever the config holds: git's init dies on `!sub` before it looks at the config.

**Checked against git 2.43's own source** (`submodule-config.c`, `submodule.c`, `builtin/submodule--helper.c` and `config.c` at v2.43.0) **and measured** with git 2.43.0 in plain clones (`p8.log`):

| `.gitmodules` form | git 2.43's update |
|---|---|
| name `""`, `nova\..\x`, `a/../Nova` | ignored; `No url found`, 128 |
| name `..Nova..` (dots that are no `..` component) | read; exit 0 |
| `path = -members/Nova` | dropped; `No url found`, 128 |
| a good url, then `url = -x` | the `-x` dropped, the good url kept; exit 0 |
| a second section with Nova's url first, Nova's own with no url after | the later read; `No url found`, 128 |
| `nova1` gives the path, `nova2` gives it too, then `nova1` gives another path | `members/Nova` maps to nothing; `No url found`, 128 |
| `[submodule.Nova]` (the old header) | read, under the name `nova`; exit 0 |

**What else git does with `.gitmodules`.** It drops nothing else. But it DIES reading the file, exit 128 before it checks out any member, on these, which `git config --list` reads without complaint:

- a `path`, `url`, `update` or `ignore` key with no value (`path` alone): `bad config line N`;
- `update = !command`, or any `update` value it does not know: `invalid value for 'submodule.<name>.update'`;
- a `shallow` value that is no boolean: `bad boolean config value`;
- a `fetchRecurseSubmodules` value it does not know: `bad submodule.<name>.fetchrecursesubmodules argument`.

`init_stop` does not model these, and bootstrap still says ok over them where every row's section has a url (`ignore = bogus` is only a warning). No tool here writes any of them. This is a checklist candidate below, not part of this round.

**D1's cure.** Writing the section back would add a third section. So where more than one section gives the path git has no url for, the line names `validate-family.py --no-members`, whose `gitmodules-path-twice` names both sections and says to take the others out:

```
  NOT CHECKED OUT, and not for want of a credential: members/Atlas (git stopped at members/Nova, a gitlink it has no url for), members/Nova (git has no url for that path). `python3 scripts/validate-family.py --no-members` names each path more than one section of .gitmodules gives, and how to take the others out.
```

With the duplicate taken out and committed, the same run checks both members out.

**The stop's sentence** was "a gitlink no section of .gitmodules gives a url". That is false in D1, where Nova's own section does give one. It now says "a gitlink it has no url for", which is true in every state here. `test_doctor_legs_members_next.py`'s `git stopped at members/Ghost` still matches.

### S1. FIXED: A1, a member git has no url for, is its own reason

In a subset clone (`--recurse-submodules=members/Atlas`) where Nova's section has no url, git exits 0. On 45c512e the line printed the exit-0 skip's `git submodule update --init --checkout --recursive -- members/Nova`, which exits 128 (`No url found`) when typed.

"git has no url for that path" (`has_url`: the section git reads for it gives none, and the config has none under its name) is now its own reason. It is asked after "no section gives that path" and before "git exited 0", and its cure is the section written back. In A1:

```
  NOT CHECKED OUT, and not for want of a credential: members/Nova (git has no url for that path). Write the section of .gitmodules for members/Nova back as `family.py add` writes one, with its path and url, and commit .gitmodules.
```

Written back and committed, the next run is the exit-0 skip, and its printed update, typed, checks Nova out. In U1, Q1 and O1 the member's own row now gives this reason too, and the other row gives "git stopped at members/Nova". The cure is said once.

### NIT 1. FIXED: a stop outside members/ (H2)

A hand-mounted `vendor/Zed` stops git's init, and the line named `--no-members`, which reads only `members/` and says `family ok` there. Both cures were measured in H2's clone (`p9-head.log`):

- `git rm --cached -- vendor/Zed` and a commit: bootstrap then checks both members out and ends ok, and the gate passes in all three modes.
- A section with its path and url written for `vendor/Zed` and committed: bootstrap ends ok, but the gate refuses `gitmodules-path-unnamed` in all three modes.

So the line now says: ``No row names vendor/Zed and no clone can check it out: `git rm --cached -- vendor/Zed` takes its gitlink out, then commit that.`` "Outside members/" is the validator's own test: the first path component, casefolded, is not `members`.

### NIT 2. FIXED: the `cures` docstring

It now says that, members checked, the validator refuses `member-uninitialized` where every row's gitlink is there (a stray, a hand mount), and `member-gitlink-absent` with the move where a row's is only in another case (#363), which the validator sentence names.

### NIT 3. FIXED: this body

This section; the parser rules in Decision 2 and in (a)'s reasons; and a new merge table, against main a27c371, #745 and #747.

### Tests (round 2)

`tests/test_family_bootstrap_gate_order.py` has 33 tests now (23 before):

- **U1, Q1 and O1, one parametrized test** (`no-url`, `dotdot-name`, `empty-name`, `backslash-name`, `option-url`). It replaces the U1 test. Each exits 1 with the last two stderr lines exact, git's `No url found` and its warning shown. Each is written back as `family.py add` wrote it and committed, and the same run ends ok.
- **D1:** exits 1 with the `--no-members` sentence. `--no-members` names `gitmodules-path-twice` for both sections; the duplicate is taken out as that finding says and committed, and the run ends ok.
- **What git reads past** (`duplicate-first`, which is D2; `dots-in-a-name`; `option-url-after-a-url`): with no credential for the members, each is still the credential's skip and ends ok; with the credential, each is a good holder's run. The no-credential run is what makes a member be asked at all, so these tests catch a rule read too widely.
- **A1:** the write-it-back line, then the exit-0 skip's update line, typed as printed, then a good holder's run, stderr empty each time but for the two lines.
- **H2:** the `git rm --cached` line, typed and committed; the run ends ok and the gate passes in every mode. A premise checks that `--no-members` says `family ok` there.
- The `variant-plain`, stray, hand-mount and round-trip expectations take the new stop sentence.

On 45c512e the new file gives `12 failed, 21 passed`. Q1 (three names), O1 and D1 fail on `exit 0`; A1 and H2 fail on the line; U1, `variant-plain`, the stray, the hand mount and the round trip fail on the reworded sentences.

### Mutation probes (round 2)

Each was applied alone to a throwaway clone holding this commit's two files, and the test file was run against it. All eight are killed:

| mutant | result | tests that fail |
|---|---|---|
| L: the first section for a path decides, not the later | 2 failed, 31 passed | D1; `duplicate-first` |
| N: a section with a suspicious name is read | 3 failed, 30 passed | `dotdot-name`, `empty-name`, `backslash-name` |
| N2: any `..` in a name, component or not, ignores the section | 1 failed, 32 passed | `dots-in-a-name` |
| O: a value that begins with `-` is kept | 1 failed, 32 passed | `option-url` |
| D: no `--no-members` sentence where two sections give the path | 1 failed, 32 passed | D1 |
| A: "git has no url for that path" not asked | 7 failed, 26 passed | the five no-url cases, D1, A1 |
| A2: that reason asked after "git exited 0" | 1 failed, 32 passed | A1 |
| H: a stop outside members/ gets the `--no-members` sentence | 1 failed, 32 passed | H2 |

N2 was not killed by a first version of the read-past tests, which ran with the credential: git then checks both members out and bootstrap asks nothing. They now run without it first.

### Probes (round 2)

rv648's `p6.sh` and `p7.sh` and w648b's `p5.sh` were re-run on 23e1105, and this round's `p9.sh` was run on 45c512e and on 23e1105. All use local bare remotes and the `gh` stub, with git 2.43.0. "ok" is `family bootstrap ok`, exit 0.

| state | git's update | 45c512e | 23e1105 |
|---|---|---|---|
| D1 | 128 | ok | exit 1, the `--no-members` cure; the duplicate taken out and committed, then ok |
| D2 (the duplicate first) | 0, both checked out | ok | ok |
| D3: the duplicate with Nova's url first, Nova's own with no url after | 128 | ok | exit 1, the `--no-members` cure |
| Q1: names `../Nova`, `""`, `nova\..\x`, `a/../Nova` | 128 | ok | exit 1, section written back; then ok |
| Q2: name `..Nova..` | 0 | ok | ok |
| O1: url `-nowhere` | 128 | ok | exit 1, section written back; then ok |
| O2: a good url, then `url = -x` | 0 | ok | ok |
| O3: path `-members/Nova` | 128 | exit 1 | exit 1, the validator's cure, whose `member-gitmodules-absent` names it |
| U1 | 128 | exit 1 | exit 1; written back, then ok |
| A1 | 0 | exit 1, the update line, which exits 128 typed | exit 1, section written back; then the update line, exit 0 typed; then ok |
| H2 | 128 | exit 1, `--no-members`, which says `family ok` | exit 1, `git rm --cached -- vendor/Zed`; typed and committed, ok, and the gate passes in all three modes |
| G1, G3, S2 | 0 | ok | ok |
| G2, X1, X2, I1 (no credential), W1 (a wrong url) | non-zero | the skip, ok | the skip, ok |
| S1, H1, V1 | 128 | exit 1 | exit 1, the same rows and cures, with the stop's new sentence |
| P1, N1 | 0 | exit 1, the update line, exit 0 typed, then ok | the same |

Every line of `p5.sh` but S1's, H1's, U1's and V1's reworded sentences is the same on 45c512e and 23e1105, gate lines included.

Round 2 rewords two of round 1's sentences: the stop's why is now "a gitlink it has no url for", and in U1 the member's own row gives "git has no url for that path". The examples below are round 1's.

Three commits: bad6c00 merges main, 1a69505 answers the review, and 45c512e updates one doctor test that pinned the old `ok` (below).

### The merge with main (bad6c00)

One file conflicted with #634 (#610): `templates/family-root/scripts/validate-family.py`, in two regions. This PR splits `_check_member` in two, and #634 made THE LOCKSTEP RULE a per-member value, `rule = _lockstep(root, project, str(row.get("id") or project))`, passed to the gitlink and tree checks.

- `_check_member` passes `rule` to `_check_member_gitlink`, as #634 has it.
- `Gitlinked` gains a sixth field, `rule`, which `_check_member` returns.
- `_check_member_checkout` passes `asked.rule` to `_check_member_tree`.

So each member's gitlink and digest findings end at the rule #634 gives them, naming that member. The closing count's `_lockstep(root)` is #634's and merged clean. This is rv648's resolution in `rv648-x634`, plus one line of `Gitlinked`'s docstring. It was built against #634's file, which is the same blob on main (519b71f). Mutant X1 below checks it.

### M1. FIXED: bootstrap no longer says `ok` where git died at another gitlink

In a plain clone, `git submodule update --init` dies in its init step at the first gitlink, in index order, that no section of `.gitmodules` gives a url. It then checks out no member. `unfetchable` asked only each row's own gitlink and section, so every row passed as the credential skip.

**Measured on 8b39cd1** (`p5-base.log`, rv648's states with local bare remotes and the `gh` stub):

| state | git's update | 8b39cd1 | 1a69505 |
|---|---|---|---|
| S1: a stray `Members/Nova` beside `members/Nova`, plain clone | 128, `No url found ... 'Members/Nova'` | `family bootstrap ok`, exit 0 | exit 1, both rows named |
| H1: `members/Zed` mounted by hand, no row, no section, plain clone | 128, `No url found ... 'members/Zed'` | ok, exit 0 | exit 1, both rows named |
| U1: Nova's section with no url, plain clone | 128, `No url found ... 'members/Nova'` | ok, exit 0 | exit 1, both rows named |
| V1: #363's state, plain clone | 128, at `Members/Nova` | exit 1, Nova alone | exit 1, Atlas and Nova |

**rv648's prototype, measured before it was taken** (`p5-proto.log`). It fixed S1, H1 and U1, but failed three states that are a missing credential, where 8b39cd1 rightly says ok:

| state | 8b39cd1 | prototype | 1a69505 |
|---|---|---|---|
| X1: the stray, recursive clone, no credential | ok | FAILED, "git stopped at Members/Nova" | ok |
| X2: the hand mount, recursive clone, no credential | ok | FAILED, "git stopped at members/Zed" | ok |
| I1: Nova's url registered by `git submodule init`, then gone from the work tree's section, no credential | ok | FAILED, "git stopped at members/Nova" | ok |

The prototype also said "no section of .gitmodules gives that path" for U1's Nova, whose section gives the path and lacks only the url.

**What changed.** `init_stop` asks the holder's records which gitlink git's init stops at, keeping two rules of git 2.43's init:

- **`submodule.active`.** Where the git config sets it, as a recursive clone sets `.`, git asks only the gitlinks a section gives a path. A gitlink no section gives is never active, so it stops nothing there (X1, X2).
- **The config's url.** A section with no url stops git only where the config has no url under the section's name, as an earlier init writes one (I1).

`gitmodules_sections` replaces `gitmodules_paths` and reads each section's keys. Where git exited non-zero and init stops at a gitlink, each row not checked out is named `git stopped at <gitlink>, a gitlink no section of .gitmodules gives a url`, and the run fails. A row with its own reason (no gitlink, no section) keeps that reason.

**What the line says cures it.** Each reason now has its own sentence:

- **No gitlink, no section:** the sentence it had, `python3 scripts/validate-family.py` names the cause and the commit that cures it.
- **Git stopped at a gitlink no row names (S1, H1, V1):** `python3 scripts/validate-family.py --no-members` names each gitlink in members/ that no row names, and the commit that cures it.
  - The plain validator would not help here: members checked, it refuses `member-uninitialized` for the first row (rv648's candidate 3, still open).
  - S1 and H1 are typed through: the finding's lines, then a commit. The hand mount's `family.py add` line is not typed, since it mounts the member again. The same run then checks both members out.
- **Git stopped at a row's own gitlink (U1):** no mode of the validator names it. So the line says: Write the section of .gitmodules for members/Nova back as `family.py add` writes one, with its path and url, and commit .gitmodules. Written back and committed, the same run checks both members out.

S1 now prints, for example:

```
  NOT CHECKED OUT, and not for want of a credential: members/Atlas (git stopped at Members/Nova, a gitlink no section of .gitmodules gives a url), members/Nova (git stopped at Members/Nova, a gitlink no section of .gitmodules gives a url). `python3 scripts/validate-family.py --no-members` names each gitlink in members/ that no row names, and the commit that cures it.
family bootstrap FAILED: Atlas (not checked out), Nova (not checked out)
```

**Unchanged** (`p5-head.log`): G1 and G3 (good holders, recursive and plain), G2 (plain, no credential), S2 (the stray in a recursive clone) and W1 (a wrong url, which looks like the missing credential).

**Tests.** The `variant-plain` case and the round trip now name MEMBER too, with both sentences. One test each covers S1, H1 and U1, and two cover the misfires: X1 and X2 in one parametrized test, and I1.

### S1. FIXED: an exit-0 skip prints the update that checks the member out

For "git exited 0 and checked nothing out there", the line now prints the update that checks the member out, in place of the `validate-family.py` sentence:

```
  NOT CHECKED OUT, and not for want of a credential: members/Nova (git exited 0 and checked nothing out there). `git submodule update --init --checkout --recursive -- members/Nova` checks it out.
```

- **`--init` with the path** registers a member a subset clone left out. A pathspec lifts git's `submodule.active` filter, and init marks the member active.
- **`--checkout`** overrides an `update = none`.

Measured in P1 (a `--recurse-submodules=members/Atlas` clone) and N1 (`update = none` committed for Nova), on 8b39cd1 and on the head:

- **8b39cd19:** the validator sentence, whose `member-uninitialized` remedy exits 0 and leaves Nova empty.
- **1a69505d:** the printed line, typed as printed, exits 0 and checks Nova out (15 entries). Bootstrap then ends `every member and leg fetched`, ok, and the gate passes in every mode.

`test_an_update_that_exits_0_and_skips_a_member_fails` is parametrized `update-none` and `subset`. It checks that the validator's remedy checks nothing out, types the printed line, and runs bootstrap again: exit 0, stdout exact, stderr empty.

The behaviour change (a subset clone and an `update = none` member now fail `make bootstrap`) belongs in v1.1.0's release notes, lane 3's #630 (candidate 4).

### S2. FIXED: the credential test asserts no "every member fetched"

`test_a_missing_credential_is_still_the_skip_it_was` asserts `"  every member and leg fetched" not in result.stdout.splitlines()`. rv648's mutant R2 (`elif proc.returncode == 0:` made `else:`) now fails it (mutants below).

### NITs

1. **FIXED.** README: ``member's. `make bootstrap` reports and skips one it lacks a credential for.`` (75 characters, the line count unchanged). Handbook: `A member <code>make bootstrap</code> lacks a credential for is reported and skipped, not a failure.` § Families marker: c40baa82 → b3d19092, computed with `test_repo_hygiene.py`'s own `readme_segments` and `readme_fingerprint`.
2. **FIXED.** `gitmodules_paths` is now `gitmodules_sections`, whose docstring says "An empty mapping where there is no such file".
3. **FIXED.** Bootstrap's (a) docstring says "128 (`No url found for submodule path`) in a plain clone; a missing credential exits non-zero too". It also names the new reason and each cure.
4. **FIXED.** The validator's WHAT IS CHECKED says the move under `--no-members` is printed "where its row's path is its canonical `members/<Project>` and its pin a 40-hex commit".
5. **FIXED.** The merge table below replaces the stale one.

### (a) `bootstrap.py` no longer ends `ok` over members it could not check out

After the fetch, bootstrap asks the holder's own records, never git's words, about each row whose member is not checked out. The first reason that holds is the row's:

1. A merge left `.gitmodules` unmerged in the index, so git reads none of it (`Gitmodules.unmerged`; MM1). This is asked first: it holds for every row, and no credential helps.
2. No gitlink is recorded at the row's path (`recorded_gitlink`, the reading `member-gitlink-absent` uses), as in #363's state.
3. No section of `.gitmodules` gives that path, as where HEAD lost the file. Every section counts here, as `validate-family.py` reads them.
4. Git has no url for that path: the section git reads for it gives none, and the git config has none under that section's name (`has_url`; U1, A1, D1, Q1, O1). No credential gives a url, whatever git exited.
5. Git exited 0, so it lacked nothing a credential gives.
6. Git exited non-zero, and its init stopped at a gitlink it has no url for (`init_stop`, M1 of both rounds).

Reasons 4 and 6 read `.gitmodules` as git 2.43's own parser reads it, not as `git config --list` lists it (`gitmodules_sections`): a section whose name is empty or has a `..` component, split on `/` and on a backslash, is ignored; a `path` or `url` that begins with `-` is dropped, and the value before it stands; and where two sections give one path, the later in the file decides.

Each such row is named on one line, with what cures each reason, and the run fails. "every member and leg fetched" is printed only where git exited 0 and every row is checked out. A row git could not fetch for another reason is still `NOT CHECKED OUT; skipped`, and the run still ends ok: that is the state a missing credential leaves. `test_family.py`'s `test_bootstrap_degrades_when_a_member_cannot_be_fetched` and bootstrap's docstring (c) say so.

### (b) The gate asks every row's gitlink before any row's checkout

`_check_member` asked each row whole, gitlink then checkout, before it asked the next. In a plain clone of #363's state plus `git submodule update --init --recursive` (CI's and `make bootstrap`'s flow), the update dies at `Members/<P>` before it checks out any member. So where the variant's row was not first, the gate refused `member-uninitialized` for the first row, naming that same update.

Now `_check_member` stops at the gitlink and returns the member (`Gitlinked`). `_check_member_checkout` asks each member's digest and identity once every row's gitlink has been asked. The refusal there is now #611's `member-gitlink-absent` with the move, whichever row it is, members checked and `--pins` alike.

### (c) `--no-members` prints the move members mode prints

Under `--no-members`, the mode CI falls back to in (b)'s state, `member-gitlink-unnamed` for a member's only gitlink in the wrong case printed no command. Where the row's path is its canonical `members/<P>` and its pin is a 40-hex commit, it now prints the same text and two lines the row's members-mode refusal prints. One helper, `_wrong_case_move`, builds both. The commit is the row's pin, not the variant's gitlink, and every variant goes on one line.

1. **The holder's records decide, not git's exit or words.** Git exits 0 and 128 over the same fault (HEAD lost `.gitmodules`: 0 after a recursive clone, 128 in a plain one), and a missing credential exits non-zero too. `No url found` is git's own message, and git translates it.
2. **`init_stop` keeps git's rules, and no more.** rv648's prototype failed X1, X2 and I1, so the `submodule.active` filter and the config's url are kept (round 1's M1). Round 2 adds the three rules by which git's own parser reads `.gitmodules`, each checked against git 2.43's source and measured: the suspicious name, the value that begins with `-`, and the later section for a path (round 2's M1). It does not match `submodule.active`'s pathspecs: under a subset clone's value, a url-less section of a member outside the subset counts as a stop. That is reached only where git also fails for another reason (Not changed, below).
3. **The cure is per reason.** The validator's members mode refuses `member-uninitialized` in S1, H1 and U1, and nothing names U1, so one sentence cannot be true of every reason. Two sections for one path get `--no-members`, since a section written back would be a third (D1), and a gitlink outside members/ gets `git rm --cached`, the one cure measured to leave the gate passing (H2). Each sentence was typed through in its state (Tests below).
4. **The README sentence.** README is at its 1459-line cap, so the sentence fits its line. It cannot also name the new failures; bootstrap's docstring does.

`tests/test_family_bootstrap_gate_order.py`, new, 34 tests. Round 1's 23 are listed here, and rounds 2's ten and 3's one in their own sections above:

- **(a), four states:** HEAD lost `.gitmodules` and #363's state, each after a recursive clone and after a plain one. Each exits 1, stdout and the last two stderr lines are exact, and git's `No url found` shows in the plain ones.
- **The good and the skipped:** a good holder's run, exact; the credential skip still ends ok, with no new line and no "every member fetched".
- **Git exits 0 and skips a member:** `update = none` and a subset clone. The run fails on that member alone, and the printed update, typed, lets the run end ok.
- **Git stops at another gitlink:** S1 and H1 (typed through `--no-members` and committed), U1 (the section written back), and the misfires X1 and X2 and I1, which are still the skip.
- **A row written twice** is named once.
- **The round trip:** a plain clone of #363's state with the variant at the SECOND row. Bootstrap fails, the gate refuses with the move, the lines are typed, and bootstrap then checks both members out, runs each one's own bootstrap and ends ok. The gate passes in every mode.
- **(b):** a plain clone plus `git submodule update --init --recursive` (exit 128), with the variant in the first row and in the second.
- **(c):** on a Linux disk and an emulated folding one, one variant and two, at a commit that is not the pin. A row whose pin is no commit still prints no command.

Changed: `tests/test_validate_family_unnamed_mounts.py`, state (ii) under `--no-members`. It printed no command; it prints the move now.

Results on 1a69505, Linux, `/usr/bin/python3 -m pytest` through `run9.sh` (`gh` stub first on PATH). 45c512e changes one test file, which was run again on it:

- **Batch A**, the brief's named files: `tests/test_family_bootstrap_gate_order.py tests/test_validate_family_unnamed_mounts.py tests/test_family.py tests/test_pinned_copy_text.py tests/test_validate_family_branch_step.py tests/test_family_members_case_variants.py tests/test_family_head_lost_gitmodules.py tests/test_family_bump_to_local_only.py tests/test_repo_hygiene.py tests/test_readme_sections.py tests/test_template_text_placeholders.py tests/test_cli_reference.py`: `698 passed in 638.20s (0:10:38)`.
- **Batch B**, every other test file that names `validate-family` or the family's `bootstrap.py` (49 files, `git grep -l`): `1 failed, 3797 passed, 32 skipped in 2483.22s (0:41:23)`.
  - The failure was `tests/test_doctor_legs_members_next.py::test_414_3_there_the_bootstrap_cures_nothing_and_the_git_line_cures_one`. It builds rv648's U1 state (a member, Ghost, whose section gives no url) and asserted bootstrap's exit 0: the false `ok` M1 ends.
  - 45c512e asserts exit 1 and `git stopped at members/Ghost`, and its point stands: the bootstrap cures nothing there, and the doctor's git line cures the other member. That file alone on 45c512e: `124 passed in 153.99s (0:02:33)`.
  - The skips are the Windows-only lines, as on main.
- **Round 2, on 23e1105.** rv648's lean set, `tests/test_family_bootstrap_gate_order.py tests/test_validate_family_unnamed_mounts.py tests/test_family_head_lost_gitmodules.py tests/test_validate_family_branch_step.py tests/test_family_members_case_variants.py tests/test_doctor_legs_members_next.py tests/test_family_bump_to_local_only.py tests/test_repo_hygiene.py tests/test_readme_sections.py` and `test_family.py`'s three bootstrap tests: `482 passed in 263.30s (0:04:23)`. That is 472 at 45c512e plus round 2's ten. The files that load the family `bootstrap.py` by path or check its imports and text, `tests/test_update_shape_kept_helper.py tests/test_windows_paths.py tests/test_pinned_copy_text.py tests/test_cli_reference.py`: `259 passed in 30.69s`. Batch B was not re-run in round 2.
- **Not run:** the 22 files that name only the assembly root's `bootstrap.py` (adopt, scaffold, `bump-leg.py` and setup tests), which this PR does not change. Main's six commits after 55c6ba7 were not run against this head either; `merge-tree` with them is clean.

Round 2's eight are in its own section above. Each of round 1's mutants was applied alone to a throwaway shared clone at 1a69505 (exact text replacements), and its test file was run against it. Each one is killed:

| mutant | result | tests that fail |
|---|---|---|
| M1a the "git stopped at" reason never given | 5 failed, 18 passed | variant-plain, S1, H1, U1, the round trip |
| M1b `submodule.active` not asked | 2 failed, 21 passed | the recursive clones with no credential, stray and hand mount |
| M1c the config's url not asked | 1 failed, 22 passed | the url an earlier init registered |
| M1d a url-less section counts as having one | 1 failed, 22 passed | U1 |
| M1e no `--no-members` sentence | 4 failed, 19 passed | variant-plain, S1, H1, the round trip |
| M1f a row's own stop given the `--no-members` sentence | 1 failed, 22 passed | U1 |
| S1a the validator sentence for an exit-0 skip | 2 failed, 21 passed | update-none, subset |
| S1b no update line | 2 failed, 21 passed | update-none, subset |
| S1c the update line without `--checkout` | 2 failed, 21 passed | update-none, subset |
| S1d the update line without `--init` | 2 failed, 21 passed | update-none, subset |
| R2 (rv648's) "every member and leg fetched" over a failed fetch | 1 failed, 22 passed | the credential skip |
| X1 the digest finding's rule not the member's (the merge) | 1 failed, 22 passed | `test_validate_family_branch_step.py`'s `test_a_digest_s_rule_names_its_own_member` |
| N1 the § Families marker left at c40baa82 | 1 failed, 76 passed | `test_repo_hygiene.py`'s `test_the_handbook_follows_the_readmes_outline` |

S1c and S1d were also run with the test's expected line edited to match, so that only typing the printed line can catch them. Each is still killed, by the state that needs the flag: without `--checkout`, update-none fails (`1 failed, 1 passed`); without `--init`, subset fails (`1 failed, 1 passed`).

| Merged with | Head | Result |
|---|---|---|
| origin/main | 2a33a56 | clean |
| lane 5's fix/704-bootstrap-leg-moves (#745: #704, #694), restacked on 23e1105 | f4fa602 | clean |
| lane 5's fix/681-validate-family-remedies (#747: #681), restacked on 23e1105 | 03e0b5a | clean |
| lane 4's fix/690-siblings-inputs | 38a40ab | clean |
| lane 5's fix/695-family-docs (#738) | c42286d | CONFLICT in `docs/handbook.html`, the § Families marker line only, as before. Rounds 2 and 3 change neither README nor the handbook. |
| every other `fix/*` and `l3-*` branch on origin, 26 more | | clean |

README is 1459 lines at f74f640 and in its merge with main.

- **The validator's members mode in S1, H1 and U1.** It still refuses `member-uninitialized` for the first row, with a remedy that dies the same way, and no mode names U1. Bootstrap's line now names the cause in all three and the cure, so the person is no longer sent there. Candidate 3.
- **What git dies on while reading `.gitmodules`.** Git 2.43 exits 128 before it checks out any member over a `path`, `url`, `update` or `ignore` key with no value, an `update` of `!command` or a value it does not know, a `shallow` that is no boolean, and a `fetchRecurseSubmodules` value it does not know (`p8.log`). `init_stop` does not model these, so where every row's section has a url, bootstrap still ends ok over them. Filed as a v1.1.1 issue (#768), which fixes the family validator in the same change.
- **Where git reads `.gitmodules` from.** Git reads the work tree's file; where the work tree has none, the index's, then HEAD's; and none at all while a merge leaves it unmerged. Since round 3 the unmerged file is its own reason (MM1). The fallback to the index's or HEAD's file is still not followed: `gitmodules_sections` reads the work tree's alone. That is reached only where the work tree lacks the file while the index or HEAD holds it, as a deletion not yet committed or a sparse checkout that leaves it out leaves it. There every row git left not checked out is said to have no section, and the run fails, even where a credential was all git lacked. In the v1.1.1 issue.
- **`submodule.<name>.active`.** Where `submodule.active` is set, git asks a member's own `active` key before the pathspec. `init_stop` asks neither, as round 1 did. In the v1.1.1 issue.
- **`submodule.active` pathspecs.** `init_stop` takes the key's presence, not its patterns. Under a subset clone's `members/Atlas`, a url-less section of a member outside the subset counts as a stop, though git's init skips it. That is reached only where git's exit is non-zero for another reason, and then the line names that section, which is a real defect. In the v1.1.1 issue.
- **macOS and Windows were not run** (CI is Linux only since #597).
  - The variant states are index records; the holder's own are asked again with `core.ignorecase=true`.
  - A clone of S1 gets `Members/Nova` and `members/Nova` as two empty mounts. On a disk that folds case, git checks them out as one directory and warns. No assertion reads either directory, and git's init dies at the stray on every disk.
  - Every file is written as bytes, and `url_edited` reads a CRLF checkout too.
  - The backslash-name case (`IRSS\..\x`): git splits names on both `/` and a backslash on every system (`is_xplatform_dir_sep`), so it is ignored on Windows too.

1. **A half-typed move** (rv648's 1). After typing the move and before committing, both modes print STRAY's `git rm --cached -- Members/Nova`, which fails `pathspec ... did not match` (exit 128). It is a v1.0.0 known limit. #681's branch says to commit a staged removal and prints no `git rm` there.
2. **The state a `git mv members/Nova Members/Nova` leaves** (rv648's 2). The move fixes only the gitlink, and two more rounds follow.
3. **Members mode in S1, H1 and U1** (rv648's 3). It refuses `member-uninitialized` for the first row, with a remedy that dies the same way. No mode names a section with no url.
4. **v1.1.0's release notes for #648**, in lane 3's #630. A subset clone, an `update = none` member, and S1, H1 and U1 now fail `make bootstrap`. The line prints the cure for each.
5. **`member-gitlink-unnamed` says "a recursive clone of this holder fetches it"** for a gitlink no section of `.gitmodules` gives. A recursive clone fetches nothing there, as S2 and X1 show for the stray.
6. **Bootstrap still says ok where git dies reading `.gitmodules`.** Filed as a v1.1.1 issue (#768).

Review: rv648, lane 5's independent review, found 1 MUST, 2 SHOULD and 5 NIT on 8b39cd1, then 1 MUST, 1 SHOULD and 3 NIT on 45c512e, then 1 MUST on 23e1105; each fix round above answers one, and its verdict at f74f640 is DONE. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 f74f640 passed tests, tests-git-bash and SonarCloud; the head 5afb24b is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 9399806 over main 1eeef3b: the doctor_legs_members_next, family_bootstrap_gate_order, validate_family_unnamed_mounts, readme_sections, readme_windows_quoting_sentence, repo_hygiene, agents_md_codes, cli_reference sets, 426 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #648

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 17, 2026
…d A3 (three SHOULDs, four nits), and notes entries for the seven pull requests landed since bf1f099, current to main 2000488 (#563, #683)

Round A4, from lane 2's review (#630 issuecomment-6233033833, READY):
- SHOULD 1: the doctor's exit-3 list names "a validator of its own that
  crashed or could not be run", so the case of one that cannot be given
  its `PYTHONPATH` (shape-doctor.py's EXIT CODES, #278) is listed too:
  README.md:1208-1222, its handbook twin (`doctor`) and the notes' docs
  line. The paragraph re-wraps to one more line:
  tests/readme-cap.d/683.txt is +2, and its text now says which causes
  the EXIT CODES block names and that #742 added the partial clone and
  #701 the naming policy. README.md is 1475 lines, its cap (1422 + 2 + 5
  + 12 + 22 + 10 + 2). Both `Keeping a project's shape current` markers
  move to c728c970: `doctor` text and marker, `current` marker only.
- SHOULD 2: #727's entry names the leg description, whose em dash stopped
  every `execute` against GitHub at the first leg.
- SHOULD 3: #749's entry names `source-ambiguous`, for an `org/repo` that
  is also a directory here.
- Nits: the doctor section's three near-duplicate pairs are merged (#636
  with #737, #632 with #700 and #705, #586 with #736); #718 has its own
  bullet; #634's entry names `git_out`'s spelling for every caller and
  #744's the gate's `--link-source-label` and `--link-remedy`; the header
  comment says 2026-10-17 at main 2000488.

Wave B1, `git log --first-parent bf1f099..2000488`: #706 (#664), #703
(#696), #670 (#646), #682 (#648), #726 (#667), #730 (#666) and #724
(#665), each from its squash commit; #726's and #682's from lane 5's lines
(#630 issuecomment-6227129879), #682's with the unmerged `.gitmodules`
its last round added, and #724's with lane 5's note that v1.0.0's
test-hygiene line on the generator's `.pyc` files closes. The counts (106
pull requests; 116 of 135 milestone issues closed at 11:53Z), "current to
main" and the open list (19) are at main 2000488, still placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 17, 2026
… in command scope and in a global config that includes the person's own, so a `copytree` of a fixture no longer meets a detached repack deleting its loose objects (#645) (#713)

**What changed.** One session fixture in `tests/conftest.py`, `no_auto_maintenance` (autouse), and one new test file, `tests/test_no_auto_maintenance.py`. No shipped file and no other test file changes. Four commits: the fix and its tests (e2110f8), a comment (accc7d2), and the fix round below (8cfc8eb, and 979bb39, which only shares a test helper).

The fixture sets `gc.auto=0` and `maintenance.auto=false` for every git the session starts, in two scopes:

- **Command scope** (`GIT_CONFIG_COUNT`), appended after the entries the environment already holds (the Windows job holds two). Every git the suite starts, every git a tool under test starts, and every git a `git submodule` starts inherits it. It outranks every config file.
- **A global config file** (`GIT_CONFIG_GLOBAL`, in the basetemp). It includes the global config git would read without it (the file `GIT_CONFIG_GLOBAL` already named, or `$XDG_CONFIG_HOME/git/config` and then `~/.gitconfig`) and sets the two keys after it. This one is for the `receive-pack` a push starts in a local remote: git starts it with `GIT_CONFIG_COUNT` and `GIT_CONFIG_PARAMETERS` removed, so command scope never reaches it. Where `HOME` is unset the `~/` paths are left out, and an empty `GIT_CONFIG_GLOBAL` includes nothing (fix round, below).

A comment beside #440's note in `clone_just_pushed` says why, names both sites, and says what is left.

**Why.** `shutil.copytree` reads a repository's `objects/` one file at a time. The suite copies a repository a fixture has just committed in, fetched into or pushed to in 139 places. Each `git commit`, `merge` and `fetch`, and the `receive-pack` in a remote after a push, runs `git maintenance run --auto` as it finishes. From git's source:

- git 2.46: the auto run stays in the foreground.
- git 2.47: it is started with `--detach`, takes `objects/maintenance.lock` and then detaches, so the command returns while it runs.
- git 2.52 and 2.53: the default strategy is still `gc`, which does nothing below 6700 loose objects.
- git 2.54: the default strategy is `geometric`. Its repack runs when packs are out of geometric order, or once its estimate, taken from `objects/17` alone, is above 256 loose objects, so two loose objects there are enough (`maintenance.geometric-repack.auto`, 100, rounded up). It deletes the loose objects it packed.

So on a current git, a copy taken just after a commit can find a loose object gone. It happened twice on the Linux runner:

- `holder` (`tests/test_family.py:129`), in `test_family_member_name.py` (lane 1's report on #473);
- `the_standard_moved` (`tests/test_doctor_pin_restore_merges.py:99`), a copy of the session's `standard` with its `.git`, in `test_copies_neither_side_edited_are_taken_whole_with_the_pin` on #623's run 38895011713: `shutil.Error ... standard5/openRepoShape/.git/objects/1e ... [Errno 2]` (lane 2's #473 comment 6215422231). The same function copies `moved` at :102 just after a commit in it.

Both are covered by the same session setting: the commits before each copy run under `os.environ` (`commit_all` passes `{**os.environ, ...}`).

A listing of `.git/objects` taken before and after such a repack also differs: lane 3's `objects changed` in `tests/test_family_bump_remove_rollback.py` (#736's Linux run 38981191418), 37 loose objects packed into two. The same setting covers it (i645 on #645).

**Decisions and evidence.**

- *Option A (the suite's environment), not B or C.*
  - B (config in each copied repository) would have to reach repositories the tools under test create themselves, such as the bare remotes `scaffold-project.py --local-remote-dir` makes and pushes into before a test sees them. A config written after that first push is too late.
  - C (a copy that survives a repack) cannot be made safe. A copy can list `objects/pack/` before the new pack lands, and then a loose-object directory after it was emptied. That copy succeeds and is missing objects.
  - A covers all 139 places from one place.
- *Two scopes, not one.* Probe on git 2.43, pushing into a local bare remote with `receive.denyDeletes=true` given each way:
  - with `GIT_CONFIG_COUNT`, the branch was deleted;
  - with `-c`, deleted;
  - through a `file://` URL with `GIT_CONFIG_COUNT`, deleted;
  - with `GIT_CONFIG_GLOBAL`, `remote: error: denying ref deletion` (refused).

  So command scope alone misses the remotes, and ten copies are of bare remotes. A global file alone would be dropped wherever a test gives git a global file of its own (14 test files set `GIT_CONFIG_GLOBAL`, five more through `excluding()`). No test file replaces both scopes and also copies a repository. rv645, the reviewer, repeated the `receive.denyDeletes` probe and got the same answers, and instrumented the git calls of the batches it finished before the checkpoint: the only receive-packs that ran without both settings were in `test_family_add_excludes.py` and `test_adopt_clone_template.py`, neither of which copies a remote.
- *Which verbs start auto maintenance* (git 2.43 trace2): `commit`, `fetch`, `pull` and `submodule update --remote` (its fetch in the submodule) start `git maintenance run --auto`. A push's `receive-pack` starts `git gc --auto --quiet`. `clone`, `submodule add` and `submodule update --init` start none.
- *Behaviour no test checks is unchanged.* The person's own global config is still read: it is included, and it comes first. A test of that is part of the new file. The tests that read or set a global config (`test_git_helper_identity.py`, `test_adopt_git_reads.py`, `test_tracking_branch_ref.py` and others) set their own, which replaces the session's.

**Fix round (8cfc8eb), on rv645's review** (no MUST; two SHOULD, five NIT).

- **SHOULD 1: with `HOME` unset every git was fatal.** The file's include of `~/.config/git/config` and `~/.gitconfig` made git exit 128, `error: could not expand include path '~/.config/git/config'`, in every command the session started. The estate runs with `HOME` set, so nothing failed here; an `env -i` or hermetic runner would have lost the whole suite.
  - `session_global_config` now writes a `~/` path only where `home_is_known()` (`HOME` is set, or `os.name == "nt"`, where Git for Windows finds a home itself), and writes no `[include]` header when nothing is included. An `XDG_CONFIG_HOME` that is set is still included, because it needs no `HOME`.
  - The two `test_a_persons_own_global_config...` cases skip where `HOME` was unset. They skip on the environment and not on the file's bytes, so a fixture that dropped its includes still fails them (probe M5 below).
  - A new test, `test_where_home_is_unset_the_file_is_one_git_can_read` (with and without `XDG_CONFIG_HOME`), reads the file back through `git config --global --includes`. It is skipped on Windows. It and the empty-value test share one helper, `probe_read_from_the_session_file`, so the new tests do not repeat a body for SonarCloud's duplication gate.
  - Measured under `env -u HOME -u XDG_CONFIG_HOME`, `run9.sh`, git 2.43.0:

    | file | before (accc7d2) | after (8cfc8eb) |
    |---|---|---|
    | `tests/test_clone_just_pushed.py` | 1 passed, 6 errors | 7 passed |
    | `tests/test_no_auto_maintenance.py` | 3 failed, 2 passed, 2 errors (7 tests) | 9 passed, 2 skipped (11 tests) |
    | `tests/test_family_member_name.py` | not run | 13 passed |

    The 2 skips are the two `test_a_persons_own_global_config...` cases.
  - Where `HOME` is set and the file is written as before, nothing changes: across 27 combinations of `HOME` (set, unset, empty), `XDG_CONFIG_HOME` (set, unset, empty) and `GIT_CONFIG_GLOBAL` (unset, a file, empty), the file's bytes are the same as accc7d2's in the 12 where `GIT_CONFIG_GLOBAL` names a file or `HOME` is set with `GIT_CONFIG_GLOBAL` unset, and differ in the 15 where `HOME` is not set or `GIT_CONFIG_GLOBAL` is empty.
- **SHOULD 2: nothing guarded the fixture's scope.** At function scope all seven tests passed (rv645's probe), and the session fixtures `scaffolded` and `family` would have been built before the setting existed. A new test, `test_the_settings_are_in_place_before_a_session_fixture_builds_a_repo`, takes a session fixture as its FIRST argument, which reads `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_COUNT` as that fixture sees them, and compares them with what the test sees and with the fixture's file. Pytest builds the higher scope first, and then an autouse fixture, so the fixture is only in place early enough if it is session scope and autouse.
- **NIT 1: the receive-pack test failed under a person's global `core.hooksPath`**, which hid the remote's own `post-receive` hook (`FileNotFoundError` on `read-by-receive-pack`). The test now sets `core.hooksPath` to the remote's own `hooks` directory in the remote's local config, which outranks a global one. Measured with `GIT_CONFIG_GLOBAL` naming a file that holds `core.hooksPath`: before, `1 failed, 4 passed, 2 skipped`; after, `9 passed, 2 skipped` (11 tests).
- **NIT 2: an empty `GIT_CONFIG_GLOBAL=` was read as unset.** Git 2.43 reads it as "no global config" (checked here: with `HOME` holding a `.gitconfig` probe, `GIT_CONFIG_GLOBAL=` does not read it). The fixture now includes nothing for an empty value: `if named is not None: included = [...] if named else []`. A new test, `test_an_empty_GIT_CONFIG_GLOBAL_is_no_global_config_and_includes_nothing`, fails on the old `if named:`.
- **NITs 3 to 5: no code change.**
  - NIT 3: `test_family_dry_run.py`'s own `NO_MAINTENANCE` is redundant now. It lives in another test file that this PR leaves alone, and it is harmless.
  - NIT 4: a `git config --global` read without `--includes` sees only the session's two keys. The only reader is `setup-project.py:1105`, behind `os.name == "nt"`, and no test reads it. `_credential_helper_configured` uses `--get-all` with no scope, so it still sees the person's config. The conftest comment already says so.
  - NIT 5: the version facts in the conftest comment ("2.47 detaches", "2.54 default geometric") are from git's source tags and are not run here: only git 2.43.0 is installed. The comment is unchanged.

**Tests** (`run9.sh`: gh stub first on PATH, identity blanked; git 2.43.0, Python 3.12, pytest 9.1.1).

- On the fix round (8cfc8eb; 979bb39 differs from it in the new test file alone, and the batches that read that file were run on it):
  - `tests/test_no_auto_maintenance.py`: 11 passed (7 from the first commit, 4 new: the `HOME` test twice, the empty value, the scope).
  - `tests/test_no_auto_maintenance.py`, `tests/test_clone_just_pushed.py`, `tests/test_repo_hygiene.py` and `tests/test_git_helper_identity.py` together, on 979bb39: 105 passed. With `tests/test_family_dry_run.py` added, on 8cfc8eb: 166 passed.
  - `tests/test_adopt_git_reads.py`, `tests/test_tracking_branch_ref.py`, `tests/test_adopt_late_failure.py`, `tests/test_adopt_github_exits.py` and `tests/test_family_add_excludes.py` (the tests that read or set a global config or a hooks path): 314 passed.
- On the first two commits (e2110f8 for the first four, accc7d2 for the rest; the second commit changes comment lines only). The fix round changes nothing where `HOME` is set and `GIT_CONFIG_GLOBAL` is not empty (the byte comparison above), so these were not run again:
  - Family, `tests/test_family*.py` (20 files): 823 passed, 19 skipped.
  - Adopt, `tests/test_adopt*.py` (46 files): 3463 passed, 2 skipped.
  - Doctor, `tests/test_shape_doctor.py` and `tests/test_doctor*.py` (12 files): 741 passed, 3 skipped.
  - Scaffold, `tests/test_scaffold*.py`: 634 passed.
  - Update-shape and bump-leg, `tests/test_update_shape*.py` and `tests/test_bump_leg*.py` (24 files): 857 passed, 4 skipped.
  - The rest of the files that copy a repository (`test_bootstrap_after_bump.py`, `test_non_list_project_fields.py`, `test_pin_source_env_collisions.py`, `test_pin_source_unreadable.py`, `test_shape_pin_files_not_a_list.py`, `test_validator_child_encoding.py`, `test_siblings_family_name.py`, `test_bootstrap_spelling.py`, `test_non_utf8_git_reads.py`), with `test_repo_hygiene.py`, `test_git_helper_identity.py`, `test_clone_just_pushed.py` and the new file: 924 passed.
  - On a merge of accc7d2 with main c6122e8: `test_no_auto_maintenance.py`, `test_git_helper_identity.py`, `test_repo_hygiene.py`, `test_clone_just_pushed.py` and `test_family_member_name.py`, 114 passed.

**Reproductions.**

- *The mechanism, on git 2.43.* A repository of 300 files plus two loose objects named `17…`, committed under `gc.auto=1`, then copied at once with `shutil.copytree`. This is what git 2.54 does to such a repository by default. With the base's settings, 10 of 10 copies failed, then 9 of 10 (`[Errno 2]` on a loose object directory or file). With the fix's two entries appended after it, 0 of 10 failed.
- *The emulation of the race* (rv645's recipe, run on 8cfc8eb: a global `gc.auto=1` that includes the person's `~/.gitconfig`, plus a `core.hooksPath` pre-commit hook that leaves two `17…` objects and drops `gc.log`, so git 2.43 repacks in the background after each commit; `GIT_TRACE2_EVENT` over the whole run). Base figures are rv645's, on a524542; head figures are the fix round's own, on 8cfc8eb:

  | file | git | auto-maintenance children | repack runs | result |
  |---|---|---|---|---|
  | `test_doctor_pin_restore_merges.py` | base | 78 `maintenance run --auto`, 77 `gc --auto` | 65 | 1 failed, 14 passed, 1 error |
  | | head | 4 `gc --auto` (from `receive-pack`, inert under `gc.auto=0`) | 0 | 16 passed |
  | `test_family_member_name.py` | base | 19 `maintenance run --auto`, 29 `gc --auto` | 15 | 13 passed |
  | | head | 14 `gc --auto` (inert) | 0 | 13 passed |

  On the base, rv645 reproduced the symptom itself: `shutil.Error ... moved/openRepoShape/.git/objects/pack/tmp_rev_...`, at `the_standard_moved`'s copy of `moved` (`tests/test_doctor_pin_restore_merges.py:102`). On head, 0 maintenance runs and 0 repacks in both files.
- *The first writer's runs on the issue's file* (`test_family_member_name.py`, base then head, 19 maintenance and 15 repacks against 0 and 0) agree.

**Mutation probes.** Each reverts one hunk of the fix in a throwaway clone; the new file then fails. First round (against the first seven tests):

| probe | fails |
|---|---|
| fixture sets nothing | 6 of 7 (all but the append test) |
| no command scope | `test_a_commit_a_fetch_and_a_merge_start_no_maintenance[a-tests-own-global]` |
| no global file | the receive-pack test, both person's-config cases |
| count overwritten, not appended | the append test |
| global file without the includes | both person's-config cases |
| the two keys before the includes | both person's-config cases |
| no `maintenance.auto` | 5 tests |
| no `gc.auto` | the receive-pack test, the append test |

Fix round (against the eleven tests):

| probe | result | what fails |
|---|---|---|
| S1, `no_auto_maintenance` at function scope | 1 failed, 10 passed | the scope test, alone (the first seven passed in rv645's run of the same mutant) |
| S2, not autouse | 4 failed, 7 passed | the two commit/fetch/merge cases, the tool test, the receive-pack test |
| H1, `home_is_known()` always true | 2 failed | `test_where_home_is_unset_the_file_is_one_git_can_read`, both variants |
| H2, the XDG include needs `HOME` | 1 failed | the same test, the `xdg` variant |
| E1, an empty value is read as unset (`if named:`) | 1 failed | the empty-value test |
| M5, the file includes nothing (the first round's probe, again) | 3 failed, 8 passed | both `test_a_persons_own_global_config...` cases, and the `xdg` variant of the `HOME` test |
| K1, the receive-pack test without its own `core.hooksPath`, under a global `core.hooksPath` | 1 failed, 8 passed, 2 skipped | the receive-pack test |

S2 is caught by tests that ask for the setting by itself; the scope test does not catch it, because earlier tests in the file build the fixture on request. K1 only fails where a global `core.hooksPath` is set, as NIT 1 says.

On the base itself the new file does not import (`cannot import name 'NO_AUTO_MAINTENANCE'`).

**Merge checks** (`git merge-tree --write-tree` on 979bb39): clean against origin/main 2fac979 and against lane 5's `fix/662-insteadof-helper` (4149cbb; #662 is stacked on accc7d2 and also edits `tests/conftest.py`: the fix round's hunks are in `session_global_config`, its helper `home_is_known`, and the comment above them). Earlier, on e2110f8 and accc7d2: clean against origin/main d4a7fab and c6122e8, #621, #622, #630, #631, #634, #635, #636, #637, #638 and #639, and the sibling branches for #646, #647, #648 and #649.

**What it leaves.**

- A git whose environment drops both scopes (a `GIT_CONFIG_COUNT` overwritten, not appended to, together with a test's own `GIT_CONFIG_GLOBAL`, or an environment built from nothing), and a push made under a test's own global file. No repository the suite copies is made or pushed to that way today.
- A git older than 2.32 ignores `GIT_CONFIG_GLOBAL`. Its `receive-pack` runs `gc --auto`, which does not detach below 6700 loose objects.
- `clone_just_pushed` keeps reading through git. A test with its own global file still needs it.
- `test_family_dry_run.py`'s own `NO_MAINTENANCE` is now redundant, and is left alone (NIT 3).
- Not run: a real Windows job (none runs the full suite since #597, and the `HOME` test is skipped on Windows), and a git newer than 2.43.

**Copies found** (`git grep -n 'shutil.copytree(' -- tests`, 152 calls):

- 127 copy a repository the suite built (non-bare, many holding `.git/modules`): `conftest.py` 1, `test_family.py` 18, `test_shape_doctor.py` 32, `test_doctor_legs_members_next.py` 8, the `test_update_shape*` files 30, and 38 more in 28 files. Four of them copy a subtree that holds no store: `standard / name` twice, and a member's work tree twice.
- 10 copy a bare remote, or a folder of them: `test_bump_leg_paste_lines.py:95`, `test_doctor_unfinished_merge.py:166`, `test_family_add_member_id.py:239`, `test_family_add_refusals.py:487`, `:788` and `:1983`, `test_family_dry_run.py:1446`, `test_family_tracking_branch.py:279`, `test_update_shape_landing.py:156` and `test_update_shape_next_quoting.py:116`.
- 2 copy a `.git` directory: `test_adopt_clone_template.py:190` and `test_scaffold_mount_ignored.py:381`.
- 13 hold no repository: a template, or this checkout with `.git` ignored.
- `test_adopt_plan.py:449` copies a clone named by `SHAPE_MEDXEHR_CLONE`. It is outside the suite and skipped without the variable.

No shipped script copies a repository.

**Checklist candidates** (rv645's; the coordinator places them):

- The import-time git probe at `tests/test_family_refuse_up_front.py:75-96` runs in a throwaway directory before any session setting. It is harmless today.
- Tests that overwrite `GIT_CONFIG_COUNT` (14 files) lose command scope and rely on the global file alone. That holds today, and nothing guards it.

Review: rv645, lane 5's independent review, found no MUST, two SHOULDs and five NITs on accc7d2; the fix rounds above take them, and its re-verification approved 979bb39. CI (Brett Heap's ruling 27): H0 979bb39 passed tests, tests-git-bash and SonarCloud; the head a9f36ce is a clean update-branch merge of main bf1f099, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 760892f over main 2000488: every test file that #706, #703 and lane 5's six round-1 PRs change, run under #713's conftest, with the two copytree-race files and the hygiene trio, 1332 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #645

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
… or an ignored file in the way is refused, P9 names the branch it left, and no git is `git-not-runnable`, so `make bootstrap` loses nothing (#704) (#694) (#745)

`make bootstrap` now moves a leg only where nothing the person has is lost, says which branch it left, reads every HEAD one way, and refuses by name when git cannot run. One pull request closes both issues because they edit the same two `bootstrap.py` files.

It is stacked on #682 (#648, `fix/648-bootstrap-and-gate-order`, head `8b39cd19`). The diff is the one commit after `8b39cd19`; main is not merged.

**Pinned files changed** (each one is an `upstream-changed` row in every project or holder, in v1.1.0's one shape bump, per R0):
- `templates/assembly-root/scripts/bootstrap.py`
- `templates/assembly-root/AGENTS-shape.md`
- `templates/family-root/scripts/bootstrap.py` (already re-pinned by #648)

### #704

- **Line 1, an ignored file.** The four moves that change files overwrote an IGNORED file at a path the pin tracks, with no word said. The four are P9's checkout, T7's checkout after `branch -f`, the on-branch `merge --ff-only`, and the missing branch's `checkout -b`. Each now passes `--no-overwrite-ignore`, so git refuses the ignored file as it refuses an untracked one, in its own words.
  - On T7, `branch -f` has already moved `main` to the pin when the checkout is refused. That was already so for an untracked file, and the line already says `branch main moved ... and git refused to check it out:`.
  - The missing-branch path now prints git's lines through `_say_refused`. It also says where the leg stays: `leaving it on other`. It used to say `leaving detached` for a leg that was on a branch.
- **Line 2, an operation in progress (Brett Heap's R1).** `checkout_tracking_branch` now reads the leg's git dir (`git rev-parse --git-dir`) for these markers, in this order:
  - `rebase-apply/applying` (an `am`), `rebase-apply` and `rebase-merge` (a rebase), `MERGE_HEAD`, `CHERRY_PICK_HEAD`, `REVERT_HEAD`, then `sequencer/todo` (see Fix round).
  - It checks them before any path is chosen.
  - The refusal names the operation, says nothing moved, and gives both commands, for example:

        [spec] spec: a merge is in progress in the leg (MERGE_HEAD is in its git dir), so nothing moved
                  finish it (`git -C spec merge --continue`) or abort it (`git -C spec merge --abort`), then run bootstrap again.

  - Why every leg, and not only the moves that leave a branch: git's checkout drops `MERGE_HEAD` whenever it runs. Measured, it ended a merge from HEAD detached at the pin, from HEAD already on `main` at the pin (a checkout that moves nothing), and in the move back of a branch past the pin.
  - `merge --ff-only` refuses over `MERGE_HEAD` and `CHERRY_PICK_HEAD`. It fast-forwards over `REVERT_HEAD` and keeps the staged revert (measured).
- **Line 3.** `place_at_pin` calls `_say_left` when HEAD was on another branch, as `fast_forward_to_pin` does.
- **Line 4 (R2).** `AGENTS-shape.md` now says bootstrap places a leg at the pin *only* where that loses no commit, and otherwise leaves its checkout where it is, saying why. The code is unchanged.
- **Line 5 (the issue's recommended answer).** A HEAD that is a symbolic ref outside `refs/heads/` is now left where it is by `place_at_pin` (`which is neither`, as T7 already said). The missing-branch path leaves it too (`which is not a branch`). A commit made there lands on that ref.
- **Line 7.** The family root's docstring paragraph (b) now uses the issue's sentence: the member puts its legs on their tracking branches at their pins where that loses no commit, and says why where it does not.

The missing-branch path moved into its own function, `create_at_pin`, so that `checkout_tracking_branch` stays within Sonar's cognitive-complexity limit.

### #694

With no git on PATH, both roots ended in a `FileNotFoundError` traceback, exit 1.

- **Assembly root:** step (a) runs each leg inside `_git_runs`, and so does the offline upstream notice.
- **Family root:** step (a) has a guard around `git submodule update`.
- **Both:** the refusal is `git-not-runnable`, the validators' code, with their first remedy sentence (`put git on PATH and run this again`), and the exit is 2.
- **The run stops there.** Every later step runs git too (#694 asked this with #648).
- **Which `OSError` counts as git's** is decided by NAME, as `validate-pins.py` decides it. The bootstraps do not settle the validators' two rules (the #539 rider).

All runs used `run9.sh` (gh stub first on PATH, `GH_TOKEN=bogus-l5`, empty `GH_CONFIG_DIR`), with git 2.43.0.

- **New: `tests/test_bootstrap_leg_moves.py`**, 27 tests: 22 from the first round, and 5 from the fix round (see Fix round).
  - Line 1: P9, T7 and the missing branch, each from another branch with an ignored `README.md` in the way, plus the on-branch fast-forward with an ignored `NEW-CONTRACT.md`.
  - Line 2: a merge, a cherry-pick and a revert in progress, each on P9, T7 and the missing branch; a merge from three more states (detached at the pin, on `main` at the pin, `main` past the pin); a rebase (both backends) and an `am`.
  - Line 5: all three paths.
  - Every way out is run as printed, and the next run places the leg.
- **New: `tests/test_bootstrap_without_git.py`**, 19 tests.
  - With `PATH` an empty directory: the assembly root (`bootstrap` and `--skip-validators`), the upstream notice in a plain clone, and the family holder (`bootstrap` and `--skip-members`).
  - The name rule, asked of each root's own function.
- **Edited: `tests/test_bootstrap_after_bump.py`.** The three P9 tests that asserted P9 says nothing of the branch it left now read the line exactly.
- **On base `8b39cd19`:** 43 failed, 47 passed. Every new test fails there, except line 5's T7 case, which is the control: T7 already left that HEAD.
- **On head:** every run is green on `4e2b211b`:
  - The 12 core files (every `tests/test_bootstrap*.py`, `test_family_bootstrap_gate_order.py`, `test_family.py`, `test_template_text_placeholders.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_stdout_encoding.py`): `806 passed, 1 skipped` (the skip is Windows-only).
  - The other 53 files that run or name either `bootstrap.py`, in two halves: `1838 passed, 1 skipped` and `2183 passed, 11 skipped`. Their skip reasons were not printed, because the run passed `-rf` in place of `-rs`.
  - Before the commit, the two new files alone gave `41 passed`, and `tests/test_bootstrap_after_bump.py` alone gave `49 passed`.

Every line of both issues was run on `8b39cd19` and on the head, with a scaffold into local bare remotes and git 2.43.0.
- **Line 1:** on base, all four moves wrote the pin's file over the person's. On head, all four refuse with git's lines, and the file is still the person's.
- **Line 2:** on base, every move ended the merge, cherry-pick or revert, or put the rebase or `am` on `main`. On head, every case is refused and its marker is kept.
- **Line 3:** on base, the line was missing. On head, `the leg was on elsewhere, which keeps its commits; it is on main now`.
- **Line 4:** both lines are as the issue quotes them, on base and on head. Only the document changed.
- **Line 5:** on base, P9 placed the leg and the missing-branch path created `main`. On head, all three paths leave it.
- **#694:** on base, a traceback with exit 1 (assembly root; the notice after `validate-pins.py`'s own `git-not-runnable`; family root). On head, `REFUSED git-not-runnable`, exit 2. `--no-fetch` is unchanged: exit 0 on both.

Each hunk was reverted alone in a throwaway clone of the head. In all 17 cases at least one new or edited test fails, on an assertion:

| Hunk reverted | Test that fails |
|---|---|
| `_check_out`'s `--no-overwrite-ignore` | ignored file, P9 |
| `merge --ff-only`'s `--no-overwrite-ignore` | ignored file, the fast-forward |
| `checkout -b`'s `--no-overwrite-ignore` | ignored file, missing branch |
| the in-progress refusal | operation in progress, merge on P9 |
| `am` asked after `rebase-apply` | rebase or `am`, the `am` case |
| P9's `_say_left` | `test_a_leg_on_another_branch_with_its_own_branch_at_the_pin_is_as_it_was` |
| P9 leaving a HEAD outside `refs/heads/` | remote-tracking HEAD, P9 |
| the missing branch leaving it | remote-tracking HEAD, missing branch |
| `leaving it on <branch>` | ignored file, missing branch |
| the missing branch printing git's lines | ignored file, missing branch (`git names the file`) |
| step (a)'s `_git_runs` | no git, assembly root |
| the notice's `_git_runs` | no git, the upstream notice |
| `main`'s `except Refusal` (assembly) | no git, assembly root |
| the assembly root's name rule | an error that is not git's, assembly root |
| the family root's guard around `git submodule update` | no git, family holder |
| the family `main`'s `except Refusal` | no git, family holder |
| the family root's name rule | an error that is not git's, family root |

The two documents (`AGENTS-shape.md` and the family docstring) are prose that no test reads.

- origin/main `de926142`: clean. Main is now `2fac9790`, with #634 landed as `30676c90`. Against it, the only conflict is `templates/family-root/scripts/validate-family.py`, which comes from the parent #682 (`8b39cd19`) and is not in this diff. This branch's own files merge cleanly. It will be restacked onto #682's next head.
- Lane 2's #634 (`fix/610-pinned-text`, `8f5716ae`): `AGENTS-shape.md` auto-merges. The one conflict is in `templates/family-root/scripts/validate-family.py`, which this diff does not touch. `8b39cd19` (#648) has the same conflict with #634.
- Lane 4's `fix/690-siblings-inputs` (`38a40abf`): clean.
- Lane 5's `fix/646-add-member-variant-remedy` (`2104a3d0`): clean.
- No other `fix/6*`, `fix/7*` or `l3-*` branch on origin touches this diff's files.

The reviewer found no MUST. These are the three SHOULDs and two NITs, all in `templates/assembly-root/scripts/bootstrap.py` and `tests/test_bootstrap_leg_moves.py`:

- **S1:** a cherry-pick or a revert of several commits whose stop was committed by hand.
  - The head file is gone then, and only `sequencer/todo` is left, but `git status` still says the operation is in progress.
  - Bootstrap moved the leg onto `main`, and the `--continue` git names then committed the next pick there.
  - It is now refused as in progress. `sequencer/todo` is asked after the `REVERT_HEAD` row, and its first word picks the label: `revert` is a revert, anything else is a cherry-pick.
- **S2:** the missing-branch path now prints the line naming the branch it left, under `created`, as P9 and T7 do.
- **S3:** `create_at_pin` now judges `checkout -b` by where HEAD is afterwards, as `_check_out` does (#533).
  - Under a post-checkout hook that fails, it says `created` and then git's words.
  - It used to say `could not create ...` and `nothing else is tried` over a leg that was on `main` at the pin.
- **Found while doing S3:** `_head_of` read `symbolic-ref` decoded as strict UTF-8.
  - A branch whose name is not UTF-8 (`--branch` a name holding U+DCFF, the byte 0xff) then raised `UnicodeDecodeError`.
  - The S3 check made the first run end in that traceback. `tests/test_bootstrap_branch_bytes.py::test_a_name_that_is_not_utf8_is_still_carried_and_the_branch_made` failed on the fix round's first try.
  - On base, a second run with that name already ended the same way, in `place_at_pin` (measured).
  - `_head_of` now reads the ref as bytes and decodes it with `os.fsdecode`, the same way the name was decoded when it was given.
- **N1:** docstring (d) now says the exit is 2 where step (a) or the upstream notice cannot start git.
- **N2:** the composite assert in the rebase helper is now two asserts.

**New tests:**
- A sequence of each kind (cherry-pick, revert) whose stop was committed by hand: the refusal, an unchanged snapshot, then the printed `--continue`, typed from the root, finishing the sequence on `other`.
- The failing hook on the missing-branch path, from detached and from `other`.
- A non-UTF-8 branch, run twice.
- The ignored-file test now asserts, on every path, the line saying the leg left `other`.

**Runs on `a1f2433f` (the commit's tree):**

| Files | Result |
|---|---|
| the two new files | `46 passed` |
| `test_bootstrap_after_bump.py`, `test_bootstrap_spelling.py`, `test_bootstrap_branch_bytes.py` | `308 passed` |
| `test_family_bootstrap_gate_order.py`, `test_template_text_placeholders.py`, `test_scaffold_e2e.py`, `test_cli_reference.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_stdout_encoding.py` | `397 passed, 1 skipped` (Windows-only) |

**Mutants:** one per new hunk, 5 of 5 caught, each on an assertion:

| Mutant | Test that fails |
|---|---|
| the sequencer check removed | the sequence test, cherry-pick case |
| every sequence called a cherry-pick | the sequence test, revert case |
| `created`'s left line removed | the ignored-file test, missing branch |
| `checkout -b` judged by its exit code again | the failing-hook test, from detached |
| `_head_of` decoding as before | the non-UTF-8 test |

**Merge checks at `a1f2433f`:**
- origin/main `20c8873b`: the only conflict is `templates/family-root/scripts/validate-family.py`, which comes from the parent #682. `AGENTS-shape.md`, which #634 changed on main, merges cleanly.
- `fix/648-bootstrap-and-gate-order` (`8b39cd19`), `fix/690-siblings-inputs` (`38a40abf`) and `fix/646-add-member-variant-remedy` (`a837a023`): clean.
- Not restacked; it waits for #682's fix round.

- **The merge:** `7bdfa3cc` merges #682's fix round `45c512eb` (`fix/648-bootstrap-and-gate-order`, which carries main `2fac9790`) into this branch. There is no rebase and no force.
- **One conflict,** in `templates/family-root/scripts/bootstrap.py`. Both sides inserted a function just above `def gitmodules_paths`: this branch inserted `git_not_started` (#694), and #682 inserted `cures` and renamed the function to `gitmodules_sections`.
- **The resolution** keeps `git_not_started`, then #682's `cures` and its `gitmodules_sections` header and docstring, and drops the old `gitmodules_paths` header.
  - It is byte for byte rv704's modelled resolution, `family-bootstrap-resolved.py`.
  - The merged tree differs from rv704's model `e1322312` only in `tests/test_doctor_legs_members_next.py`: the one commit #682 added after the model.
  - It differs from `45c512eb` only in this branch's six files.
- **Tests on `7bdfa3cc`:** the six bootstrap files (`test_bootstrap_after_bump`, `_branch_bytes`, `_leg_moves`, `_spelling`, `_without_git` and `test_family_bootstrap_gate_order`) give `377 passed`. rv704 had 370 on `a1f2433f`; the other 7 are #682's fix round's own.
- **Merge check against today's main:** origin/main `3ceabef0` merges cleanly (`merge-tree` exit 0, tree `a6ff00b8`).

`f4fa6025` merges #682's second fix round `23e11056` (`fix/648-bootstrap-and-gate-order`, one commit on `45c512eb`) into this branch, with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`, and `git merge` took it with no conflict; `import re` is still in the gate-order test. The six bootstrap files give `387 passed` (`377` at `7bdfa3cc`; the other 10 are the fix round's own tests). The new head merges clean with `origin/main` `2a33a569` (`merge-tree` exit 0, tree `57117de4`).

`f4935e11` merges #682's third fix round `f74f640c` (`fix/648-bootstrap-and-gate-order`, one commit on `23e11056`) into this branch, with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`, and `git merge` took it with no conflict; `import re` is still in the gate-order test. The six bootstrap files give `388 passed` (`387` at `f4fa6025`; the one more is the fix round's own test). The new head merges clean with `origin/main` `e3137010` (`merge-tree` exit 0, tree `76165949`).

- rv704: CHANGES NEEDED at `4e2b211b`, with no MUST; the SHOULDs and NITs were taken in the fix round.
- rv704: APPROVE WITH NITS at `a1f2433f`. Its nits were the two body statements above, now fixed.

- **#458's line 6, the case-sensitive branch compare:** deferred, as #704 says (L4-79).
- **The oldest git:** `--no-overwrite-ignore` was measured only on git 2.43.0, the only git on this machine. The scripts name no floor; they only take care with git before 2.27.
- **The Makefile help (`templates/assembly-root/Makefile:34-35`), the holder's `AGENTS-shape.md:63-65` and its `README.md:17`** still say "where that loses no commit, or says why not". R2 ruled only on the assembly root's sentence, and "or says why not" keeps them true.

These are from rv704's review (its C1 to C4), not taken here as the coordinator ruled. The missing-branch path's exit-code judgement, which an earlier draft listed, is fixed in the fix round (S3).

- **C1:** a `SHAPE_UPSTREAM_PATH` directory nobody may enter ends in a `PermissionError` traceback, exit 1, on head and on base. The notice's docstring says it "never fails the bootstrap".
- **C2:** a validator's exit-2 refusal is reported as `bootstrap FAILED: pins (exit 2)`, exit 1 (measured with no git, in a plain clone).
- **C3:** `scripts/render-cli-reference.py:182-184`, which generates `docs/cli.md:47`, still says bootstrap "places each leg on its tracking branch AT the pinned commit", with no condition.
- **C4:** a reftable leg (git 2.45+, `--ref-format=reftable`) keeps `CHERRY_PICK_HEAD` and `REVERT_HEAD` in its ref store, not as files, so the in-progress check would not see them. Reasoned only: git 2.43 has no reftable.

Review: rv704, lane 5's independent review, approved with nits at a1f2433 after its fix round; the branch was then restacked by merges onto each of #682's fix rounds (f4935e1). CI (Brett Heap's ruling 27): H0 f4935e1 passed tests, tests-git-bash and SonarCloud; the head 1945154 merges #682's final head and then main after #682 landed as dc7d3fb, taking the branch's own side for #682's files, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 3b3e892 over main 98f1497: the bootstrap_after_bump, bootstrap_leg_moves, bootstrap_without_git, bump_leg_paste_lines, cli_reference, clone_url_bytes, family_add_member_variant, family_add_spellings_remedies, family_members_case_variants, install_sh, local_remote_dir_urls, no_auto_maintenance, openreposhape_command, readme_windows_quoting_sentence, shape_doctor, validator_child_encoding, repo_hygiene, agents_md_codes, cli_reference sets, 1186 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #704
Closes #694

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
…s to commit it, `member-gitlink-absent` puts the gitlink back by hand, the variant `mv` is guarded, and a pin row with no `path:` is `shape-pin-row` (#681) (#747)

Every remedy the holder's `validate-family.py` prints for #681's five lines now types as printed in the state it names. A shape-pin row with no `path:` is refused by name in both validators.

**Stacked on #682** (`fix/648-bootstrap-and-gate-order`, head `45c512eb`). The diff is `d91fd15e`, the fix round `dbca1130`, and `031ba75e`, the merge of #682's head `45c512eb`. Retarget to `main` once #682 lands.

**Restacked onto #682's head `45c512eb`** by the merge `031ba75e`, with no rebase and no force. #682 had merged main `2fac9790`, which carries lane 2's #634 (`30676c90`), so the conflicts with #634 are resolved there and here. The new head merges clean with `origin/main` `e634d299`. This branch does not merge main itself.

**Restack 2:** the merge `03e0b5a8` takes #682's second fix round `23e11056` (`fix/648-bootstrap-and-gate-order`, one commit on `45c512eb`), with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`; `git merge` took it with no conflict and the gate-order test still has `import re`. The six files named for this PR (the typed-remedies, pin-row-path, lost-`.gitmodules`, branch-step and pinned-copy-text tests, and the gate-order test) give `205 passed`. The new head merges clean with `origin/main` `2a33a569` (`merge-tree` exit 0, tree `a62ac94b`).

**Restack 3:** the merge `58d6c50f` takes #682's third fix round `f74f640c` (`fix/648-bootstrap-and-gate-order`, one commit on `23e11056`), with no rebase and no force. It touches only `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_gate_order.py`; `git merge` took it with no conflict and the gate-order test still has `import re`. The same six files give `206 passed` (`205` at `03e0b5a8`; the one more is the fix round's own test). The new head merges clean with `origin/main` `e3137010` (`merge-tree` exit 0, tree `cace7c06`).

**The L2-38 pick is the issue's recommended direction, words only.** Brett Heap took it at 2026-10-16T14:11:42Z ("take all recommendations for #670 and #681"). There is no `family.py` change.

- `templates/family-root/scripts/validate-family.py`: every holder takes one `upstream-changed` row.
- `templates/assembly-root/scripts/validate-pins.py`: every assembly root takes one `upstream-changed` row (the L2-3 check only).

**L20, a removal staged and not committed.** The gate lists the gitlinks HEAD records as well as the index's. So after the printed `git rm -- members/IRQS`, before the commit, it named the same gitlink and the same `git rm`. Typed again, that `git rm` dies `fatal: pathspec 'members/IRQS' did not match any files`, exit 128. A second path's `git rm --cached -- Members/IRRS` did the same.
- The listing is kept. A new `_staged_removals` names the gitlinks HEAD records where the index holds no entry at that path, at any stage.
- For those, `member-gitlink-unnamed` says the removal is staged and to commit it, and prints no `git rm`. A hand mount keeps its branch and `add` lines. A second path says only "Commit that removal."
- A `.gitmodules` section left by `git rm --cached` now prints its own `--unset-all` lines. Before, it said "the git rm printed above takes this section out too", and no `git rm` is printed there any more.
- No misfire: a path the index holds unmerged is a conflict, not a removal. It keeps its `git rm`, which takes an unmerged path out (tested).

**L2-38, the `member-gitlink-absent` remedy.** Take a row kept after `git rm` took its gitlink and section out. Its remedy named `git switch -c` and `family.py add`, but `add` refuses the row (`member-already-present`). `family.py remove` dies at `git submodule deinit` (`pathspec 'members/IRRS' did not match any file(s) known to git`, exit 2) and removes nothing. The remedy now prints, in words and `git` lines:
- `git update-index --add --cacheinfo 160000,<pin>,members/<P>`, which puts the gitlink back at the commit the row pins. It changes the index only, as #639's case-variant move does.
- The section lines `member-gitmodules-absent` prints, when HEAD's `.gitmodules` gives no section the path. The line builder `_section_lines` is now shared by both.
- Then "commit that", and `git submodule update --init --recursive` (or `make bootstrap`) checks the member out.
- If the member is to go: take its row out of `family.yaml` by hand and commit that.

No branch is needed, so this remedy no longer prints `switch -c`.

**N-1 and N-2, the variant move's `mv`.** The text gave a bare `mv -- Members/<P> ../Members-<P>`, and stated when to type it by system ("On a case-sensitive disk, as Linux's usually is … On macOS and Windows … leave it"). Each `mv` is now:

    [ Members/<P> -ef members/<P> ] || [ -e Members/<P>/.git ] || mv -- Members/<P> ../Members-<P>

- **N-2:** the `mv` does not run where the two names are one directory, such as a case-folding Linux mount like WSL's `/mnt/c`. There the bare `mv` moved the member's own directory out.
- **N-1:** it does not run where the directory holds a `.git` entry. For a live submodule checkout that entry is a file, `gitdir: ../../.git/modules/Members/<P>`, and a move left it saying `fatal: not a git repository`.
- For a submodule checkout, the text says its commits are kept under this holder's `.git/modules/`, in the directory its `.git` file names, whatever happens to the checkout. Only what is not committed is in the checkout alone. `git -C Members/<P> status` shows it, except where a plain `mv` brought the checkout there, which git cannot open. It says to delete such a checkout by hand once nothing only it holds is needed.
- For a `.git` directory, a clone of its own, the text says its commits are there and nowhere else. `git -C Members/<P> log --oneline HEAD --branches --not --remotes` lists the ones not pushed, and the text says to move the clone out whole, by hand, rather than delete it.

**L2-3, a `files:` row with no `path:`.** Both validators read `str(row.get("path"))`. So `- nopath: x` gave `FINDING shape-copy-missing: contracts/shape-pin.yaml: None is pinned but absent`, and `path: ''` and `path: 5` did likewise. A row whose `path:` is absent, empty or not a string now gives:

    FINDING shape-pin-row: contracts/shape-pin.yaml: a files row's `path:` is None, not the path of a file

That is the code each validator's non-mapping branch already says, and the row `update-shape.py` refuses as `update-pin-row`. `shape-pin-row` is not a new code.

- **The N-1 guard is `-e` (any `.git` entry), as the issue's direction says.** A standalone clone (a `.git` directory) is not moved either. Since the fix round (rv681's S2, Direction B), the text tells a clone apart from a submodule checkout: a clone is moved out whole by hand, and a submodule checkout is deleted by hand. `test_family_head_lost_gitmodules.py`'s #611 move test now uses a plain directory, which is moved. All three checkout kinds (submodule, clone, plain `mv`) are typed in the new test file.
- **Both guards are kept, and each is tested by behavior.** On a case-folding disk, the row's own directory is a checkout in a working holder, so the `.git` test alone would keep it there. The `-ef` test is what keeps it where the directory is empty, as a fresh clone leaves it before `git submodule update`. A test layout covers exactly that case (see the mutation probes).
- **"Staged removal" means the index holds no entry at the path at any stage**, not only "no gitlink at stage 0". An unmerged gitlink is a conflict, and committing it fails, so it keeps `git rm`.
- **No def line changed in a function that may already be over Sonar's cognitive-complexity limit.** `_staged_removals` is its own function, not a second return value of `_member_gitlinks`; it costs one more `git ls-files -z` per run. The branch bodies moved into `_second_path_out` and `_mounted_by_hand`.

The same throwaway probe ran on base `8b39cd19` and on head `d91fd15e`. The case-folding disk is **emulated**, with a `Members` symlink to `members`; no case-folding mount was used.

| Line | Base `8b39cd19` | Head `d91fd15e` |
|---|---|---|
| L20, hand mount | after the printed `git rm`, the re-run prints the same `git rm`; typed again it exits 128, `did not match any files` | the re-run says the removal is staged and prints only `switch -c` and `add` |
| L20, second path | the re-run prints `git rm --cached -- Members/IRRS` again, which exits 128 | the re-run says to commit the removal and prints no command |
| L2-38 | the printed `add` is refused `member-already-present`; `remove` exits 2 at `submodule deinit` | the printed `update-index` and section lines, `git add .gitmodules`, a commit and `git submodule update --init --recursive` all exit 0; the gate exits 0 in every mode |
| L2-38, the row taken out by hand | exit 0 | exit 0, unchanged |
| L2-3, holder and assembly root | `FINDING shape-copy-missing: … None is pinned but absent`, and the same for `''` and `5` | `FINDING shape-pin-row: … is None` / `''` / `5`, `not the path of a file` |
| N-1, live submodule checkout at `Members/IRRS` | after the printed `mv`, `git -C ../Members-IRRS status` says `fatal: not a git repository` | the guarded line moves nothing; `git -C Members/IRRS status` exits 0, and its local commit is in `.git/modules/Members/IRRS` |
| N-2, one directory (emulated) | the bare `mv` moves `members/IRRS` out of the holder | `[ Members/IRRS -ef members/IRRS ]` exits 0, and the guarded line moves nothing |
| N-2, plain case-sensitive layout | the bare `mv` moves `Members/IRRS` out | `-ef` exits 1, the guarded line moves `Members/IRRS` out, and the gate exits 0 |

New files:
- `tests/test_validate_family_remedies_typed.py` (15 tests in the first round, 18 after the fix round): L20, L2-38 (including a fresh clone), N-1 (a submodule checkout, a plain clone, and since the fix round a checkout moved there by plain `mv`), N-2 (one directory, one empty directory, apart). Every remedy is typed as printed, and then the gate is run.
- `tests/test_shape_pin_row_path.py` (10 tests): L2-3, five rows in each validator.

Updated to the new words: `tests/test_family_head_lost_gitmodules.py`, `tests/test_family_bootstrap_gate_order.py`, `tests/test_validate_family_branch_step.py`, `tests/test_family_switch_branch_exists.py`, `tests/test_family.py`.

Runs:
- **Targeted set, 83 files, on `55bd67d9`** (every `test_validate_family*.py` and `test_validate_pins*.py`, `test_family_bootstrap_gate_order.py`, `test_template_text_placeholders.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py`, and every test file naming either validator), in two batches:
  - batch A, 42 files: `2359 passed, 1 skipped in 2540.75s (0:42:20)`;
  - batch B, 41 files: `2879 passed, 23 skipped in 3364.87s (0:56:04)`.
  - The skips need a Windows host.
- **`d91fd15e`** differs from `55bd67d9` only in `tests/test_validate_family_remedies_typed.py` (the empty-directory layout). The two new files on it: `25 passed in 33.44s`.
- **Fail on base:** head's changed tests over `8b39cd19`'s code. With `55bd67d9`'s tests: `30 failed, 3 passed in 47.76s`. The N-2 test's added layout, with `d91fd15e`'s tests: all 3 layouts fail, `3 failed in 7.64s`. The 3 that pass do so by design:
  - the unmerged-gitlink no-misfire test;
  - the row taken out by hand;
  - the BRANCH_LEFT guard test, since the sentence already ended every `switch -c` remedy.

| Mutant | Killed by |
|---|---|
| M1, `_staged_removals` returns nothing | 5 failed: both hand-mount L20 tests, the second-path test (2 disks), the section test |
| M2, the section ignores a staged removal | 1 failed: `test_a_section_git_rm_cached_leaves_keeps_its_own_commands` |
| M3, only stage-0 entries count as indexed | 1 failed: `test_an_unmerged_gitlink_keeps_its_git_rm` |
| M4, the old `add` remedy back | 3 failed: both put-back tests and the fresh-clone test |
| M5, section lines printed even when HEAD gives the section | 1 failed: `…put_back_as_printed[git-rm-cached]` |
| M6, no `-ef` test | 5 failed (the refusal's text pin) |
| M7, no `.git` test | 5 failed (the refusal's text pin) |
| M8, no `path:` check in `validate-family.py` | 5 failed: every holder row |
| M9, no `path:` check in `validate-pins.py` | 5 failed: every assembly-root row |
| M10, an empty `path:` allowed | 1 failed: `[empty-family-holder]` |

M6 and M7 again, with the refusal's text pin switched off in the clone's test helper, so that only behavior can kill them:
- **M6:** 1 failed, `[one-empty-directory]`. The `-ef` test is what keeps an empty row directory in place.
- **M7:** 2 failed, both checkout-at-the-variant tests. The `.git` test is what keeps a checkout in place.
- **Control, the pin switched off and nothing else:** 5 passed.

- **`origin/main` `e634d299` (and `3ceabef0`): clean.**
- **#682's head `45c512eb`:** an ancestor of this head.
- **Clean:** the 30 other open `fix/6*`, `fix/7*` and `l3-*` branches on origin: `fix/645`, `fix/646` (`a837a023`), `fix/652`, `fix/654`, `fix/661`, `fix/662`, `fix/663`, `fix/664`, `fix/665`, `fix/666`, `fix/667`, `fix/672`, `fix/674`, `fix/684`, `fix/686`, `fix/688`, `fix/689`, `fix/690` (`38a40abf`), `fix/692`, `fix/693`, `fix/696`, `fix/704` (`7bdfa3cc`), `l3-563-docs`, `l3-677`, `l3-678`, `l3-680`, `l3-687`, `l3-691`, `l3-697` and `l3-rehearsal-v1.1.0`.
- **Two conflicts, neither in a file this branch changes:**
  - `fix/695-family-docs` (`c42286de`), in `docs/handbook.html`. It is clean with main and conflicts with #682's head `45c512eb` the same way.
  - `l3-683-partial-clone` (`9f24bf31`), in `shape-doctor.py`. It conflicts with `origin/main` the same way.

This is one new commit on `d91fd15e`, not an amend. Only text changes in `validate-family.py`: the guards are the same, and `validate-pins.py` is not touched.

- **S1.** A hand mount taken out with `git rm --cached` leaves its checkout at the path. Git itself suggests `--cached` when the printed `git rm` refuses a checkout with changes. The printed `family.py add` then refused that directory (`member-path-taken`).
  - The staged-removal text now says: "Where git rm --cached took it out, its checkout is still at <path>, and family.py add refuses that directory (member-path-taken): delete it by hand, once nothing only it holds is needed, before the add."
  - The L20 hand-mount test gains a `git rm --cached` case. It is typed through the section's printed lines and the commit; then `add` is refused `member-path-taken`; the checkout is deleted by hand; `add` is refused `member-git-dir-cached`; the store is deleted; `add` exits 0, and the gate exits 0 in every mode.
- **S2, Direction B.** The `-e` guard is kept, as the issue's "no .git entry", which Brett Heap's word took. For a `.git` directory, the text now says its commits are there and nowhere else, names the `git log` above, and says to move the clone out whole, not delete it.
  - The `[clone]` case asserts that the line keeps the clone and that the printed `git log` lists exactly its own commit. It then moves the clone out whole and reads the commit there.
  - Direction A (`-f`) is not taken. The coordinator is asking Brett.
- **N1.** The store is "under this holder's .git/modules/, in the directory its .git file names". It is not always `.git/modules/Members/<P>`: a checkout a plain `mv` brought to the variant names `.git/modules/members/<P>`. The status check now says git cannot open such a checkout.
  - A new `[moved]` kind types this state: the commit is in the store the `.git` file names, `git -C Members/IRRS status` fails, the checkout is deleted by hand, and `git submodule update --init --recursive` checks the member out again.
- **N2.** The guarded line's sentence says "in a POSIX shell", as the section lines do.
- **N3.** A new test writes the section back in the work tree's `.gitmodules` alone. The section lines still print, because HEAD's `.gitmodules` is what a clone gets. Typed, staged and committed, they give a gate that exits 0.

Runs on the fix round:

| Run | Result |
|---|---|
| rv681's four files plus the new tests (`test_validate_family_remedies_typed.py`, `test_shape_pin_row_path.py`, `test_family_head_lost_gitmodules.py`, `test_validate_family_branch_step.py`) | `80 passed in 86.95s` (rv681 had 77 on `d91fd15e`) |
| every other file importing the changed helpers (`test_family_bootstrap_gate_order.py`, `test_family_remove_gitmodules.py`, `test_family_switch_branch_exists.py`, `test_validate_family_member_path_glob.py`, `test_validate_family_unnamed_mounts.py`) | `137 passed in 128.12s` |
| `test_template_text_placeholders.py`, `test_repo_hygiene.py`, `test_agents_md_codes.py` | `266 passed in 50.50s` |
| MO1 (`_gitlink_put_back` reads the work tree's `.gitmodules`), rv681's patch | killed: `1 failed, 94 passed`, `test_a_section_back_in_the_work_tree_alone_still_prints_its_lines` (it survived with 92 passed on `d91fd15e`) |
| M5 (section lines printed even when HEAD gives the section), rv681's patch | still killed: `1 failed, 17 passed`, `[git-rm-cached]` |

Not re-run in this round, to save usage:
- MO2 (`-e` changed to `-f`). By reading: it now moves the clone in the `[clone]` case, which asserts the clone is kept.
- The 83-file targeted set, the fail-on-base run and the other mutants.

The texts this round changes are pinned only in `test_family_head_lost_gitmodules.py` and `test_validate_family_remedies_typed.py` (`git grep` of each changed phrase in `tests/`).

**The restack merge (`031ba75e`).** Two files conflicted; every other file merged clean.
- `validate-family.py`, 3 regions:
  - WHAT IS CHECKED keeps #682's narrower wrong-case sentence, then this branch's staged-removal sentence.
  - The signature is `_check_member_gitlink(root, path, commit, project, repository, gitlinks, report, rule)`.
  - `_check_member` passes `row.get("repository"), gitlinks, report, rule` and returns `Gitlinked(row, project, path, pin, commit, rule)`.
- `tests/test_family_bootstrap_gate_order.py`, 2 regions:
  - the docstring keeps both sentences;
  - the import from `test_family_head_lost_gitmodules` is the union of both sides.
  - `import re` is put back by hand. The merge had dropped it with no conflict marker, and the file calls `re.compile` and `re.findall` (rv681's note). `import subprocess` stays out, since neither side uses it.
- The gate-order test is rv681's model (`_lane5-scratch/rv681-restack2`) byte for byte. `validate-family.py` is that model with this branch's fix round `d91fd15e..dbca113` applied by `git merge-file`, byte for byte.
- Runs on `031ba75e`:
  - the four files plus `test_pinned_copy_text.py`: 172 passed in 65.19s (`$Q/v112/w681/rs/four.log`);
  - the gate-order test and the rest of the family set, plus `test_validate_pins_lockstep_lines.py`: 159 passed in 104.07s (`$Q/v112/w681/rs/more.log`);
  - hygiene, the placeholder test and the AGENTS.md codes test: 266 passed in 38.36s (`$Q/v112/w681/rs/hyg.log`).

- `family.py`'s own `family-members-dir-variant` and `member-path-taken` move remedy (`mv -- Members/<P> <new name>`) has N-1's flaw for a live submodule checkout. Fixing it is a `family.py` change, so it is a checklist candidate below.
- `AGENTS.md`'s "Never hand-edit `family.yaml`'s `members:` block" is not changed. The validator already tells people to put a row back by hand in a repair state (`RESTORE_ROW`), and now `member-gitlink-absent` tells them to take one out by hand. Whether to reconcile the sentence is a checklist candidate.

1. **`family.py`'s case-variant move remedy breaks a live submodule checkout.** Its `family-members-dir-variant` and `member-path-taken` refusals print `mv -- Members/<P> <new name>` for any directory, a live submodule checkout included. That checkout's `.git` file names `.git/modules/` by a path relative to it, which the move leaves pointing nowhere. The same guard, or the same "delete it by hand" words, would fix it.
2. **A member's own staged un-registration passes the gate.** After `git rm --cached members/IRRS`, before the commit, the gate reads the gitlink from HEAD and says ok. `member-gitlink-absent` only fires after the commit, so CI is the first to see it.
3. **`AGENTS.md` and the validator disagree on hand edits.** `AGENTS.md` says never to hand-edit `family.yaml`'s `members:` block. The validator's repair remedies tell people to do so (`RESTORE_ROW`, and now `member-gitlink-absent`). One of the two should change.
4. **`member-commit-unresolvable` names no remedy** (rv681's candidate 3). Take a pin no remote holds: `git submodule update` exits 128 (`not our ref`), the gate refuses `member-commit-unresolvable` with no remedy line, and `--no-members` exits 0 over it.
5. **The v1.0.0 notes list L20 as a known limit** (rv681's candidate 4). `docs/release-notes/v1.0.0.md:466` lists it. The v1.1.0 notes (lane 3's #630) should say #681 fixes it.

**Review.** rv681 re-verified the fix round: APPROVE WITH NITS at `dbca1130`, the nits being three body lines, now fixed. Its P1 probe now ends with `add` exiting 0 when typed as printed, MO1 is killed, and its four files give 80 passed. MO2 (`-f` for `-e`) fails only the `[clone]` case, so that test alone holds the `-e` choice (Direction B); Direction A stays Brett's call.

Review: rv681, lane 5's independent review, approved with nits at dbca113; the branch was then restacked by merges onto #682's fix rounds (58d6c50), with `import re` put back in tests/test_family_bootstrap_gate_order.py. CI (Brett Heap's ruling 27): H0 58d6c50 passed tests, tests-git-bash and SonarCloud; the head ff7c59b merges #682's final head and then main after #682 landed as dc7d3fb, taking the branch's own side for #682's files, with tests, tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 994e716 over main 40e678e: the bootstrap_after_bump, bootstrap_leg_moves, bootstrap_without_git, bump_leg_paste_lines, cli_reference, clone_url_bytes, family, family_add_member_variant, family_add_spellings_remedies, family_bootstrap_gate_order, family_head_lost_gitmodules, family_members_case_variants, family_switch_branch_exists, install_sh, local_remote_dir_urls, no_auto_maintenance, openreposhape_command, readme_windows_quoting_sentence, shape_doctor, shape_pin_row_path, siblings_inputs, validate_family_branch_step, validate_family_remedies_typed, validator_child_encoding, repo_hygiene, agents_md_codes, cli_reference sets, 1495 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #681

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
…efusals and `family-operation-in-progress` name a path as `shown_source` names it, so an ESC or a line break in a path no longer reaches the terminal (#692) (#731)

#622's folder refusals printed a path as it was. `family.py init --into <a file named wd-ESC[31mRED> --dry-run` gave `REFUSED family-folder-cannot-be-made: the family folder .../wd-^[[31mRED/Orbit cannot be made: ...` through `cat -v`. A name with a line break and then `WARNING forged-line: pwned` printed that line as a line of its own. R612's `family-operation-in-progress` printed the mark's path across two lines for a holder whose path holds a newline.

Each path is now spelled as `shape_materialize.shown_source` spells a `--pin-source`: as it is, and as Python's `repr` spells it where a character in it is not printable.

- `scripts/shape_materialize.py`: both answers of `folder_cannot_be_made` (a file or a bad stat in the way, and a folder this user cannot write in), the folder in `refuse_unmakeable_folder`, and the folder in `make_folder`.
- `scaffold-project.py`: `scaffold-target-exists`.
- `scripts/family.py`: `family-target-exists`, and the mark's path in `family-operation-in-progress` (`where.as_posix()`).

A printable path is named byte for byte, so no existing row changes and `why.endswith(CANNOT_WRITE_IN)` in `refuse_unmakeable_folder` still holds. No help text changed, so `docs/cli.md` is untouched. No README, handbook or template sentence says how a refusal names a path (`git grep` of `shown_source`, `target-exists` and `folder-cannot-be-made` in AGENTS.md, README.md, `docs/handbook.html` and the templates found none), so no doc changes.

- **`shown_source`, not `shown_path`.** Since #634, `shape_materialize.shown_source` returns `repo_shape.shown_path(source)` (`scripts/shape_materialize.py`: `return shown_path(source)`), so the seven call sites this change adds (four in `shape_materialize.py`, one in `scaffold-project.py` and two in `family.py`) spell a path as `shown_path` does. Renaming them to `shown_path` stays out of this PR: it would only move imports in `scaffold-project.py` and `family.py` and reword docstrings in `family.py`, `shape_materialize.py` and the test file, and the review's N1 left it optional. I did not take it. The predicted merge with main works with the alias: `family.py --help` and `scaffold-project.py --help` exit 0 on it, and `shown_source` and `shown_path` return the same string for an ESC name, a line-break name and a plain path.
- **`shown_source` is imported by name into each file's existing `from shape_materialize import (...)` block.** The first commit put it on a line of its own after the block; lane 4's #660, #688 and #693 add an import at that same place, and `merge-tree` reported add/add conflicts in `scripts/family.py` and `scaffold-project.py`. The second commit moves the name into the block, and those conflicts are gone.
- **"The holder in the other refusals of the same block" is one refusal.** The `# What the holder is in the middle of, and what its sparse checkout leaves out` block holds `family-operation-in-progress`, `family-sparse-excluded` and `member-path-unaddable`. Only the first prints a path of the holder's. `family-sparse-excluded` names `members/<Project>` and `.gitmodules`, and `member-path-unaddable` names `members/<Project>`.
- **No scaffold row for `--local-remote-dir`.** A control character there is refused earlier as `scaffold-unwritable-value`, which already names the path with `repr`. The unit test covers `make_folder`, which only a race reaches by the command line.

`tests/test_dry_run_parity.py`:
- `SCAFFOLD_CASES` gains five rows: ESC and line break in the `--work-dir` name, ESC in a folder name above it, and ESC and line break in a work dir that holds a file named `Atlas` (`scaffold-target-exists`).
- `FAMILY_CASES` gains five rows: ESC and line break in `--work-dir`, ESC in `--into` (`the family folder`), and ESC and line break in a work dir that holds a file named `Orbit` (`family-target-exists`).
- Two rows join `UNWRITABLE_FOLDER_AT` (ESC in the scaffold's and in `init`'s `--work-dir`, a folder this user cannot write in).
- Rows named `ESC-...` and `line-break-...` are skipped on Windows, whose file names cannot hold them (`POSIX_NAMES`, `case_params`). Every expectation spells the path with `repr`, and not with `shown_source`.
- `assert_refused` now fails any stderr line that starts with `WARNING forged-line`.
- Three unit tests: `test_each_folder_question_spells_a_name_that_is_not_printable` (ESC, line break; `folder_cannot_be_made`, `refuse_unmakeable_folder` and `make_folder` by themselves), `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`, and `test_a_printable_name_is_named_byte_for_byte` (a name with a space and an accented letter stays unquoted).

`tests/test_family_refuse_up_front.py`: `test_a_holder_path_with_a_newline_still_shows_its_merge` asserted the raw newline in the refusal (`"Ink\nRouter/.git/worktrees/" in ran.stderr`). It is now `test_a_holder_path_with_a_control_character_still_shows_its_merge`, over a line break, an ESC and a forged line, and asserts the one-line `repr` form (`refusal()` fails unless the stderr is two lines). It is the one existing test this change had to edit.

Results at head `ab55c29f` (Python 3.12.3, Linux):
- `tests/test_dry_run_parity.py tests/test_dry_run_asks_remotes.py tests/test_family_dry_run.py tests/test_family_init_landing.py tests/test_repo_hygiene.py tests/test_pin_source_unreadable.py tests/test_scaffold_leg_path_collisions.py tests/test_scaffold_reuse_seeded_legs.py`: **494 passed**.
- `tests/test_family.py tests/test_family_refuse_up_front.py tests/test_family_init_values.py tests/test_family_add_refusals.py`: **356 passed, 19 skipped** (the skips are `test_family_init_values.py:219` and `:291`, template-quoting premises).
- Not run: the full suite (the coordinator's gate), and macOS and Windows (CI is Linux-only since #597). The new hostile-name rows are POSIX only by design.

On the base code: the review overlaid this head's two test files on main `2fac9790` and ran `tests/test_dry_run_parity.py` and `tests/test_family_refuse_up_front.py` with `-k "dry_run_parity or control_character"`. The result is **20 failed, 87 passed** (76 deselected, 43.5 s). The 20 are exactly the new and edited tests: the 5 scaffold and 5 `init` rows, the 4 unwritable-folder rows, the 3 unit tests (`test_each_folder_question_spells_a_name_that_is_not_printable` in two cases, and `test_a_folder_this_user_cannot_write_in_is_spelled_safe_to_show`), and the 3 cases of the edited `test_a_holder_path_with_a_control_character_still_shows_its_merge`. The one new test that passes on main's code is `test_a_printable_name_is_named_byte_for_byte`, by design: a printable name is named byte for byte on both. An earlier draft of this body said 21 failed, 2 passed. That run was on `8062810d` with a wider selection, and it held a scaffold row for ESC in the local remote dir's name (`ESC-in-the-local-remote-dir-name`) that this head's tests no longer have; its second pass was the existing `test_the_refusal_quotes_gh_s_first_line_and_escapes_what_is_unprintable`, which also passes in the new run, one of the 87.

`family.py init` and `scaffold-project.py --dry-run` against a file named `wd-ESC[31mRED`, a file named `wd-\nWARNING forged-line: pwned`, and a work dir with a file `Atlas` or `Orbit` in it, run with a stub `gh` and no real `gh`:
- On main, through `cat -v`: `... the family folder /.../wd-^[[31mRED/Orbit cannot be made: /.../wd-^[[31mRED is a file, not a folder`, and a stderr line `WARNING forged-line: pwned cannot be made: ...` of its own.
- On this head: `REFUSED family-folder-cannot-be-made: the family folder '.../wd-\x1b[31mRED/Orbit' cannot be made: '.../wd-\x1b[31mRED' is a file, not a folder`, and one line for the forged name: `--work-dir '.../wd-\nWARNING forged-line: pwned' cannot be made: '...' is a file, not a folder`. `scaffold-target-exists` and `family-target-exists` are one line each in the same form.

Each spelled site reverted alone in a throwaway clone at `19d126cd` (the call sites are the same at `ab55c29f`); the new tests that fail for each:
- `folder_cannot_be_made`, the `_not_a_folder` answer: 8 failed (six CLI rows and the two unit tests).
- `folder_cannot_be_made`, the `CANNOT_WRITE_IN` answer: 5 failed (the four unwritable rows and the unit test).
- `refuse_unmakeable_folder`: 12 failed.
- `make_folder`: 2 failed (the unit test only).
- `scaffold-target-exists`: 2 failed (its two rows).
- `family-target-exists`: 2 failed (its two rows).
- `family-operation-in-progress`: 3 failed (line break, ESC, forged line).

`git merge-tree --write-tree` of this head `ab55c29f` against origin/main `20c8873b` and against every ref on origin that could meet it, fetched 2026-10-16: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. (The same list was run for #730's head `7f2bb36b`.)
- **origin/main `20c8873b`: clean** (the predicted tree `00b5b24c`, which differs from main in these five files only: 216 lines added, 25 removed).
- **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change. This change touches no README or handbook.
- **The refs that edit one of this change's five files**, and so could meet it, all merge clean with this head: `fix/646`, `fix/651`, `fix/654`, `fix/660`, `fix/661`, `fix/684`, `fix/686`, `fix/688`, `fix/689` and `fix/693`, the heads of #670, #725, #728, #743 and #744, and the heads of the landed #634, #638, #639, #640, #643 and #671. #634 has landed (`30676c90`), so the `fix/660` note about a conflict with it is gone.
- **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change. Every other ref, 92 of the 96 in all, is clean with this head, including the refs that conflict with main in `templates/family-root/scripts/validate-family.py` (#682, #745, #747, `fix/648`, `fix/681`, `fix/704`) or in `shape-doctor.py` (#742, `l3-683`) on their own. So do the old heads of the landed #638 and #639, and lane 3's `l3-677-pins-hand-step-wip` branch, and each of them merges clean with this head too.
- **Tests on the predicted merge**, run by the review on the merge with main `2fac9790`: `test_dry_run_parity`, `test_family_refuse_up_front`, `test_pinned_copy_text`, `test_pin_source_unreadable`, `test_dry_run_asks_remotes`, `test_family_dry_run` and `test_family_init_landing`: 445 passed in 215 s. `test_family`, `test_family_init_values`, `test_family_add_refusals`, `test_derived_id_refusals`, `test_repo_hygiene`, `test_naming_policy`, `test_scaffold_leg_path_collisions` and `test_scaffold_reuse_seeded_legs`: 758 passed, 19 skipped in 243 s. I did not re-run them on `20c8873b`; the merge is clean, and the two commits since `2fac9790` (#700 and #702) change only `shape-doctor.py`, `templates/assembly-root/scripts/validate-pins.py` and four test files, none of them a file of this change.
- Before the second commit, `fix/660`, `fix/688` and `fix/693` each conflicted with this head in the import region of `scripts/family.py` or `scaffold-project.py` (a separate import line each side added at the same place). That is why the import joins the existing block.

#634 is on main, and this change does not edit `shown_source` or its docstring in `scripts/shape_materialize.py`.

The review approved with nits. Its S1, the stale base-run numbers and merge lines, is taken here, in this body: no code changed, and the head is still `ab55c29f`. Its N1, renaming the call sites to `shown_path`, is not taken (see "Decisions and their evidence"). It also re-ran 5 of the 7 mutation probes on `ab55c29f` and got the same failure counts as above (12, 2, 3, 2 and 2), and ran 144 hostile-name dry runs (16 names in 9 scenarios): on main 69 rows put a control character on stderr and 6 print a forged line of their own; on this head all 144 exit 2 with no traceback, no control character and no forged line.

- `scripts/family.py` `init`'s plan prints `folder` and `landing` lines with the path raw (`print(f"landing      {holder}   ...")`, `print(f"folder       {folder}   ...")`). On a dry run or a refused run the plan is on stdout before the refusal, so `WARNING forged-line: pwned/Orbit   (--work-dir: no family folder)` still appears there. It is the plan, not a refusal, and not one of the issue's lines.
- Other refusals that name a holder or folder path raw, none of them in the issue's lines: `family-holder-detached` and `family-on-tracking-branch` (`{family.root}`), `family-index-locked` (`lock.as_posix()` in the detail), `family-landing-in-standard` (`{holder}`, `{checkout}`, `{beside / family / family}`), `family-holder-already-here` (`{into}`), and `family-remote-exists` and `scaffold-remote-exists` (`{target}`, a local remote path).

1. **family.py `init`'s plan prints the holder and folder paths raw.** `scripts/family.py` `landing` and `folder` rows (about :1756-1759) print `holder` and `folder` as they are, so a forged line or an ESC reaches stdout in a `--dry-run` and in a run refused after the plan. Direction: spell them with `shown_source`.
2. **The same raw spelling in the refusals listed above.** `family-holder-detached`, `family-on-tracking-branch`, `family-index-locked`, `family-landing-in-standard`, `family-holder-already-here`, `family-remote-exists` and `scaffold-remote-exists` each print a path the person supplied or git keeps. Direction: the spelling this change gives its sites.

Review: rv692, lane 5's independent review, approved ab55c29 with nits, and its body fix (S1) is published. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 ab55c29 passed tests, tests-git-bash and SonarCloud; the head 13f643d is a clean update-branch merge of main 431c8cf, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 4327947 over main 431c8cf: the dry_run_parity, family_refuse_up_front, repo_hygiene, agents_md_codes, cli_reference sets, 330 passed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts".

Closes #692

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
… or a leg checkout the gate cannot reach or read is refused by name, relative to the root, once and with its cause's advice, and every name and path it prints is spelled (#712) (#827)

Issue #712 gathers lane 4's checklist lines L4-103, L4-104, L4-106 and
L4-144, with the riders L4-105, L4-143 and L4-102, on the assembly root's
lockstep gate. This takes all seven.

L4-103. The leg pin, the shape pin, each shape copy, each neutral-product
pin and the manifest were asked with `Path.is_file()`. Up to Python 3.13 it
raised a refused `stat`, so a `contracts/` at mode 000 or r-- ended the run
in a PermissionError traceback, exit 1; on 3.14 it answered False, and the
run refused `pin-missing`, a present file said not to exist, under THE
LOCKSTEP RULE's remedy. The five sites are `_is_file` now: #633's `_is_a`,
and a refused `stat` is a refusal naming the file. A name no `stat` can be
handed (a NUL in a role or a product) is no file, as `is_file()` answered.
A shape copy at mode 000 passed its `stat` and ended in `file_sha256`'s
PermissionError on every Python, in the same function; its open is the same
refusal. A copy has no id for one, so it is `shape-copy-unreadable`, beside
`shape-copy-missing` and `shape-copy-drift`; the YAML files keep
`yaml-unreadable`, the id `load_yaml` gives them.

Checkouts this user cannot search (RV827's S1, fix round 1). A leg's
checkout at 000 or r-- (`_check_pin_digest`'s `(submodule /
".git").exists()`) and a checkout beside the root at 000
(`resolve_neutral_pin_source`'s) raised `PermissionError` out of the run on
3.11 to 3.13; from 3.14 the leg would have read as an uninitialized
submodule and the sibling been passed over for `gh api`. Both ask
`_is_there`, `_is_a` with any kind. The leg is `pin-submodule-unreadable`,
beside `pin-submodule-uninitialized`, naming the `.git` it could not reach
(`spec/.git: cannot be read (Permission denied)`) with the cause's advice;
the sibling is there, so it is the source, and the pin's
`neutral-pin-source-unreadable` finding says it could not answer.

L4-104. A pin this user cannot open was `load_yaml`'s line, `<absolute
path>: [Errno 13] Permission denied: '<absolute path>'`, with the lockstep
remedy, which moves a pin and says nothing of access. `_load` reads the
`OSError` the refusal is raised from, which `load_yaml` has chained since it
was written, and names the file relative to the root, POSIX, once:
`contracts/spec-pin.yaml: cannot be read (Permission denied)`, with a remedy
of its own, so `main` adds no other. Read off the cause and not off new
library words, so a root whose `scripts/repo_shape.py` is older than its
`validate-pins.py` names the file the same way (#370, #610). `load_yaml`
itself names the path once for every caller: `<path>: cannot be read
(<the system's reason>)`.

One rule for every file the gate names, on the coordinator's ruling: the
other refusals `load_yaml` raises here (not UTF-8, not parsable) keep their
id, words and remedy and name the file relative to the root, and so do
`not a mapping` for the four YAML files, `shape-pin-missing`,
`manifest-missing` and `manifest-no-legs`, which named the absolute path
raw.

L4-105. Every file this gate cannot open is told what its cause needs. A
permission keeps the words it had (a workflow) or gets bump-leg.py's (#383)
for a pin, the manifest or a copy: `chmod u+r` on a file, `u+rx` on each
directory above it. Any other cause, an I/O error or a name too long, gets
bump-leg.py's other sentence and is never told to `chmod`. `_Unreadable`
carries the cause, so one workflow refusal naming both kinds says both.

L4-106, L4-144 and every other print, on the coordinator's ruling (option
A). A workflow's name, a pin's path built from a role or a
`neutral_product_pins:` entry, a leg's path, role and repository, a pin's
`source_repository`, a declared product, a shape row's path and digest, the
shape pin's and the manifest's commits, a `--pin-source` value with no path
and the root in a printed `bump-leg.py` line each reached stderr or stdout
raw: a newline began a line, a bidi control reordered one, a C1 control
broke one, an ESC was the control it is. Each is printed as `_shown`
spells it now: `repr` where a character is not printable, the same bytes
where every one is. A word of a printed command is `shlex.quote`d as before
where it is printable, and `_shown`'s where it is not (`_quoted`): no
quoting makes a control character pasteable, and quoted it still reached
the terminal. The workflow-reference note's repository is not spelled: it
prints only a repository equal to a reference `WORKFLOW_REF_RE` matched,
which is printable ASCII.

L4-143. A `--pin-source` under a `neutral_product_pins:` that is not a list
was noted "project.yaml declares no neutral-product pin"; it says the field
is not a list and points at the `manifest-neutral-product-pins` finding.

L4-102. `PinSources` keys `--pin-source` products by `pin_product_key`, the
pin file's own fold, as #638 keys the naming gate's `LinkSources`:
`casefold()` took `openss=` for `openß`, two pin files.

Re-pin: `templates/assembly-root/scripts/validate-pins.py` and
`scripts/repo_shape.py` are pinned shape copies. As #634 says, no digest is
recorded in this repository; each root and holder reads the copies it
carries as `upstream-changed` in its `contracts/shape-pin.yaml` (both in an
assembly root, `scripts/repo_shape.py` in a family holder), which
`update-shape.py apply` re-copies and re-pins.

Tests: tests/test_validate_pins_named_and_spelled.py (new): the issue's
modes through the real gate, the five sites under a refused `stat` on every
Python, every file named relative to the root, each cause's advice, one row
for each print over a newline, a bidi control, a C1 control, an ESC and a
byte that is not UTF-8 (and a printable control), the issue's own pin entry
and workflow names, the L4-143 note and the L4-102 keys, a leg checkout
and a checkout beside the root this user cannot search, and a role or a
product spelled as an absolute path. Four existing
tests held the old absolute path: tests/test_refusal_default_remediation.py
(the `yaml-unparsable` line under the lockstep remedy),
tests/test_bump_leg_unreadable.py (the validator names the other leg's pin
POSIX now, on Windows too), tests/test_reader_refusals_named.py (#817's
`yaml-unparsable` line from the root's gate, which names
`contracts/spec-pin.yaml`) and a docstring in
tests/test_validate_pins_unreadable_workflow.py.

Closes #712

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