Skip to content

family.py: add asks the holder's index for the mount path in any case, so an entry Members/Atlas/README is refused as member-path-taken before anything is cloned instead of leaving the member's 19 files staged under it, and a refused add says the holder is exactly as it was only when the whole index is as it was (#302) - #318

Merged
brettheap merged 4 commits into
mainfrom
fix/family-rollback-icase-index
Oct 12, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #302 (comment)

What and why

family.py add read the holder's index by exact name, twice: first in member-path-taken before the mount (_index_entries, #242), then in MountRollback after it. With core.ignorecase=true and an index entry Members/Atlas/README (nothing on disk), main (c0ed615) mounted the member and git submodule add staged its 19 files under Members/Atlas/. The commit's pathspec members/Atlas then matched nothing. The unwind put back six things and printed "the holder is exactly as it was before this command" over an index that had gone from 16 entries to 35. A plain git commit would have committed them.

The rulings

  1. Refused before the mount. _index_entries finds every entry at the mount path or under it, in any case, on every disk, whatever core.ignorecase says, as adopt's _index_entries does. A holder is cloned on macOS and Windows, where the two spellings are one path.
    • Literal, with no pathspec at all. It lists the whole index (git ls-files -s -z) and compares names with ASCII case folded. Probed on git 2.43 with a * in the path:
      • --literal-pathspecs --icase-pathspecs is fatal;
      • --icase-pathspecs alone globs (members/A* selected members/Atlas/z);
      • :(literal,icase) works, but under GIT_LITERAL_PATHSPECS=1 git does not parse the magic, matches nothing and exits 0, so the refusal would vanish silently;
      • git's icase folds ASCII only, as bytes.lower() does. APFS and NTFS fold more, which no git pathspec check follows either.
    • Named as the index spells it. The refusal names the entry in its own spelling. Its remediation spells each variant, because git rm --cached -- members/Atlas does not match Members/Atlas/... even with core.ignorecase=true.
    • A variant HEAD also holds. The remediation says to commit the removal first, because the add's own commit names members/Atlas alone. Skipping that step leaves HEAD in one of these states, all probed:
      • it keeps the other spelling beside the member (core.ignorecase false);
      • it records the member's gitlink under a spelling other than the members/Atlas that .gitmodules and family.yaml say (core.ignorecase true: Members/Atlas/README gave Members/Atlas, and members/atlas gave members/atlas);
      • both (core.ignorecase true: MEMBERS/ATLAS/README was kept, with the gitlink at MEMBERS/Atlas).
    • Exact-case text unchanged. Exact-case refusals are byte-identical to main's, a conflicted entry's "and 2 more" included, and family.py add runs git submodule add without --force (and _commit_one a plain git add), so under an operator's global excludes naming members or the member's own name it exits 2 at the mount for every member, and the remediation it prints blames access (gh auth status, your SSH key) instead of the ignore rule #242's rows pass.
    • On disk. A tracked Members/Atlas directory is refused now. An untracked one on a case-sensitive disk still adds beside it, and the commit holds only the gitlink. On a case-insensitive disk the existing lexists check already refuses it.
  2. "Exactly as it was" only for what was compared. MountRollback records the whole index, names and staged records, before the mount, and undo compares it after its steps.
    • When they agree, the sentence is today's.
    • When they differ, it says the index is NOT as it was. It names what is new, gone and staged differently, at most five of each. It points to git ls-files -s, which lists every record the index now holds, and to git rm -r --cached -- <path>, which touches no file. A gone entry is in no listing, so the sentence itself is what names it. git status compares the index with HEAD, not with the index before the command.
    • If the index cannot be read back, it says the mount was undone but could not be confirmed, and points to git ls-files -s as well.
    • A real state reaches it. An index entry Members/README is a variant of members/, not of the mount path, so ruling 1 lets it by. Under core.ignorecase it makes git stage the new gitlink as Members/IRQS, and the unwind used to leave it staged in silence.
  3. _entries, the rollback's per-name read, keeps its exact-name records.

Fix round on the review of 80050a9 (4302d85)

The review found it mergeable with nits.

  • SHOULD-1. A comparison of names alone passed every test. A restage-only step now pins the record comparison.
  • N1. The commit-first clause names both outcomes (above).
  • N2. The sentence names git ls-files -s, not git status.
  • N3. An unreadable index is "unconfirmed", not "undone only in part".
  • N4. Entries are counted per record again, as main did, so a conflicted exact-case entry gets main's text. A test pins it.
  • N5. A comment on the ASCII-only fold.
  • N6 is left. With three spellings held, "and 2 more under " scopes the count to one spelling. The remediation names all three and clears them.

Wording round on the re-check of 4302d85 (95a4e55)

The re-check found it mergeable with two wording nits. There is no logic change.

  • NIT-B. The commit-first clause is now true for every variant shape (above).
  • NIT-A. The comment's reason for git ls-files -s now says what that listing shows. The unreadable-index sentence points to it too.

Not in this PR (follow-ups)

Tests

Appended to tests/test_family_add_refusals.py, in their own functions:

Mutants, each against the new tests at 4302d85:

Mutant Result
Index read exact again 7 failed. On the issue's state the sentence then names five of the 19 "and 14 more"
Index comparison dropped 3 failed
Names compared, records not 1 failed. All 47 passed at 80050a9's tests
Prefix without / 1 failed
Adopt's --icase-pathspecs read 1 failed
Remediation spelled as the exact path 6 failed

Targeted run (tests/test_family_add_refusals.py tests/test_family.py tests/test_family_add_excludes.py): 170 passed, on Linux, Python 3.12, git 2.43.0, at 4302d85's bytes. After the wording round, tests/test_family_add_refusals.py gave 48 passed at 95a4e55's bytes. CI's three OSes are the gate.

Closes #302

🤖 Generated with Claude Code

Summary by Sourcery

Refuse case-variant mount-path conflicts before mounting and accurately describe any index changes left after rollback.

Bug Fixes:

  • Prevent family.py add from mounting when the holder index already contains the mount path or a case variant of it, avoiding unintended staged member files.
  • Report refused adds accurately when the full index changes or cannot be read back after rollback.

Enhancements:

  • Compare complete staged index records, including conflicted stages and restaged entries, when confirming rollback state.
  • Provide case-preserving remediation guidance for removing conflicting index paths and committing variant removals when necessary.

Tests:

  • Add coverage for case-insensitive index conflicts, literal path matching, rollback index differences, unreadable indexes, and conflicted entries.

…se, so an entry `Members/Atlas/README` is refused as `member-path-taken` before anything is cloned instead of leaving the member's 19 files staged under it, and a refused `add` says the holder is exactly as it was only when the whole index is as it was (#302)

THE DEFECT. With `core.ignorecase=true` and an index entry
`Members/Atlas/README` (nothing on disk), `add --member testorg/Atlas` on
c0ed615 mounted the member, and `git submodule add` staged its 19 files as
regular files under `Members/Atlas/`. The commit's pathspec `members/Atlas`
then matched nothing, and `MountRollback`, which reads the index by exact
name, put back six things and printed "the holder is exactly as it was
before this command" over an index that had gone from 16 entries to 35. A
plain `git commit` in the holder would have committed them. `member-path-
taken`'s index read (`_index_entries`, #242) was exact as well, so it never
saw the variant.

RULING 1, REFUSE BEFORE THE MOUNT. `_index_entries` now finds every entry
AT the mount path or UNDER it IN ANY CASE, on every disk, whatever
`core.ignorecase` says, as adopt's `_index_entries` does. A holder is cloned
on macOS and Windows, where `Members/Atlas` and `members/Atlas` are one
path. It is LITERAL TOO, and so asks git with no pathspec at all: it lists
the whole index (`_index_records`, `git ls-files -s -z`) and compares names
with ASCII case folded. Probed on git 2.43 in a scratch repository with a
`*` in the path:
- `--literal-pathspecs --icase-pathspecs` is fatal ("global 'literal'
  pathspec setting is incompatible with all other global pathspec
  settings");
- `--icase-pathspecs` alone is a glob: `members/A*` selected
  `members/Atlas/z` and `members/A*X/w`;
- `:(literal,icase)members/A*` selects the right three entries, but with
  `GIT_LITERAL_PATHSPECS=1` in the environment git does not parse the magic
  and matches nothing, exit 0, so the refusal would vanish without a word;
- the listing compared in Python selects the same three under every
  `GIT_*_PATHSPECS` setting;
- git's `icase` folds ASCII only (`members/ä` did not select `members/Ä`),
  and so does `bytes.lower()`.
The refusal names the entry as the index spells it, and its remediation
spells each variant the index holds, because `git rm -r --cached --
members/Atlas` does not match `Members/Atlas/...` even with
`core.ignorecase=true` ("pathspec 'members/Atlas' did not match any
files"). For a variant it adds one clause: where HEAD holds it too, commit
the removal first. The add's own commit names `members/Atlas` alone, so a
probe that skipped that commit ended with HEAD holding both
`Members/Atlas/notes.txt` and `members/Atlas`. For an exact-case entry the
add's commit carries the staged removal, and its text is unchanged, so
#242's rows pass as they were. `_index_entries` now returns one entry per
name, not one per stage.

ON DISK. A DIRECTORY `Members/Atlas` that is tracked is in the index and is
now refused. An UNTRACKED one on a case-sensitive disk still adds:
`members/Atlas` is mounted beside it, the commit holds only the gitlink,
and `Members/` stays untracked, so the pushed holder has no case twin for a
case-insensitive clone. On a case-insensitive disk the existing `lexists`
check already refuses it. Refusing it on Linux would take a case-folded
walk of the disk in the disk block, not a few lines in the index block, so
it is left as a follow-up.

RULING 2, THE CLOSING SENTENCE. `MountRollback` records the whole index
before the mount. When no step failed, `undo` compares it with the index
after its steps. When they agree, the sentence is today's. When they
differ, it says "Nothing was committed: the mount has been undone — ... —
but the holder's index is NOT as it was before this command (new in it:
...; gone from it: ...; staged differently: ...)", at most five names each.
It then sends the human to `git status` and names `git rm -r --cached --
<path>`, which touches no file, for a path that is new. An index that
cannot be read back after the unwind gets the partial sentence. A state
ruling 1 rightly lets by reaches this: an index entry `Members/README`, a
variant of `members/` and not of the mount path, makes git stage the new
gitlink as `Members/IRQS` under `core.ignorecase`. The commit fails on its
pathspec, and the unwind left that gitlink staged while printing "exactly
as it was". `git rm -r --cached -- Members/IRQS` puts the index back byte
for byte and touches no file.

RULING 3. `_entries`, the rollback's per-name read, keeps its exact-name
records: the whole-index comparison covers what it does not ask for.

TESTS, appended to `tests/test_family_add_refusals.py`:
- the issue's state, refused before anything is read, the index byte-
  identical (`git ls-files -s -z`) and the snapshot equal. `core.ignorecase`
  true and false, for a file under the mount path, a gitlink at it and a
  committed file under it. The remediation, run as printed, lets the same
  add through with the member under one spelling;
- `Members/README` under `core.ignorecase`: the refusal at the commit, the
  closing sentence naming `Members/IRQS`, the one new record being that
  gitlink, and the remediation restoring the snapshot. It asserts the step,
  not the commit command's spelling, which #299 changes;
- `MountRollback` at unit level: an unchanged index keeps "nothing in the
  holder had changed"; six new entries, one gone and one restaged are named
  with the cap of five; an index that cannot be read back gets the partial
  sentence;
- `_index_entries` at unit level: any case, not a prefix sibling, not a
  variant of `members/` alone, and `members/IRQ*` read as a name.

MUTANTS, each run against the new tests:
- the index read exact again: the six refusal rows and the unit selection
  test fail (7 failed). On the issue's state the sentence then names
  `Members/Atlas/.gitattributes` and four more "and 14 more" instead of
  saying "exactly as it was";
- the index comparison dropped: the ruling-2 test and both unit tests of
  the sentence fail (3 failed);
- the prefix compared without `/`, and adopt's `--icase-pathspecs` read: the
  unit selection test fails (1 failed each);
- the remediation spelled as the exact path: the six refusal rows fail (6
  failed).

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

sourcery-ai Bot commented Oct 12, 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 19 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR prevents family.py add from mounting when any case variant of the target path is already represented in the index, avoiding unintended staged member files, and strengthens rollback messaging by comparing the complete index before and after unwind. It implements both behaviors through whole-index NUL-delimited reads with Python-side literal case folding and adds focused regression and unit tests.

Sequence diagram for case-insensitive mount refusal

sequenceDiagram
    participant User
    participant FamilyAdd as family.py add
    participant Index as Git index
    participant Mount as Member mount

    User->>FamilyAdd: add(path)
    FamilyAdd->>Index: _index_records()
    Index-->>FamilyAdd: git ls-files -s -z
    FamilyAdd->>FamilyAdd: _index_entries(path)
    alt Case-folded path or descendant exists
        FamilyAdd-->>User: Refusal: member-path-taken
        FamilyAdd-->>User: git rm -r --cached -- <spelled paths>
    else No matching index entry
        FamilyAdd->>Mount: Mount member
        Mount-->>FamilyAdd: Mounted member
    end
Loading

File-Level Changes

Change Details Files
Make mount-path index checks case-insensitive, literal, and independent of Git pathspec behavior.
  • Read the complete index with git ls-files -s -z and perform ASCII case-folded matching in Python.
  • Reject entries at or beneath the mount path in any spelling while preserving the index’s original spelling and entry kind.
  • Generate remediation commands for every stored path variant, including a prerequisite commit when HEAD contains the variant.
scripts/family.py
Make rollback status claims reflect changes anywhere in the index.
  • Snapshot the entire index before mounting and compare it after rollback, including staged-record changes.
  • Report new, removed, and differently staged entries with a five-item-per-category cap.
  • Use a partial-failure message when the post-rollback index cannot be read, rather than claiming the holder was unchanged.
scripts/family.py
Add regression coverage for case-variant refusals and index-aware rollback reporting.
  • Test staged, gitlink, and committed case variants with both core.ignorecase settings, including byte-identical refusal checks and remediation.
  • Cover Git staging under an alternate spelling and verify the closing message identifies the leftover index entry.
  • Unit-test literal case-insensitive matching, difference categorization and truncation, and unreadable-index handling.
tests/test_family_add_refusals.py

Assessment against linked issues

Issue Objective Addressed Explanation
#302 Detect index entries at or beneath the mount path case-insensitively, regardless of the holder's core.ignorecase setting, and refuse the add before mounting or cloning. ✅
#302 Ensure a refused add does not falsely claim that the holder is unchanged: compare the complete index before and after rollback, and report any new, removed, or differently staged entries instead of saying it was exactly as it was. ✅
#302 Preserve correct handling and remediation of case-variant index entries, including naming the entry as spelled in the index and providing a cached-removal command that works for the variant. ✅

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

…s -s` rather than to a `git status` that cannot show a vanished staged entry, an index that cannot be read back is called unconfirmed rather than partly undone, the commit-first clause says what skipping it does under `core.ignorecase` too, and `member-path-taken` counts a conflicted entry once per stage again, as main did (#302)

The review of 80050a9 found it mergeable with nits: no MUST, one SHOULD and
six NITs. This commit answers the SHOULD and five of the NITs.

SHOULD-1, the record comparison is pinned. `_index_moved` calls the index
unchanged only when names AND records agree. No test told the two apart:
the unit test restaged `Makefile` in the same change that added six entries
and removed one. So a mutant comparing names alone passed all 47 tests and
said "nothing in the holder had changed" over a restaged entry. That false
claim is the one #302 exists to stop. The unit test now restages
`Makefile` and nothing else first, and asserts "staged differently:
`Makefile`".

N1, the reason for "commit that removal first". The clause said the add's
commit "would keep both spellings". That is so only with `core.ignorecase`
false. Probed with HEAD holding `Members/Atlas/README`, `git rm -r --cached`
run and the commit skipped:
- with `core.ignorecase` false, HEAD holds `Members/Atlas/README` and
  `160000 members/Atlas`;
- with it true, the macOS and Windows default, HEAD holds `160000
  Members/Atlas` alone, while `.gitmodules` and `family.yaml` say
  `members/Atlas`. `validate-family.py` passes both.
The clause now names both outcomes. The tool side of the second is follow-up
F2.

N2. "`git status` shows each difference" was false for an entry that was
staged, not in HEAD, and then vanished: status compares the index with HEAD
and prints nothing for it. The sentence now names `git ls-files -s`, which
lists what the index holds.

N3. An index that cannot be read back after the unwind was reported as "The
mount was undone only in part; NOT the index read back ...", though every
step may have succeeded. It now says the mount was undone, that the index
could not be read back to confirm it is as it was, with git's reason, and
to check it with `git status` before running `add` again.

N4. `_index_entries` returns one entry per index record again, as main's
pathspec read did. So a path conflicted at three stages is "and 2 more", as
it was, and an exact-case refusal is byte-identical to main's. A new unit
test pins that text. It passes with c0ed615's `family.py` and fails with
80050a9's.

N5. A comment says the fold is ASCII-only, as git's `icase` is, and that
APFS and NTFS fold more. No code change.

N6 is left as it was. With three spellings held, the detail's "and 2 more
under MEMBERS/ATLAS" scopes the count to the first spelling. Listing every
spelling there reads worse, and the remediation already names all three and
clears them.

Mutants, run in a scratch clone:
- names compared, records not (the review's R6): 47 passed at 80050a9's
  tests, and 1 failed (the restage-only step) at these;
- the earlier five, at these bytes: the index read exact again 7 failed,
  the comparison dropped 3, a prefix without `/` 1, adopt's glob read 1,
  the remediation as the exact path 6.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap and others added 2 commits October 12, 2026 05:06
…kipping it does for every case variant, and both closing sentences for an index that differs or cannot be read back point to `git ls-files -s`, with the reason stated as what that listing does show (#302)

The re-check of 4302d85 found it mergeable with two wording nits. No logic
changes here.

NIT-B, the commit-first clause. It said that skipping the commit would leave
HEAD with "both spellings", or, under `core.ignorecase`, with the gitlink
"under the other one". That is exact for a variant that differs only in
`members/`, or only in the member's name. For `MEMBERS/ATLAS/README` under
`core.ignorecase`, HEAD kept the variant's file AND recorded the gitlink as
`MEMBERS/Atlas`, a third spelling, probed again here. The clause now says:
HEAD keeps the other spelling beside the member, or, with `core.ignorecase`
set, records the member's gitlink under a spelling other than the
`members/<P>` that `.gitmodules` and `family.yaml` say, or both. That is
true of all four outcomes probed. The instruction to commit the removal
first is unchanged; it is right in every case.

NIT-A, `git ls-files -s`. The comment justified that pointer by saying
`git status` cannot show an entry that was staged and then vanished. But
no listing can show such an entry, so only the sentence's own "gone from
it" names it. The comment now says what is true:
- `git ls-files -s` lists every record the index holds now, so a new or
  restaged entry can be read there;
- a gone entry is named by the sentence;
- `git status` compares the index with HEAD, not with the index before the
  command.
The sentence for an index that cannot be read back pointed to `git status`.
It now points to `git ls-files -s`, consistent with the other sentence.

The tests that pin these sentences word for word are updated:
- the six case-variant refusal rows;
- the unreadable-index unit test.

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

Copy link
Copy Markdown

@brettheap
brettheap merged commit 6f4ce47 into main Oct 12, 2026
7 of 8 checks passed
@brettheap
brettheap deleted the fix/family-rollback-icase-index branch October 12, 2026 12:11
brettheap added a commit that referenced this pull request Oct 12, 2026
…mily-add-ghost-section, keeping both appended test blocks

Both reviews tested this union: main's #302 block in full, then #317's #300 block after it at the end of tests/test_family_add_refusals.py.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 12, 2026
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #253 via #264, #302 via #318, #288 via #309, #279 via #310, #306 via #311, #334 via #336, #239 via #269, #243 via #268, #321 via #335) into fix/adopt-record-submodule-placement

Brings in the twelve commits from c7c02fa to a1b3a4b on top of 71b58a1,
merged by sha. No rebase. Among the files this branch changes:
346079d (#216) and e43fa95 (#243) change adopt-project.py; 34ecbc2 (#260)
changes scripts/shape_materialize.py; 87373a6 (#306) changes
shape-doctor.py and tests/test_shape_doctor.py; 7491530 (#288) changes
AGENTS.md and tests/test_repo_hygiene.py.

Two conflicts, each resolved keeping both sides:

- adopt-project.py, the module docstring's `execute` paragraph. #243 ends
  "the root's only submodules are the two leg mounts, each at the commit
  pushed to its leg."; this branch follows "two leg mounts." with "The
  split's `project.yaml` records the leg each source submodule kept in a
  leg was answered into, under `submodules:`, for `shape-doctor.py` to
  check it against." The merge has #243's sentence, then this branch's,
  re-wrapped to the paragraph's width. The module docstring is not in
  `--help` (docs/cli.md holds neither sentence).
- tests/test_repo_hygiene.py, `test_agents_md_is_short_enough_to_be_read`.
  Each side moved the AGENTS.md cap from 464 and wrote a paragraph
  saying why: #288 to 477, for thirteen lines, and this branch to 472,
  for eight. AGENTS.md itself merged cleanly and holds both: 485 lines,
  main's 477 plus this branch's +9 -1. The merge keeps #288's paragraph
  word for word, then this branch's, renumbered "2026-10-12: 477 -> 485"
  because it now follows #288's, and asserts `<= 485`.

`git merge-tree --write-tree` of the two parents gives the same tree in
every file but these two.

Checked:

- adopt-project.py: `_commit_the_split` was changed by both sides, in
  hunks that do not touch. #243 clones the assembly with `--template=`;
  this branch builds `append` with the `submodules:` record. The merged
  function holds both, and each side's text is unchanged. Every other
  definition one side changed is that side's text.
- shape-doctor.py: #306 changes `validator_row` and `run_validator`.
  This branch's placement functions, F1's
  `gitmodules_the_record_answers` among them, are this branch's text.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py,
  scripts/shape_materialize.py, shape-doctor.py, validate-manifest.py,
  update-shape.py, scripts/family.py or scripts/bump-leg.py. Each merged
  module's names are the base's plus what either side added.
- AGENTS.md is main's plus exactly this branch's changed lines.
- The branch's diff against main is #189 alone: 12 files, +1694 -157. That
  is +1670 at 27d039b, plus F1's fix and test (+23), plus the one line
  the docstring re-wrap adds.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…rints the `old -> new` row and its trailers without checking the member out or rewriting `family.yaml`, and `remove --dry-run` prints the row, gitlink, `.gitmodules` section and directory it would remove and changes nothing, so the module's `EXIT CODES` line is true of both (#326) (#345)

`bump`, `add` and `remove` took no `--dry-run`, though the module's exit-code
line said "1 nothing (a dry run prints and exits 0)", which was true of `init`
and `siblings` only, and `bump-leg.py` has one. Seeing what a bump would pin
meant running it: fetch, `git checkout` in the member, rewrite `family.yaml`,
stage. Even #210's `nothing to do` cost the fetch and the checkout.

The coordinator's rulings, as implemented:

- `bump --dry-run` leaves after the fetch and before the checkout, the real
  run's first write, so every refusal made before that point is the same
  refusal, exit 2: the row, `--to`'s form, #315's `member-path` and
  `member-uninitialized`. It fetches, as the real run does. A fetch writes
  into the member's object store, which nothing the holder records, and the
  help and the docstring say so. After a failed fetch the closing line says
  `--to` was resolved from what the object store already held. `--to` is
  resolved as `git checkout` takes it (`^{commit}`), and a commit the member
  lacks is exit 2. The questions the real run asks after its checkout are
  also asked, of the member's objects: `project.yaml` through the same
  `_read_member_project` (`_member_id_at`), and `family.rewritten`. The
  digest is `tree_digest`, which reads objects, so it is the real run's. It
  prints `commit <old> -> <new>` and `tree`, `bump-leg.py`'s layout, then the
  gitlink `old -> new` and the whole row as `family.yaml` would carry it. The
  row comes from `_bumped_rows`, which the real run also calls. Exit 1
  follows the real run's #210 rule, asked without the write: HEAD's gitlink
  is `--to`, and the rewritten manifest hashes (`git hash-object --path`, no
  `-w`) to HEAD's blob. A symlinked `project.yaml` is left to the real run,
  and the dry run says so. A member's local changes that `git checkout` would
  refuse are not asked, and the docstring says so.
- `remove --dry-run` leaves after `member-path` and before `git submodule
  deinit`. `ls-files --error-unmatch` over the same literal pathspec fails
  where `deinit` fails, on git's same line. It is printed as a failed git
  command is printed, exit 2, never with `repo_shape`'s leg remediation; the
  dry runs ask git through `_git_bytes` for that reason. It asks
  `family-members-unrecognised` and prints the row, the gitlink, the
  `.gitmodules` section and the `.git/config` registration, and the
  directory. The section is named by git's own lookup over its work-tree
  reading of the file: a name keeps its last `path`; a name moving away
  takes the old path out of the path map; suspicious names are ignored.
  Every section of the name found is dropped. Where none is found it says
  none is removed, and it names the sections left that still give the path.
  The registration is printed only where the holder has one under that name.
  An unmerged `.gitmodules`, which only `git rm` refuses, is not asked.
- `--trailer` with `--dry-run` prints the trailers after the row, worded as
  `bump-leg.py` words them, and never for `nothing to do`.
- The `EXIT CODES` paragraph says which subcommands have a dry run, and new
  paragraphs say what each asks and what neither asks. `bump`'s and
  `remove`'s `--help` gained `--dry-run`, and `docs/cli.md` is regenerated.

`add` gets none here. Its dry run follows the three changes to `cmd_add` in
flight (#317, #318, lane 3's #330), and neither `cmd_add` nor any function
only it calls is touched. AGENTS.md is unchanged; a line in its family
section is a follow-up.

tests/test_family_dry_run.py, over `test_family.py`'s local-bare-remote
family, checks "changes nothing" as bytes: the index, `status`,
`family.yaml`, `.gitmodules`, `.git/config`, the member's HEAD, the work tree
and the holder's `.git/objects`. Then the real run writes exactly what the
dry run printed. Covered:
- a bump previewed and then made;
- `--to` the pin;
- both #210 repairs;
- trailers printed and then committed;
- six refusals compared stderr for stderr with the real run;
- a commit the member lacks;
- a failed fetch;
- `member-not-a-project` at `--to`;
- a linked manifest;
- a remove previewed and then made, registration included;
- a gitlink the index does not hold;
- four `.gitmodules` hand edits, each compared with a real remove;
- the unrecognised manifest;
- in process, no git command that writes (`hash-object -w` included).

Mutants, each killed by the test that must catch it:
- the dry-run return moved after the checkout (`member HEAD changed`);
- `write_members` before the return (`family.yaml changed`);
- remove's return after `git rm` (`index changed`);
- no `--error-unmatch` (exit 0 where 2);
- `hash-object -w`;
- a name keeping its first path;
- a suspicious name read like any other.

Part of #326

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
… of main's commits merge with no conflict, among them #243's template-less clone of the assembly and #216's literal `git grep` in adopt-project.py, and #260's one-pass `render` in shape_materialize.py

origin/main moved 71b58a1 -> 8cdca5e. It was merged by sha, as the
coordinator asked for e43fa95 or later:
- c7c02fa (#299 via #315), scripts/family.py;
- 346079d (#216 via #228), adopt-project.py: `plan`'s follow-up
  `git grep`;
- 34ecbc2 (#260 via #316), scripts/shape_materialize.py: `render`
  substitutes in one pass;
- 86da808 (#253 via #264), scripts/bump-leg.py;
- 6f4ce47 (#302 via #318), scripts/family.py;
- 7491530 (#288 via #309), AGENTS.md, README, docs and
  scaffold-project.py's --help;
- 7591455 (#279 via #310), tests/conftest.py: `git()` takes its default
  identity as `-c` config;
- 87373a6 (#306 via #311), shape-doctor.py;
- 8a14ac9 (#334 via #336), tests/test_update_shape_landing.py;
- c1b6732 (#239 via #269), update-shape.py;
- e43fa95 (#243 via #268), adopt-project.py: the assembly root is cloned
  with `--template=`, and the verification refuses a mount whose gitlink
  is not the commit pushed to its leg (`adopt-mount-commit`);
- a1b3a4b (#321 via #335), tests/test_git_output_decoding.py;
- 8cdca5e (#295 via #312), the template's scripts/validate-pins.py.

`git merge-tree` and this merge give the same tree, 07e6b49.
adopt-project.py, scripts/shape_materialize.py and
tests/test_adopt_ignored_shape_files.py auto-merge. In the last of these
main's 346079d and this branch's 86a5e30 change different cases.

Checked on the merged tree:
- AST (fin183g/names.py, base 71b58a1): no top-level name is bound twice
  in adopt-project.py, scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere.
  Main changed the signature of no function this branch calls.
- Every sentence 86a5e30 changed is still there: in `_through`, in
  `_link_parts`, in `_verify`'s `_resolve` (which e43fa95's
  `adopt-mount-commit` leaves as it was), and in the three test files.
- `_commit_the_split` now clones the assembly with `--template=`. The
  next commit, F3, reads what that clone holds in its own `.git`, which
  is why F3 comes after this merge.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…ut family.yaml before creating anything, `--dry-run` too, so `--id null`, `--tracking-branch true`, a blank `--created-by` and `--created-on 20261011` are refused (#307) (#397)

`family.py init` rendered `templates/family-root/family.yaml` from `--id`, `--org`, `--name`, `--created-by`, `--created-on` and `--tracking-branch` and pushed it. The holder reads that file with its own YAML reader and its own `scripts/validate-family.py`, and `init` asked neither anything. Every row of #307 reproduces on c7c02fa (the issue's own script, with a stub `gh`): `--dry-run` and the real run both exit 0 for all 15 rows, and

- `--created-by ' '`, `--created-on 20261011` / `2026-W41-6` / `tomorrow`, `--id null|true|false`, `--org null`, `--name 'Contoso Group'` leave a holder whose `validate-family.py` exits 1 from its first commit. `init` cannot be run again over it (`family-remote-exists`, no `--force`).
- `--tracking-branch true`, `007`, `null`, `2026` exit 0 with `validate-family.py` exit 0 (it has no rule about `tracking_branch`), the holder pushed on that branch, `family.yaml` reading back as `True`, `7`, `None`, `2026`, and `update-shape.py apply --branch` printing `gh pr create --base True` / `--base 7` / `--base main` (for `null`) / `--base 2026`.

**What changed.** `scripts/family.py` asks two questions about the manifest `init` is about to write, right after the substitution table is built: before the plan is printed, before the `--dry-run` return, before `_create_holder_remote` (which is where `gh repo create` runs against GitHub). Both refuse, name the flag, and say nothing was created.

- `family_manifest_misreads` renders each line of the TEMPLATE that carries one of the values, reads it back with the holder's own reader (`repo_shape.parse_yaml`) and refuses a value that does not come back as the same text: `family-manifest-misread`. This is what catches `--id null`, `--org null`, `--created-on 20261011` and the silent `--tracking-branch true|007|null|2026`. A line the refusal quotes is shown the way Python spells it where it holds a line break or an escape, so a value cannot forge a second line of the refusal.
- `family_manifest_invalid` renders the whole `family.yaml` and runs the validator the template ships over it, loaded from `templates/family-root/scripts/validate-family.py` (`Report` and `_check_envelope`), never re-implemented: `family-manifest-invalid`, in the validator's own words. This is what catches a blank `--created-by`, `--created-on 2026-W41-6` / `tomorrow` and `--name 'Contoso Group'`, and whatever the validator learns next. A misread is reported first and alone, because the validator's finding about it (`id is None`) is its consequence. A validator that is missing, broken, raising or exiting, whose `Report` has no `findings`, or that answers with a finding that is not text, is `family-validator-unusable`, not a traceback and not a zero.
- A value no flag supplied is named by where it came from (`--id (not given, so --family lowercased)`).
- `init --help` and `docs/cli.md` (regenerated, `render-cli-reference.py --check` ok) say what is held to what.

No template is edited. A refused value is refused, not quoted or corrected.

**Fix round 1 (the review of 14e2d68).** The first review found one broken test elsewhere in the suite and four claims no test guarded. This round is one commit on top of 14e2d68 (tests, plus one change to `scripts/family.py` for the validator that cannot be asked); the first commit is not amended.

- **`tests/test_stdout_encoding.py` (the blocker).** The `family-init-dry-run` row ran `init --name <non-ASCII> --dry-run` and asserted the name is printed in the plan. The holder's own `validate-family.py` refuses that name (`naming-unclassified`: a family holder is one CamelCase token), so `init` now refuses it before the plan prints. That is this PR working as intended, so the TEST changed and the refusal did not: the row drops `--name`. The plan still prints the non-ASCII `--created-by` on its `created by` line, so the constrained stdout still has a value to escape (`created by   日本 ...` under `cp1252`, the characters themselves under UTF-8), and the row still fails if `family.py main` stops calling `escape_what_stdout_cannot_encode`. On the first head the old row failed (`1 failed`); the row as it is now passes on this head, and on this head merged with origin/main.
- **A validator that cannot be asked.** The in-process call now sits in one `try` that also catches `SystemExit` and reads `report.findings` there, and a finding that is not text is refused by name. Three cases used to escape the refusal: a `Report` with no `findings` (an `AttributeError` traceback), a finding that is not a string (a `TypeError` from the regular expression) and a `_check_envelope` that calls `sys.exit(0)` (`init` exited 0, having printed and created nothing). Each is now `family-validator-unusable`, exit 2, nothing created; one test per case over a deliberately broken copy of the template, and one run at the command line for the exit.
- **`_line_shown`.** A value with a line break, an ESC and a would-be forged line (`x\nFINDING forged: y\x1b[2J`, and a CR, U+2028 and U+0085 variant) is one escaped sentence: asserted on `family_manifest_misreads` itself, through `main` and what it prints (in process, so no argv crosses to Windows), and at the command line (skipped on Windows before any call, since argv is UTF-16 there; the in-process rows hold it on every OS).
- **Two more guarded hunks.** The `sys.path` restore (also when the validator fails to load) and the comment-line skip each had a surviving mutant; each now has a test that kills it. The bytecode flag's restore is guarded by the same tests.
- **The bytecode test** asked the REAL template's `__pycache__` before and after, so any stale `validate-family*.pyc` there failed it whatever the code did. It runs over a copy of the template made without a `__pycache__`, with the flag cleared first so that it is `family_validator` that must set it, and a control shows that importing the same file that way does write a `.pyc`.

**How this composes with #259 (Brett ruled to quote the placeholders).** The misread check never says which lines are bare: it renders the real template line and reads it back. So once `fix/template-quoted-placeholders` (PR #395, first head 233dc71) quotes `id:`, `org:`, `created_on:`, `visibility:` and `tracking_branch:`, `--id null`, `--org null` and `--tracking-branch true|007|null|2026` read back exactly, the first question refuses nothing, and no edit is needed here. What the validator refuses stays refused: `--created-on 20261011` becomes `family-manifest-invalid` (`family-created-on`) instead of a misread, and a blank `--created-by` and `--name 'Contoso Group'` are unchanged. Proved three ways, all in `tests/test_family_init_values.py`:

- the same values through a copy of the template with the placeholder forced quoted: accepted, and `parse_yaml(render(...))` reads the exact text back;
- an end-to-end run of a forged one-commit checkout of `scripts/`, `templates/` and `contracts/` whose `family.yaml` is forced quoted: `init` exits 0, the holder's own `validate-family.py` exits 0, `family.yaml` reads back as typed, and the branch pushed is the typed one (a second forged checkout forced bare: refused, nothing created);
- the tests that read the real template's own quoting to say what to expect: run here with the real template as it is, and on a tree with #259's branch merged in (below), so the file passes whichever of this and #259 lands first.

**Once #259 is on main, 19 rows skip for good and one sentence goes unreached.** With the template quoted, the 16 end-to-end rows of `test_init_asks_the_holders_own_programs_before_anything_is_created` whose quoted answer is "the run is fine" and the 3 of `test_an_id_the_family_name_would_derive_is_named_by_where_it_came_from` skip with a message that says why (the 8 `test_a_quoted_value_is_held_as_typed` rows un-skip and pass), and nothing fails. `MANIFEST_MISREAD_REMEDIATION`'s "family.yaml writes a value like these unquoted" is then text no real template reaches; a template that writes one bare (the forged bare checkout the tests use) still does. Nothing is changed here for it: whichever of this and #259 lands second can drop the dead rows and reword that sentence in the same PR.

**Same helpers as #314 (#261)?** #314's `manifest_misreads` / `manifest_invalid` are hard-wired to the assembly root (`project.yaml`, its templated list, `validate-manifest.py`'s `_findings`) and were not on main when this PR was written, so this PR writes the smallest version the family needs, in `scripts/family.py` (not `shape_materialize.py`, which #266, #270, #314 and #316 all edit). They differ only in the template, the validator's entry point and the sentence that names the file. #314 landed first (3ef059c), and this PR leaves both sets of helpers as they are: **unifying them is left to a follow-up on #459's checklist (#459 (comment)

**Evidence, refreshed when this PR was opened (head abb27e6).** Main had then moved to 28bdc84: #345 landed as 3ae0109, #350 as 0f67f7b, and lane 3's `single-repository.yaml` as 28bdc84. The review's re-verify of abb27e6 ran on the head merged with 28bdc84: clean, with the six init, encoding, CLI-reference and family files at 305 passed / 9 skipped, and `test_family.py` with #345's `test_family_dry_run.py` at 126 passed. Merge-tree was also clean against #259's 3894aa8 (PR #395) and #314's 4b8da3d. The results below were taken before that; main has moved since.

- Reproduction, before and after, with the issue's script: on c7c02fa every row exits 0; on this branch all 13 failing rows now exit 2 on `--dry-run` and on the real run, with no holder, no remote directory and nothing pushed; the two controls (`--tracking-branch main`, `--name ContosoGroup`) are unchanged. `--tracking-branch 2026` is refused too, as #314 does for the scaffold: it worked only because every reader calls `str()` on the number.
- `tests/test_family_init_values.py`: 125 tests, 103 of them fail on c7c02fa; on this branch 117 pass and 8 skip (the 8 rows that need the real template to be quoted, which they run against the day it is). On 14e2d68, the first head, the 4 tests of this round that guard a code change are the ones that fail (the three validator cases and the exit at the command line); the rest guard what 14e2d68 already did.
- Mutations: fifteen of the change in the first round (drop the call, move it after the `--dry-run` return, move it after the remote is created, empty either question, ignore the template's quoting, ignore the template's validator, drop the not-given naming, drop a finding no flag owns, write bytecode into the template, let a validator's `SystemExit` through at load, ask the validator first, drop the unusable-validator checks), and eight more in this round, each killed by a new test: `_line_shown` always in backticks (9 fail), the `sys.path` restore dropped (2), the bytecode flag's restore dropped (2), the comment skip dropped (1), `findings` read outside the `try` (1), the non-text check dropped (1), `SystemExit` let through the run (2, one a zero at the command line), and `main` without `escape_what_stdout_cannot_encode` (the `family-init-dry-run` row of `test_stdout_encoding.py`). The write-bytecode mutation is now killed by the new bytecode test.
- Targeted runs on the head of this branch, abb27e6 (Linux, Python 3.12.3, git 2.43.0, git-filter-repo 2.47.0): `test_family_init_values.py test_family.py test_stdout_encoding.py test_cli_reference.py`: 276 passed, 9 skipped (the 8 rows that need a quoted template, and one Windows-only row).
- Merged with origin/main at 161c6c9 (which has #316, #317, #318, #245 and #349): `test_family_init_values.py test_family.py test_family_add_refusals.py test_family_add_excludes.py test_family_member_path.py test_stdout_encoding.py test_cli_reference.py` 407 passed, 9 skipped; `test_shape_doctor.py` 255 passed, 3 skipped (the PowerShell row, which needs a Windows host); `test_update_shape_add.py test_update_shape_pr_base.py test_template_values_single_pass.py test_scaffold_ignored_shape_files.py` 223 passed (with the git identity blanked, because the standard's own checkout sets a `user.name` that scaffold and adopt tests read). Main then moved to 0f67f7b (#350 landed, which changes the validator this reads, and a scaffold PR): `test_family_init_values.py test_stdout_encoding.py test_cli_reference.py test_validate_family_duplicates.py` on this branch merged with it: 197 passed, 9 skipped.
- Merge-tree (`git merge-tree --write-tree`), all clean: origin/main at 0f67f7b; #345 (abaaf3c, which landed as 3ae0109); #314 (4b8da3d); and `fix/template-quoted-placeholders` (3894aa8, #259). This branch + origin/main + #345 + #259's branch together are clean too, and `test_family_init_values.py test_family.py test_stdout_encoding.py test_cli_reference.py test_family_dry_run.py test_validate_family_duplicates.py test_template_text_placeholders.py` there: 442 passed, 20 skipped (the 19 rows above, which skip because #259 quotes the template, and one Windows-only row). #345 changes `cmd_bump`, `cmd_remove` and `build_parser`, not `cmd_init`; #350 (landed) added member-name checks to the validator and left `_check_envelope` and `Report`, the two names this change asks, as they are. #314 at 4b8da3d did not merge with origin/main by itself then (`adopt-project.py`, from what landed after its last merge), so it was not composed with the rest, but it was clean against this branch alone. #309, #312, #316, #317, #318 and #350, which the first round merged against or named, have landed.
- `--created-by '{{ORG}}'`: on c7c02fa `init` created the remote and then died with `template-unsubstituted` once the worktree was rendered. Merged with main, #316's one-pass `render` writes the value as typed: `init` exits 0, `family.yaml` records `created_by: "{{ORG}}"` and the holder's own `validate-family.py` is green. This change does not touch it and does not refuse it. (On this branch alone, without #316, the pre-flight render refuses it before anything exists, with the same `template-unsubstituted` text.)

**Left out.**

- `AGENTS.md`, `README.md`, `docs/handbook.html`: no prose change (#309 has landed since; `init --help` carries the sentence).
- A calendar check of `--created-on` (`2026-13-45` passes the validator's `DATE_RE`): the validator's rule, not copied here.
- A branch git itself refuses (`a..b`, `x.lock`): #283.
- `--org test+org` passes `validate-family.py`; not claimed.
- `--created-by` with an ESC or OSC is printed raw by the plan's `created by` line on c7c02fa and here: not part of this change.
- No run against GitHub (the `gh repo create` ordering is proved in-process, with every step that would reach GitHub replaced by one that fails the test), none on macOS or Windows.

Reviewed adversarially at 14e2d68 (CHANGES NEEDED: 1 blocker, 1 should-fix, 6 nits). The blocker was a test elsewhere in the suite that the new refusal turned red, `tests/test_stdout_encoding.py`'s `family-init-dry-run` row; the should-fix was the line-escaping hunk (`_line_shown`), which no test guarded. Fix round 1 (abb27e6) changed that test and not the refusal, guarded `_line_shown` and the two other surviving mutants, closed the three ways a broken validator escaped `family-validator-unusable`, and rewrote the bytecode test over a copy of the template. Its re-verification at abb27e6 found every finding closed: APPROVE WITH NITS (0 blockers, 0 should-fixes, 2 nits, both about this description and the commit trailer and not about the code: the trailer names Opus 5.5 below, and the description's merge-tree and composition paragraphs are refreshed above). CI is green on Linux, macOS and Windows, with SonarCloud passing, at d216ba8 (main 8782333 merged in).

Closes #307

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 13, 2026
… text placeholders, so `--id 007`, `--tracking-branch 2026`, `--spec-path true` or `--org null` read back as typed, and `family.py add` quotes a member id alike (#259) (#395)

Brett Heap ruled on 2026-10-12: "quote #259". The templates now write each text field a tool fills from a value it was given between double quotes:

| template | fields |
|---|---|
| `templates/assembly-root/project.yaml` | `id`, `elected_on`, `visibility`, `tracking_branch`, the spec leg's `path`, the code leg's `path` |
| `templates/assembly-root/contracts/spec-pin.yaml`, `code-pin.yaml` | `submodule_path` |
| `templates/family-root/family.yaml` | `id`, `org`, `created_on`, `visibility`, `tracking_branch` |

`render` already spells a value between a YAML template's double quotes with `repo_shape.double_quoted` (#284). The single-pass `render` from #316, on main as 34ecbc2, does the same through `YAML_PLACEHOLDER_RE`. So each value reads back exactly. An ordinary value renders as it did before, plus the two quote characters.

`family.yaml`'s `org:` is quoted along with the four fields #259 names. It is the same defect in the same file: `family.py init --org 2026` and `--org null` pass `checked_value`, and on c7c02fa the holder then fails `family-org` (#307 lists the `--org null` row). Taking `org:` back out is not a one-line revert. It fails 14 of the new tests (the 8 `family.yaml:org` in-memory cases, and all 6 `family.py init` rows, because every row of `FAMILIES` passes a misread `--org`), and it would also take the `ORG` row out of `TEXT_FIELDS` and put plain `--org` values into `FAMILIES`. The lane keeps `org:` quoted, as the same defect in the same file, unless Brett says otherwise.

`scripts/family.py`: `_member_lines` writes each member's own `project.yaml` id into the holder's `members:` block. That id now reads back exactly, so a bare `007` in the row came back as 7, and `validate-family.py` would report `member-id-mismatch` for a member it had just pinned. `_member_id` quotes an id only when the reader would not read the bare text back as the same text. No row whose id reads back as text moves: `irrs` is still `id: irrs`. Two edges:

- A legacy row whose bare id the reader misreads moves once. A holder written before this change, by `family.py add` of a member scaffolded with `--id 2026`, holds `id: 2026`. The next `bump`, `add` or `remove` of that holder rewrites it as `id: "2026"`. That is harmless: `validate-family.py` compares with `str()` and passes before and after.
- Mixed versions. A `family.py` from before this change, run against a holder this branch wrote, turns `id: "007"` back into `id: 007`, and `validate-family.py` then fails `member-id-mismatch` (the member declares `007`, `family.yaml` records `7`). A current `family.py` that rewrites the holder next then writes that row as `id: "7"`. Nothing in this branch can prevent it: run `family.py` from a current checkout on a holder that has such a member.

Left unquoted on purpose: `topic` (`xf-project-<id>`), every `repository` (`<org>/<Name>`; the slash keeps it text), `SHAPE_REPOSITORY` and `DIGEST_DEFINITION` (constants), and the block or list placeholders (`NEUTRAL_PRODUCT_PINS`, `*_NAMING`, `SHAPE_FILES`).

**Why.** Every reader of these files uses `repo_shape`'s YAML subset. It reads a bare `007` as 7, `2026` and `20261011` as numbers, `1.5` as a float, `true` as True and `null` as None. The checks on the flags (`PROJECT_ID_RE`, `checked_value`, the leg-path rule) accept every one of these. Reproduced on c7c02fa, with local bare remotes only:

- `scaffold-project.py --id 2026|007|true|null|20261011` exits 0, and the project's own `validate-manifest.py` then fails `manifest-id`.
- `--spec-path 2026 --code-path 007` fails `manifest-leg-path`, and `validate-pins.py` exits 2.
- `--tracking-branch 007` pushes each leg on `007`, and the root's `bootstrap.py` then puts the legs on a local `7` with `fatal: no upstream configured`. `true` and `null` do the same on `True` and `main`.
- `adopt-project.py execute` with `--tracking-branch 007` does the same; `--id 007|20261011` gives `manifest-id`; leg paths `2026`/`007` give `manifest-leg-path`.
- `family.py init --id 2026|007|true|null|20261011` gives `family-id`; `--org 2026|null` gives `family-org`; `--tracking-branch 007|true|null` reads back as 7/True/None.

On this branch every one of those rows reads back as typed, bootstraps onto the typed branch with `origin/<branch>` as upstream, and passes the root's own validators.

**Every reader checked.** Every reader reads these fields through `load_yaml`/`parse_yaml` into a dict. None uses a line regex, `split(":")`, `startswith("<field>: ")` or a whole-line rewrite of them:

- `templates/assembly-root/scripts/bootstrap.py`: `tracking_branch`, leg `path`.
- `validate-manifest.py`: `id`, `elected_on`, `visibility`, leg `path`.
- `validate-pins.py`: `submodule_path` against the manifest's `path`. The pin side is read as it stands and the manifest side goes through `str()`, so the two differ in type exactly when a bare value is misread (that is the `pin-path-mismatch` row above). With both lines quoted they are the same text.
- `validate-repository-naming.py`: `str(id)` for the topic.
- `templates/family-root/scripts/validate-family.py`: `id`, `org`, `created_on`, `visibility`, `str()` member-id compare.
- `siblings.py`, family `bootstrap.py`.
- `scripts/bump-leg.py`: `tracking`, leg `path`. Its `rewrite_pin` rewrites only `commit:` and `tree_sha256:`.
- `update-shape.py`: `tracking_branch_of`. `rewrite_manifest` touches only the `shape:` block.
- `scripts/family.py`: `tracking()`, the member's id, and the `members:` rewrite. The member-id writer is the one fixed here.
- `shape-doctor.py`: leg `path`, member row `path`, working-clone `str(id)` compare.
- `adopt-project.py`: plan reads and `_template_values`.
- `setup-project.py`: `family.yaml` `name`/`kind` only.
- `scripts/shape_advisory.py`: the single-repository record only.

The `str()` fallbacks (`bootstrap.py`, `adopt-project.py`'s `checked_value`) stay, because projects made before this keep their bare lines.

**Pins.** The four files are TEMPLATED, not COPIED, so no row of any project's `contracts/shape-pin.yaml` names them. `Atlas` was scaffolded and `family.py init Contoso` was run on c7c02fa and on this branch. Each `contracts/shape-pin.yaml` differs only in `commit:` and `digests.tree_sha256`, which identify the standard itself and change with every commit; every per-file `sha256` row is identical. `update-shape.py check` of the c7c02fa project and holder against this branch reports every copied file `unchanged` (11 and 10 rows): "no copied file differs; `apply` would move the pin alone".

**Tests.**

- New: `tests/test_template_text_placeholders.py`, 123 tests:
  - Each of the 13 fields is rendered with each of 8 values the reader misreads (`2026 007 1.5 true null 20261011 ~ False`), and must read back exactly.
  - 6 `scaffold-project.py` runs, then a recursive clone, `bootstrap.py`, the three validators, and `bump-leg.py`, which refuses on the tracking branch and finds the leg at its path on a branch of its own.
  - 6 `adopt-project.py` `plan`/`check`/`execute` runs into local bare remotes, then a clone, `bootstrap.py` and the validators.
  - 6 `family.py init` runs.
  - One `family.py add` of members whose ids are `007` and `true`.
  - **All 123 fail on c7c02fa.**
- `true`/`null` through adopt: `plan` writes them bare into the plan, and the plan's reader then misreads them. That belongs to the plan writer, #181 (lane 1). Those two rows quote the value in the plan by hand, as #181's writer will, and test `check`/`execute`.
- Mutation probes: reverting any one of the 13 quoted lines fails 8 cases of its field. Reverting `_member_id` fails the member test (the rows read 7/True).
- The `try/except YamlError` in `_member_id` is guarded by the follow-up commit. The review found that calling the parse directly left the member test passing. Ids such as `[`, `{`, `&a`, `*a` and `!t` reach the `except` (they raise `YamlError` bare), and `_member_id` returns each one exactly, as it does 34 adversarial inputs in all (the review's in-process probe). `test_a_member_id_the_reader_refuses_bare_is_rewritten_between_quotes` (7 cases: `[`, `{`, `&a`, `*a`, `!t`, an unclosed `'` and an unclosed `"`) opens a hand-edited holder whose row holds the id between double quotes, rewrites the `members:` block with `Family.rewritten`, and asks that the id read back exactly and the text come out byte for byte. With the `try/except` removed all 7 fail with the reader's `YamlError`, and with the `except` returning the bare text all 7 fail too; the old member test still passes under the first of those mutants.
- 14 existing tests that asserted or edited a bare rendered line now spell it quoted: `test_manifest_validator` ×5, `test_pin_validator`, `test_reference_default`, `test_scaffold_e2e` ×2, `test_setup_project_py`, `test_setup_sh`, `test_family`, `test_shape_doctor` ×2.
- Targeted runs on this branch:

  | files | result |
  |---|---|
  | new file + `test_template_quoted_values`, `test_scaffold_e2e`, `test_scaffold_pin_and_reuse`, `test_scaffold_leg_paths`, `test_scaffold_leg_path_collisions`, `test_reuse_visibility`, `test_reference_default` | 405 passed |
  | the six adopt files | 491 passed, 1 skipped (the MedxEHR clone is not set) |
  | `test_family`, `test_family_add_refusals`, `test_family_member_path`, `test_manifest_validator`, `test_pin_validator`, `test_bump_leg`, `test_update_shape`, `test_update_shape_pr_base`, `test_setup_project_py`, `test_park_resume_targets`, `test_repo_hygiene`, `test_cli_reference`, `test_setup_sh` | 603 passed |
  | `test_shape_doctor` | 241 passed, 3 skipped (Windows-only), 1 failed; that one test was fixed, and with its sibling it reran at 2 passed |

**The review.** An independent opus review at 233dc71 (the branch's first commit) found no code defect. Its verdict was CHANGES NEEDED (0 blockers, 2 should-fixes, 6 nits) because it asked for this description and its composition counts to be corrected, and they are. Its evidence:

- **Readers and rewriters.** Every reader in the shipped tree reads the 13 lines through `load_yaml`/`parse_yaml`, and no line regex or whole-line rewrite touches them. It read `bump-leg.py` `rewrite_pin`, `update-shape.py` `rewrite_shape_pin` and `rewrite_manifest`, `family.py` `rewritten`, the three validators, `siblings.py`, both `bootstrap.py`, the doctor, `setup-project.py`, the workflows, the Makefile and `setup.sh`. No test among main's 17 commits since c7c02fa (through #245's 161c6c9) edits one of these lines bare.
- **`_member_id`, the one rewriter changed.** A mutant that writes every id bare fails the member test (the rows read back as `{'IRRS': 7, 'IRSS': True}`). A mutant that quotes every id fails `b"    id: irqs" in lines`. So the minimal-churn choice is tested both ways.
- **Old projects and holders.** Against a control commit (c7c02fa plus an unrelated README edit), a project and a holder made on c7c02fa give identical `update-shape.py check` and `shape-doctor.py` output, up to the shas. `check --at 233dc71` reports all 11 project files and all 10 holder files `unchanged` and says `no copied file differs`. The doctor's verdict is `COMPLIANT, SHAPE BEHIND (... no copied file differs)` for the control and for 233dc71. This branch's `family.py add` and `bump` on a c7c02fa holder leave the old `atlas` row and the header byte for byte, apart from the legacy-id edge described at the top.
- **Tests.** All 123 new tests fail on c7c02fa (`123 failed`) and pass on 233dc71 (`123 passed`). The 14 changed existing tests pass (49 passed).
- **Not covered.** Windows and macOS were reviewed by reading and not run: the plan edit and the member-row check are in bytes, and nothing writes to git's stdin in text mode. Of `test_shape_doctor.py` and `test_family.py` the review ran only the changed tests.

**Follow-up commit.** A second commit, `3894aa8`, answers two nits from the review, and changes no behaviour. The `_member_id` docstring said "no existing holder's `members:` block moves by a byte", which was false: the next `bump`, `add` or `remove` of a holder written before this change rewrites a legacy `id: 2026` row as `id: "2026"`, harmlessly, because `validate-family.py` compares with `str()`. The docstring now says exactly that, and a grep of every comment and docstring the branch added for "by a byte" and "moves" found one more copy, in the member test's docstring, corrected the same way. The `try/except YamlError` in `_member_id` is guarded by a new test (see Tests), so the new file holds 130 tests. Every count above that says 123 (123 new tests, all failing on c7c02fa and passing on 233dc71) is for the first commit; the follow-up commit adds the 7. The `except` also carries a comment saying which ids reach it.

**Composition at the landing head (4984651).** `49846518` is GitHub's update-branch merge of origin/main `cc8f2da4` (#376, a `--trailer` value carried into the commit as typed) into `3220c9ee`. Git reported no conflict, and the merge has no hand edit. #376 changed `scripts/family.py` and `tests/test_family.py`, which this branch also changes. None of the lines it added under `tests/` names a key the templates now quote. The coordinator ran targeted tests on this head, Linux only, before CI:
- main's changed test files (`tests/test_commit_trailers.py`, `tests/test_family.py`, `tests/test_bump_leg.py`, `tests/test_update_shape.py`) with `tests/test_family_add_member_id.py`: 421 passed, 1 skipped (a case-insensitive disk only);
- this branch's template set (`tests/test_template_text_placeholders.py`, `tests/test_template_quoted_values.py`, `tests/test_scaffold_manifest_values.py`, `tests/test_bootstrap_spelling.py`, `tests/test_bootstrap_after_bump.py`) with the hygiene, CLI-reference and AGENTS.md-codes tests: 652 passed.

**Composition at 3220c9e.** `3220c9ee` is GitHub's update-branch merge of origin/main `70d0d1b3` into `e5e768bc`. It brings in three commits: #491 (da19735), in which `setup-project.py` asks at the person's own terminal; #374 (7ab11cf), update-shape's NEXT quoting; and #384 (70d0d1b), adopt's `_spelled` plan values. Git reported no conflict, and the merge has no hand edit. #491 changed `tests/test_setup_project_py.py` and `tests/test_setup_sh.py`, which this branch also changes. A grep of the lines main added under `tests/` for the keys the templates now quote found two hits, and neither reads a rendered template line: `tests/test_update_shape_next_quoting.py` writes its own `project.yaml`, and `tests/test_adopt_question_and_submodule_spelling.py` edits a plan's `- path:`/`question:` entry, which `adopt-project.py` writes bare.

The coordinator ran targeted tests on the same tree (`e5a74d19`), Linux only, before CI:
- six of main's eight changed test files (the setup, update-shape, `openRepoShape` command and doctor-row tests): 365 passed;
- main's other two (adopt's spelling and repo hygiene), `tests/test_adopt_github_exits.py` and nine more files of this branch's sixteen-file set: 943 passed.

**Composition, refreshed again 2026-10-13 (head e5e768b).** `6d108d18` merges origin/main `56713e21` into `6cbbac60`. That brings in two commits: 5e02abe, the doctor's and `validate-pins.py`'s gitlink repairs, and 56713e2 (#479), in which adopt's `plan` takes the tracking branch from the source's default branch. Git reported no conflict and `docs/cli.md` auto-merged; the merge has no hand edit. Two kinds of test failed on it, and two commits fix them:
- **`7968f902`** changes one expected literal, at `tests/test_adopt_github_exits.py:675`, for `test_a_source_on_another_branch_opens_its_pull_request_against_it[master]` and `[trunk]`.
  - The test reads the adopted root's `project.yaml`, which this branch's template renders as `tracking_branch: "master"`, so it now expects the quoted line.
  - The look-alike lines at `:743` and `tests/test_adopt_tracking_branch.py:73-76` read a plan, which `adopt-project.py` writes bare, and they stay as they are.
- **`e5e768bc`** adds `git("branch", branch, cwd=source)` to `tests/test_template_text_placeholders.py::test_an_adoption_reads_back_every_value_it_was_given`.
  - All six rows had failed at `check` with `plan-tracking-branch-missing`: #479 refuses a tracking branch (`2026`, `007`, `1.5`, `20261011`, `true`, `null`) the source does not have, and the test's source had only `main`.
  - The source now carries each row's branch, in the flag rows and the by-hand rows alike. The test's assertions are unchanged.

Targeted runs, Linux only:
- On e5e768b: main's seven changed test files, 211 passed; this branch's sixteen-file set, 1223 passed, 2 skipped.
- On 7968f90: the same sixteen files gave 6 failed, which are the six rows e5e768b fixes. The scaffold, setup, doctor and family set gave 1094 passed, 22 skipped, and twelve further template-grep files gave 1259 passed.
- `render-cli-reference.py --check` is ok.

**Composition, refreshed 2026-10-13 (head 6cbbac6).** `d651b594` merges origin/main `aa4e1f59` (#508) into `d526f768`.

- **The one conflict.** Git conflicted in one file, `tests/test_adopt_manifest_values.py`, in four hunks, all against #356 (965420b, #293/#294). #356 refuses an `elected_on:` that `election_date` does not read, as `election-date-malformed`, before the validator is asked (`_manifest_values` returns None, adopt-project.py:2258).
- **Resolved by what the merged tree prints**, measured with `adopt-project.py plan`/`check`/`execute` into local bare remotes:
  - `id: "007"` checks clean and `execute` writes it as typed, so it stays in `TEXT_THE_TEMPLATE_NOW_HOLDS`.
  - `visibility: "true"` gets the validator's `manifest-visibility: visibility is 'true'`.
  - `elected_on: "20261011"` gets #294's one finding, "the plan's `elected_on:` is the text '20261011'", and the validator is not asked. That row keeps this branch's list, `TEXT_THE_VALIDATOR_REFUSES`, with main's sentence.
  - Two tests git merged cleanly now ask `said_of`, as main's own rows do: `test_check_refuses_a_text_value_the_validator_refuses` and `test_where_a_misread_rule_judges_the_rest_the_validator_still_answers`.
- **Two more failures that no hunk showed**, fixed in the same merge:
  - `[branch-tilde]` in `tests/test_scaffold_manifest_values.py` was this branch's own `xfail(strict=True)` until #355 landed as 6c2a371, so it XPASSed. The mark is off, and the row asserts `REFUSED scaffold-bad-tracking-branch:`.
  - #391's `tests/test_bootstrap_spelling.py` and #490's `tests/test_bootstrap_after_bump.py` matched `    path: spec` and `tracking_branch: main` in a scaffolded `project.yaml`, and nine cases failed on the merge. They now match the quoted lines.
- **The #507 interplay.** `6cbbac60` switches the holder to `family/members` before the real `add`s in `test_a_member_whose_id_yaml_would_misread_is_pinned_under_that_id`, so the test holds once PR #507's `family-on-tracking-branch` refusal lands.
  - On the clean merge of 6cbbac6 with #507's head 708f3bb, that file, all 13 `tests/test_family*.py` and `tests/test_shape_doctor.py::test_a_sibling_at_the_right_origin_with_another_id_is_not_the_member` give 686 passed, 19 skipped.
  - Without the switch line, the member test fails `REFUSED family-on-tracking-branch`.
- **Targeted runs on 6cbbac6, Linux only:**
  - the template, adopt-plan and manifest files (14): 995 passed, 2 skipped;
  - scaffold e2e, setup, the doctor, leg-path collisions and every family file: 1094 passed, 22 skipped;
  - the 15 further files a grep for template lines found: 1503 passed;
  - `render-cli-reference.py --check`: ok.
- **Re-verified independently** at d651b59 and 6cbbac6: APPROVE WITH NITS (0/0/3), all three in this paragraph and fixed here. The re-verification also ran the three rows, five mutations, and 26 more files (834 passed; 291 passed, 1 skipped).
- **Not run locally:** the full suite, macOS and Windows; CI's three jobs are the check.

**Composition, as measured on 2026-10-12.** The merge trees below were computed for 233dc71; the follow-up commit 3894aa8 changes only `scripts/family.py` comments and adds tests. At 15:28Z main was 3ae0109 (#245, #351, #350 and #345 had landed since the merge trees): 3894aa8 merges clean with it, and the review's re-verify ran the family tests on that merge (61 passed). Every sha below is the one it had then; main and the other branches have moved since.

`git merge-tree --write-tree --name-only`, run after a fetch with automatic maintenance off on 2026-10-12:

- **origin/main 161c6c9**, 17 commits past c7c02fa (the base): clean, tree 7ba8246. #316 (34ecbc2), #317, #318, #311 and #245 were on main by then.
- **The 46 other remote branch heads** (the 30 open PR heads and 16 branches with no open PR): clean for 44. Among them #314 (4b8da3d), #266 (3410be7), #355 (a03abcf), lane 1's `fix/adopt-plan-quote-yaml-keywords` (d38e143), `fix/adopt-record-submodule-placement` (70f4d29) and `fix/adopt-late-failure-notice` (9f1778d), #380 (73068a3), and `fix/family-init-values` (14e2d68, #307). The two that conflict, each in the same file as it does with c7c02fa and with origin/main on its own: lane 1's `fix/adopt-root-kept-symlinks` (6c44591, `adopt-project.py`) and #358 `fix/doctor-manifest-kinds-unreadable-pin` (ebffb08, `shape-doctor.py`). This branch touches neither file.
- #314 and d38e143 each conflicted with origin/main on their own, in `adopt-project.py`. Merged with this branch, neither gained a conflict.

**Composition with #314, which lands before this branch.** Its misread gate (`manifest_misreads`) reads the template, so once the template quotes a field it refuses nothing there, and some of #314's tests expected that refusal.

Composing with #314 (landed as 3ef059c), whose misread gate asks the template how a value is written. With `project.yaml` quoting `id`, `elected_on`, `visibility` and `tracking_branch`, the gate refuses nothing on the shipped templates, and 45 of #314's tests flipped on the merge. Forty expected a refusal the value no longer needs. Thirty-four of them now assert that the value is accepted, and the six whose premise was a bare placeholder now assert that no shipped template placeholder is misread. For example: a scaffold of `--id null|true|false|007|2026`, or of `--tracking-branch true|True|TRUE|false|False|null|Null|NULL|007|2026|1.5|1.50|0`, exits 0, and the manifest holds the text it was given. A dry run prints the plan. An id derived from `--project True|Null` is held as typed. `--id=null` reaches `gh repo create`. Adopt's `id: "007"` checks clean and `execute` writes it as typed. The rule's own sentence, remediation and line-break wording are still asserted, on a copy of the standard whose `project.yaml` writes the four lines bare. Four stay refused by #314's validator gate, now in its words about the text: `visibility is 'true'` and `elected_on is '20261011'`. One is a real break. `--tracking-branch=~` reads back as `~`, which git refuses as a branch name, and it dies at `git init -b ~`, after `gh repo create`. That is #283, so the row is `xfail(strict=True)` with that reason, and it turns XPASS (and fails) once PR #355's `git check-ref-format --branch` refusal lands. `--help` for `--id` and `--tracking-branch` no longer says such values are refused, and `docs/cli.md` is regenerated.

**Composition with the other branches.** Where another branch's rules meet this one, the tests were run on the merged tree. Quoting changes what some of their tests see, and the changes are not all one kind: some tests expected a refusal the value no longer needs, some are still refused under another rule, and some guard a case quoting does not fix. Whoever lands second must tell them apart, so each branch's flips are classified below.

- **#266** (lane 2, `fix/leg-path-yaml-scalars`). 27 flips, all from one cause: its rule asks a hard-coded bare line, `LEG_PATH_LINE = "path: {}\n"`, so it refuses leg paths the quoted templates now read back. 15 are `test_the_rule_agrees_with_every_template_line_that_holds_a_leg_path` (null, Null, NULL, ~, true, True, TRUE, false, False, FALSE, 2026, 007, 0, 1.5, 1.50) and 12 are this branch's own end-to-end tests (6 scaffold rows fail with `REFUSED scaffold-bad-leg-path`, 6 adopt rows with `FINDING plan-bad-leg-path`). Counted on the merge with b2b42f8. #266's head was then 3410be7, whose rule reads each template line that holds a leg path, renders the value into it as `render` does and asks the reader, so it has no hard-coded line left. The merge with this branch was clean (tree 1e348c6); that head was read and the 27 were not re-run on it. At b2b42f8, whichever of #266 and #259 landed second had to drop or re-point the bare rule; 3410be7 re-points it already.
- **Lane 1's #181** (`fix/adopt-plan-quote-yaml-keywords`, d38e143). Its gate reads the template too, so `plan --tracking-branch 2026|007|true` exits 0 on the composed tree (exit 2 `adopt-manifest-misread` on d38e143). On the merge, `tests/test_adopt_plan_yaml_quoting.py` and this branch's 6 adopt end-to-end tests give 70 failed, 371 passed:
  - 57 refusals that are now unnecessary: `plan_refuses` ×14, `plan_names_an_id` ×2, `check_names_an_id` ×4, `check_reports` ×14 and `execute_refuses` ×14 (every value except `elected_on` and `visibility`), `text_the_loader_reads_as_other_text` ×7, and the two tests that need a bare placeholder (`test_the_gate_follows_the_loader_when_the_loader_learns_a_keyword`, `test_the_template_line_says_whether_a_value_is_quoted`).
  - 11 guards #181 loses. See "Do not flip".
  - 2 that fail only by cascade: `execute_refuses[pins='null']` and `[pins='True']` pass alone (`2 passed`). An earlier case no longer refuses, and its `execute` pushes the split branch.
  - The writer half of #181 (`y` asking the reader, so `true`/`null`/`True` are quoted in the plan) is still needed and composes cleanly. With it, `plan --tracking-branch true` reaches the quoted template intact, and this branch's two hand-quoted adopt rows become flag-driven.
- **Do not flip these 12 to "accepted".** Each still needs a refusal or a finding, and quoting does not supply it:
  - **`~` (1, #314).** `test_a_value_the_reader_misreads_is_refused_before_anything_is_made[branch-tilde]`. `--tracking-branch=~` is not a branch name git accepts, and #314 refused it only as a misread. On the merge it passes the plan and dies at `git init -q --bare -b ~ .../Atlas-spec.git` with `fatal: invalid initial branch name: '~'`, and against GitHub that is after `gh repo create` has made all three repositories. That is lane 2's #283 (PR #355). `~` must stay refused, by #283's ref-name check, which is why #314's row for it is not flipped to "accepted".
  - **A hand-edited plan value that is not text (7, #181).** `test_check_names_a_plan_value_that_is_not_text_by_what_was_read[tracking_branch='007']`, `[tracking_branch='TRUE']`, `[tracking_branch='1.50']`, `[tracking_branch='[a]']`, `[visibility='{a: b}']`, `[id='007']` and `[elected_on='20260902']`. The plan's reader reads each as something other than text, and on the merge `check` passes. Measured on the merge: `tracking_branch: 007` and `id: 007` are adopted silently as `"7"` with every validator passing; `visibility: {a: b}` pushes both legs and then fails `manifest-visibility`; `tracking_branch: [a]` is still refused at `execute` (`unsafe-value`) before any leg exists. On d38e143 alone, `check` exits 1 `plan-manifest-misread` for each of those four. Re-point the gate at "a plan value that is not a string" and these keep their guard.
  - **A quoted value the validator refuses (4, #181).** `test_check_reports_a_plan_carrying_one[elected_on='20260902']` and `[visibility='true']`, and `test_execute_refuses_a_plan_carrying_one_before_any_leg_exists[elected_on='20260902']` and `[visibility='true']`. The value stays invalid for the validator (`DATE_RE`, the visibility choices), and #181's misread gate caught it only incidentally. On the composed tree `check` passes and `execute` pushes the legs, and the gate fails afterwards. That is what main does today, and #314's validator gate (`plan-manifest-invalid`) would catch it.
- **main** (merge with ab623dd, tree cdeec7f, run by the review): this branch's new file, `test_adopt_template_values.py` (#270), `test_template_values_single_pass.py` (#316) and `test_template_quoted_values.py` give 431 passed, 1 skipped. The skip is `test_adopt_template_values.py:223`, which reads `git config user.name` and skips where the shared repository's `.git/config` sets one; it is environmental. The changed tests with `test_family_add_refusals.py` (#317, #318), `test_pin_validator.py` and `test_manifest_validator.py` give 132 passed. #270's `unwritable_reason` is value-based, not template-relative, so it refuses as it did and nothing flips. Main then added #245 (`adopt-project.py` and `repo_shape.remote_key`), which adds no test that edits a bare quoted line; it was not run on that merge.
- **#380** (lane 3, `fix/pin-rewrite-indentation`; it landed since as 1f90a0f). It rewrites `commit:` and `tree_sha256:` only, and its tests render the pins from the templates. On the merge at dc0fa7b, `test_pin_rewrite_layout.py` and the 6 scaffold end-to-end tests gave 121 passed. Its head was then 73068a3; that merge was clean and was not re-run.
- **#397** (`fix/family-init-values`, #307; measured at 14e2d68): its tests skip themselves where the template quotes. 137 passed, 19 skipped, 0 failed. With its fix-round head abb27e6, this branch's 3894aa8, main 0f67f7b and #345 together: 442 passed, 20 skipped (the 19 rows, and one Windows-only row).
- **#345** (run at 875d802; it has since landed as 3ae0109): `test_family_dry_run.py` and the member test, 22 passed. Its `"    id: irrs"` assertion holds, because ordinary ids stay bare.

**Left out.**

- The adoption plan writer `y` still writes `true`/`null` bare into the plan. That is #181 (lane 1).
- `templates/assembly-root/contracts/neutral-product-pin.yaml` still writes `product`, `source_repository` and `digest_source` bare, and `family.py` writes a member row's `project:` bare. Neither is a #259 field.
- A plan value someone edits to a non-text type (`tracking_branch: 007` or `id: 007`, unquoted, in the plan) is read by the plan's reader as the number 7. `str()` makes it `"7"`, and the quoted template now writes it faithfully. For `id` this is a change: on c7c02fa a bare `id: 007` failed loudly (`validate-manifest: manifest-id`, manifest line `id: 7`), and on this branch it is adopted silently as id `"7"` and every validator passes. `tracking_branch: 007` was already adopted silently as `7`. The plan side is not this branch's: the guard against it belongs with #181 (adopt plan writes YAML keywords and non-text values unquoted), whose gate should refuse a plan value that is not a string. The case is narrow, because `plan --id 007` itself writes `id: "007"`; only a hand edit reaches it.
- No docs change: README, AGENTS.md, the handbook and the template comments show none of these lines; `docs/cli.md` is unchanged because no `--help` text changed.

Reviewed adversarially at 233dc71 (CHANGES NEEDED: 0 blockers, 2 should-fixes, 6 nits; the code and the tests were sound, and the should-fixes were the composition counts and the claim about a hand-edited plan `id`). Fix round 3894aa8 changes no behaviour: it corrects the description and the two false "moves by a byte" claims, and guards `_member_id`'s `except YamlError` with a test of 7 cases. Its re-verification at 3894aa8 found every finding handled: APPROVE WITH NITS (0 blockers, 0 should-fixes, 1 nit: shas that had moved since the description was written, refreshed here). What was added after that re-verification, main and the composition with #314, is described under "Composition with #314". An independent re-verification of that composition at 77cd0b2 confirmed it with nits only (0 blockers, 0 should-fixes, 5 nits): the 45 flips reproduce test for test, breaking the real rule or the real template's quoting turns the compose tests red, and the `[branch-tilde]` row XPASSes on a merge with #355.

**Composition with #381.** #395's quoted `id: "{{PROJECT_ID}}"` composes with #381's `member-duplicate-id` test (`tests/test_family_add_member_id.py`), whose id rewrite now matches the id bare or quoted and keeps its quoting, so the full suite, which failed that one test on Linux at 1ef3310, no longer fails `test_a_member_whose_id_changes_between_the_probe_and_the_mount_is_unwound` on Linux.

CI is green on Linux, macOS and Windows, with SonarCloud passing, at 4984651 (main cc8f2da merged in by update-branch). Main af01068 (#505), which landed after, touches none of its files, and the combined tree passed the template, hygiene, CLI-reference, AGENTS.md-codes and setup tests (807 passed).

Closes #259

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 15, 2026
… under another spelling of `members/`, never for that directory, and asks the disk whether a `Members` is `members/` itself, so the printed remedy no longer moves every member's checkout on macOS and Windows, fails on an entry with nothing on disk, or takes three rounds for #318's tracked variant, and a `members/` renamed by hand on Linux is no longer let through (#362, #363, #364)

The fix round for the review of 40eda84 (one blocker, three should, five
nits) and Codex's P2 at scripts/family.py:2099, under Brett Heap's ruling
of 2026-10-14, "Refuse on every disk".

B1. On macOS and Windows a `Members/` and `members/` are one directory,
holding the members' checkouts, and the printed `git mv -- Members <new
name>` / `mv -- Members <new name>` moved them all. `_spelled_target` now
targets the entry directly under the other spelling (`Members/README`,
`Members/docs`, `Members/IRQS`), or the full entry where that name folds to
one `members/` itself tracks; never the spelling's directory.
`_members_dir_on_disk` asks the disk which spelling IS `members/`: `members`
where listed, else a directory under another spelling for which
`os.path.isdir(root/'members')` and `os.path.samefile` agree. That alias is
treated as `members/` itself: an exact `<P>` in it is `already exists`
(unless the index holds it under that spelling), a case variant `<p>` is
targeted as `<spelling>/<p>`, and an index entry that IS that directory is
never renamed.

S1 (Codex's P2). Rule (b), which hid a `Members` holding a directory named
like a tracked member, is gone: only the confirmed alias is `members/`. On
Linux a `members/` renamed `Members` with nothing staged (a member's
checkout in it, `<spelling>/<name>/.git` with the gitlink `members/<name>`
in the index) is refused and moved back (`mv -- Members members`); a
human's own `Members/` is named entry by entry.

S2. An index entry with nothing on disk in its own spelling
(`_on_disk_as_named` reads exact listings, so a folding disk never answers
for another entry) is taken out of the index with `git rm -r --cached`, and
that removal is committed only where HEAD holds it, or will once the
pending change is committed. An entry a sparse checkout keeps off disk
(`git ls-files -t`'s `S`) takes `git mv --sparse`.

S3. #318's index check keeps its text where every variant of the mount path
it holds is index-only. A variant that is on disk goes to the new refusal,
one committed `git mv` in one round. #318's pinned test is unchanged.

N1. A disk path the index holds, holds anything above, or (for a move out of
the way) holds anything under, is not named on disk, so no two remedies act
on one path; an empty directory under another spelling, which a `git mv` of
its last entry leaves, is not named.

N4. `family-members-dir-variant` says "a path at or under another spelling
of members/, which a case-insensitive disk takes for members/ itself", true
of a file named `Members` too.

Tests: tests/test_family_members_case_variants.py types every printed remedy
as printed and adds the member after it; a layout table asks
`_checked_mount_path` of a disk that folds case and of one that does not,
both emulated below the holder only (N5), on every OS; new tests kill the
review's U2, U3 and U4. #318's unit test in test_family_add_refusals.py
pins the per-entry targets.

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 15, 2026
…`Members` link, every remedy the index takes, a file at `Members/IRQS` and a gitlink `Members` over a human's directory, and the move back is asked of a listing handed in, so ten conditions of `add`'s case-variant refusal that no test caught are now each guarded (#362, #363, #364)

The re-review of 8a95345 (rvcase2, APPROVE WITH NITS) found ten
mutations of scripts/family.py that left all 57 tests passing (NIT-1,
probes Q1-Q7 and Q9-Q11). No product code changes here.

- Q1, the alias may be a link: a row with `members/` gone and a link
  `Members` to a directory outside the holder. A disk that folds case
  resolves `members` through it, and it must still be named as the link
  it is. The row skips on Windows, before the link is made.
- Q2-Q5, the index's remedies: one row with three committed entries under
  `Members/`: a gitlink `X` with its directory on disk (`git mv`; Q3), a
  file `README` where the disk holds a directory (not on disk as named,
  so `git rm -r --cached`; Q2), and `docs/a` kept off disk by
  `skip-worktree` (`git mv --sparse` of the entry itself, not of
  `Members/docs`; Q4). HEAD holds the removed entry, so the text says
  "and commit both" (Q5).
- Q10: a gitlink `Members` over a human's `Members/notes.txt`; on Linux the
  file under it is the index's to name, never a second time on disk.
- Q11: a file `Members/IRQS`, in the index and on disk; #318's index-only
  text must not take it.
- Q6, Q7, Q9, the move back of a renamed `members/`: two spellings side by
  side exist only on a disk that does not fold case, and no test makes
  them, so `_disk_spellings` is asked of a listing handed in: once only,
  never where `members/` is on disk, never for a `.git` no gitlink names.

All twelve of rvcase2's probes now fail at least one test.

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 15, 2026
…, any path under another spelling of `members/`, in its index, in HEAD or on disk, and names a remedy per entry that never moves the members' checkouts (#362, #363, #364) (#574)

Brett Heap ruled on 2026-10-14, "Refuse on every disk": refuse before reading the member, name the entries and a committed `git mv` as the remedy, use one rule for the index, HEAD and the disk, and give the same answer on Linux, macOS and Windows, as #318's any-case rule for the mount path does. The cost he accepted: a Linux holder with its own `Members/` folder renames it first.

What went wrong before, all reproduced on bfed143:

- **#362, the index.** With `core.ignorecase` set, an index entry `Members/README` made git stage every new gitlink as `Members/<P>`. The commit, whose pathspec is `members/<P>`, matched nothing, and the remedy the refusal printed failed the same way every time. With `core.ignorecase` unset, the add committed and left `Members/` beside the member, ready to break the next clone on macOS or Windows.
- **#363, HEAD.** HEAD held `Members/README.md`, and an uncommitted `git rm -r Members` had taken it out of the index. `add` then committed the gitlink as `Members/<P>` with exit 0, while `.gitmodules` and `family.yaml` said `members/<P>`. A fresh clone left the member empty and failed `member-gitlink-absent`.
- **#364, the disk.** `_checked_mount_path` checked the disk with an exact `os.path.lexists`. An untracked `Members/<P>` was refused on macOS and Windows, and on Linux the member was mounted beside it.

What `add` asks now, in `_checked_mount_path`, before it clones, fetches or mounts anything, and before it prints anything about the member:

1. **Which directory is `members/` on this disk (`_members_dir_on_disk`).** The holder's root is listed for every spelling of `members`, with ASCII case folded. `members` is `members/` where it is listed. Where it is not, a directory under another spelling is `members/` itself only when the disk says so: `os.path.isdir(root/'members')` is true and `os.path.samefile(root/'members', root/<spelling>)` names the same directory. That happens on macOS and Windows, never on Linux, and never for a link.
2. **The mount path in that directory, by listing.** An exact `<P>` there gets the same `already exists` refusal as before, word for word. Under another spelling that is `members/` itself, the exception is a `<P>` the index holds under another spelling; the next two questions name that.
3. **#318's any-case index check of the mount path.** Its text and `git rm -r --cached` remedy are unchanged where every variant it holds has nothing on disk. A variant that is on disk goes to the next question instead, which renames it.
4. **`_members_spellings`.** It looks in three places, and gives each path the remedy that acts on it:
   - every index entry under a spelling of `members/` other than `members/` itself, and every case variant of the mount path;
   - every file and gitlink HEAD holds there that the index no longer holds (`git ls-tree` of HEAD's root tree, then of each matching entry's object, with no pathspec; an unborn HEAD holds nothing);
   - what the disk holds there and no index entry covers.
5. **`lexists` of the mount path**, still asked, last, for a directory no listing could read.

The refusal (`_spellings_refusal`):

- It names each path in its own spelling and says where it was found: `in the index`, `in HEAD and no longer in the index`, or `on disk and untracked`. Each place names at most five paths, then a count.
- **Every remedy acts on one entry, never on another spelling's directory.** On macOS and Windows `Members/` and `members/` are one directory, holding the members' checkouts, so a rename of `Members` would move them all. The entry is the one directly under the other spelling (`Members/README`, `Members/docs`, `Members/<P>`), or the spelling itself where it is a file or a link. Where that entry's name folds to one `members/` itself tracks (`Members/irrs` beside `members/IRRS`), it is the full entry instead.
- The remedies run in this order:
  1. what HEAD holds and the index no longer does: commit the pending change first (`git commit`);
  2. what the index holds:
     - on disk in its own spelling: `git mv -- <entry> <new name>`;
     - kept off disk by a sparse checkout: `git mv --sparse -- <entry> <new name>`;
     - nothing on disk: `git rm -r --cached -- <entry>...`, which touches no file;
     - then a commit, where there is something to commit;
  3. what is on disk and untracked: `mv -- <entry> <new name>`;
  4. a `members/` renamed by hand on Linux: `mv -- Members members`.
- Every path in a command is quoted for a POSIX shell (`shell_quoted(..., 'posix')`, #532).
- The holder stays byte-identical. The tests compare the snapshot of `tests/test_family_add_refusals.py` and the whole index, byte for byte.

Example: #364's state on Linux, an untracked `Members/Atlas` in a holder that has `members/Nova`:

```
REFUSED member-path-taken: the family root holds a case variant of members/Atlas, the same path on a case-insensitive disk, though no `members:` row names it — on disk and untracked: `Members/Atlas` (a directory)
Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Move what is on disk and untracked out of the way (`mv -- Members/Atlas <new name>`); then re-run.
```

On macOS and Windows the same `mkdir -p Members/Atlas` lands inside `members/`, which is then the mount path itself. The refusal there is the one it always was, with the same code:

```
REFUSED member-path-taken: members/Atlas already exists in the family root but no `members:` row names it
Remediation: this is a half-finished add or a hand-made directory. Remove it (`git rm -r` if it is tracked), then re-run.
```

Example: #362 on a real disk, a committed `Members/README` beside `members/Nova`. In a fresh clone this text is the same on all three systems. A macOS or Windows holder that PULLED the commit keeps the directory spelled `members`, and gets `git rm -r --cached` instead (a checklist candidate below):

```
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/README` (a file)
Remediation: every member is mounted under members/, and a holder keeps that directory in one spelling on every disk. Rename what the index holds with `git mv`, entry by entry (`git mv -- Members/README <new name>`), and commit the rename; then re-run.
```

Head `8a95345b` is one new commit on `40eda849`. The review graded one blocker, three should and five nits; Codex added a P2.

- **B1 (blocker): the printed `git mv -- Members` / `mv -- Members` moved every member's checkout on macOS and Windows.** Every remedy now acts on one entry under the other spelling, never on that directory (`_spelled_target`). `_members_dir_on_disk` asks the disk which directory is `members/` (`isdir` + `samefile`). Where it is spelled `Members`, it is treated as `members/` itself: an exact `<P>` in it is `already exists`, a case variant `<p>` is targeted as `Members/<p>`, and an index entry that IS that directory is never renamed. Scenarios `macsingle-mac` and `r318mac-mac` type the new remedy and show `Members/Nova/.git` still in place; `test_the_printed_git_mv_leaves_the_members_where_they_are` does the same in the suite.
- **S1, with Codex's P2 at `scripts/family.py:2099`: rule (b) hid a real Linux variant.** Rule (b) is gone; only the alias that `isdir` + `samefile` confirm is `members/`. On Linux, Codex's state (`members/` renamed `Members`, nothing staged) is refused, and its remedy moves the directory back (`mv -- Members members`). The sign is a member's checkout in it (`Members/<name>/.git`, with the gitlink `members/<name>` in the index) and no `members/` on disk. A human's own `Members/` holding a directory named like a member (`ruleb`) is named entry by entry. `tests/test_family_members_case_variants.py:359-371`, which pinned Codex's case as "not named", is replaced. The layout table asks the same states of an emulated folding disk and an emulated Linux disk on every OS, and `test_a_members_directory_renamed_by_hand_is_moved_back` asserts the answer of the disk the test runs on.
- **S2: `git mv` failed for an index entry with nothing on disk (`bad source`), and for a sparse one (`not under version control`).** Such an entry is now taken out of the index (`git rm -r --cached -- <entry>`, touching no file). The commit after it is printed only where HEAD holds the entry, or will once the pending change is committed. An entry the sparse checkout keeps off disk gets `git mv --sparse -- <entry> <new name>`, which keeps its content. Scenarios `idxonly` and `sparsemv` type each.
- **S3: #318's tracked `Members/<P>/...` took three refusals.** #318's index check now keeps its text only where every variant of the mount path it holds has nothing on disk; its pinned test is unchanged. A variant that is on disk goes to the new refusal: one committed `git mv -- Members/<P> <new name>`, one round (scenario `r318`; `test_a_tracked_path_on_disk_takes_one_committed_git_mv[Members/IRQS/README]`).
- **N1: two remedies collided (`mv: cannot stat 'Members'`).** A disk path the index holds, holds anything above, or (for a move out of the way) holds anything under, is not named on disk. Per-entry targets cannot collide: `git mv -- Members/README` and `mv -- Members/Atlas` now run in order (scenario `overlap`).
- **N2: U2, U3 and U4 survived.** `test_a_spelling_that_is_a_link_is_named_and_not_entered` kills U2 (a link entered, probe P19) and U3 (no `a symlink`, P20). `test_a_name_head_holds_that_is_not_utf8_is_shown_replaced` kills U4 (P14).
- **N3: the "#364's state on any OS" example and the docstring at `:278-280`.** The example above now says Linux, and shows the macOS/Windows text beside it. The test that docstring belonged to is replaced by two: one for the states every disk answers alike, and one that asserts per disk (`test_an_untracked_members_directory_is_moved_entry_by_entry_on_linux`).
- **N4: "hold as one directory with it" for a FILE named `Members`.** The sentence is now "a path at or under another spelling of members/, which a case-insensitive disk takes for members/ itself", true of a file too.
- **N5: `folding_lexists` walked from `/`.** It is replaced by `disk_as`, which resolves names below the holder root only and passes every other path through unchanged.

Head `e5aac93f` adds one commit on `8a95345b`. It changes only `tests/test_family_members_case_variants.py` (+102); no product code changes. The re-review (rvcase2) said APPROVE WITH NITS: every point above is fixed, every scenario ends clean as typed, and five nits remain.

- **NIT-1: ten conditions no test caught.** rvcase2's probes Q1-Q7 and Q9-Q11 each left all 57 tests passing. Four new rows in the layout table, each asked of both disks, and one new unit test now guard them:
  - a link `Members` to a directory outside the holder, `members/` gone (Q1, the alias may be a link). A folding disk resolves `members` through the link, and it must still be named as a link. This is now caught on every OS by the emulated folding disk, not by macOS CI alone. The row skips on Windows, before the link is made.
  - three committed entries under `Members/` (Q2-Q5): a gitlink `X` with its directory on disk (`git mv`); a file `README` where the disk holds a directory (`git rm -r --cached`); and `docs/a`, kept off disk by `skip-worktree` (`git mv --sparse` of the entry itself). HEAD holds the removed entry, so the text says "and commit both".
  - a file at `Members/IRQS`, in the index and on disk (Q11): #318's index-only text must not take it.
  - a gitlink `Members` over a human's `Members/notes.txt` (Q10): on Linux, the file under that entry is the index's to name.
  - the move back of a renamed `members/` (Q6, Q7, Q9). This needs two spellings side by side, which only a disk that does not fold case can hold, and no test makes them. So `_disk_spellings` is asked of a listing handed in, with nothing read from a disk: the move back happens once only, never where `members/` is on disk, and never for a `.git` that no gitlink names.
- **rvcase2's twelve probes (`rvcase2/mut2.py`), re-run against these tests** with `-x`, the new file plus #318's three tests. Every probe now fails a test:

  | probe | result | the first test that fails |
  |---|---|---|
  | Q1 alias may be a link | `1 failed, 46 passed` | layout `a link Members, no members/-folding` |
  | Q2 on-disk kind ignored | `1 failed, 47 passed` | layout `and every remedy the index takes-linux` |
  | Q3 gitlink not asked for a directory | `1 failed, 47 passed` | the same row |
  | Q4 sparse entry keeps its group target | `1 failed, 47 passed` | the same row |
  | Q5 "commit both" wording | `1 failed, 47 passed` | the same row |
  | Q6 move back more than once | `1 failed, 53 passed` | `test_only_a_spelling_that_is_members_renamed_is_moved_back[once, though two spellings hold checkouts]` |
  | Q7 move back where `members/` is on disk | `1 failed, 54 passed` | `...[never where members/ is on disk]` |
  | Q8 move back without a `.git` | `1 failed, 39 passed` | layout `a human's own Members/, no members/-linux` |
  | Q9 move back without the gitlink | `1 failed, 55 passed` | `...[never for a .git no gitlink names]` |
  | Q10 a path under an index entry named | `1 failed, 51 passed` | layout `a gitlink Members over a human's Members/-linux` |
  | Q11 every mount variant taken as a directory | `1 failed, 49 passed` | layout `and a file Members/IRQS in the index and on disk-linux` |
  | Q12 no case for a bare `Members` | `1 failed, 4 passed` | `test_an_index_entry_with_nothing_on_disk_is_taken_out_of_the_index[Members-Members-true]` |

- **Tests at `e5aac93f`:** the new file (60 tests) and `tests/test_family_add_refusals.py` (65): `125 passed in 105.50s`.
- **NIT-5, this body:** P4's row now reads "7 of the 9 folding rows"; the #362 example says its text holds in a fresh clone; and the merge checks are refreshed.
- **NIT-2, NIT-3 and NIT-4** are checklist candidates below.

- **The new code is `family-members-dir-variant`**, used where every path found is under a spelling of `members/` alone. Codes about the holder as a whole start with `family-` (`family-index-locked`, `family-on-tracking-branch`, `family-members-unrecognised`). `members-dir` is `family.yaml`'s own name for that directory (`members_dir`). `variant` is the word #318's refusal and the validator use. `member-path-taken` stays wherever any path is a case variant of the mount path `members/<P>`, as #318 and #364 have it.
- **The alias is asked, never guessed.** `isdir` and `samefile` are what the disk says. On Linux `members` is simply not there when only `Members` is (Codex's state), and with two directories `samefile` is false. A Linux `members` symlink is listed as `members`, so it is never the alias. A Linux `casefold` directory answers as macOS does, which is right.
- **Per entry, not per file.** The remedy names the entry directly under the other spelling, so `Members/docs/` with 300 files is one `git mv`. A `git mv` of a directory is one `rename(2)`, so its untracked contents go with it, and a mount variant `Members/<P>` is cleared in one step on every disk. The full entry is used only where the name folds to something `members/` itself tracks.
- **"On disk" means in its own spelling (`_on_disk_as_named`).** It reads listings component by component, exactly. On macOS `lexists("Members")` is true because `members/` exists, and `git mv -- Members X` there would move `members/`. With exact listings an index file `Members` beside `members/` is taken out of the index instead, on every disk.
- **An empty directory under another spelling is not named.** `git mv` and `rm` of its last entry leave one behind on Linux; it holds nothing a clone or a disk could meet. A directory nobody may list is named and moved whole, since nobody can tell it is empty.
- **A `members/` renamed by hand is moved back (Linux).** The sign is a member's checkout in it: `<spelling>/<name>/.git`, with the gitlink `members/<name>` in the index, and no `members/` on disk. A human's own `Members/` holding a directory named like a member, with no checkout in it, is named entry by entry instead.
- **The commit after `git rm --cached` is printed only where there is one to make:** where HEAD holds the entry, or will once the pending change is committed. A staged-only entry, #362's own planted state, leaves nothing to commit, and a printed `git commit` would fail with `nothing to commit`.
- **`git mv --sparse` needs git 2.34 or later**, the release that made `git mv` and `git rm` refuse paths outside the sparse checkout. Its test skips on an older git.
- **#318's unit test** (`test_the_index_is_asked_at_the_mount_path_in_any_case_and_literally`) pinned "a variant of `members/` alone: not refused, by design". Its docstring cites Brett Heap's ruling of 2026-10-14 ("Refuse on every disk"). It asserts that `_members_spellings` names each entry with its target and remedy.
- **#318's end-to-end test of the unwind's closing sentence** runs in process with `_members_spellings` switched off, so the unwind's guard behind the refusal stays tested.
- **`add --dry-run` refuses alike**, because the check runs before the dry run branches off.

All runs used `run9.sh`: a `gh` stub first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`, `/usr/bin/python3 -m pytest`, and `PYTHONDONTWRITEBYTECODE=1`.

- `tests/test_family_members_case_variants.py` held 49 tests at `8a95345b` (60 at `e5aac93f`; see Round 3). Run against `40eda849`'s `family.py`, with #318's unit test beside them: `40 failed, 10 passed in 60.95s`. The 10 that pass are behaviour round 1 already had right: the controls (2); HEAD's mount variant (2); `members/irqs` untracked (1 end-to-end, 2 in the layout table); the unlistable `members/` backstop; and the folding-disk answers for Codex's state and a human's own `Members/`, which round 1's rule (b) hid on every disk.
- At head `8a95345b`, the new file, `test_family_add_refusals.py`, `test_repo_hygiene.py` and `test_cli_reference.py`: `189 passed in 180.78s`.
- On the merge of `8a95345b` with origin/main `64fc9a86` (main has rewritten `test_family*.py`'s clone sites since this branch's base), the new file, `test_family_add_refusals.py`, `test_family_member_path.py`, `test_family.py`, `test_repo_hygiene.py` and `test_cli_reference.py`: `338 passed in 353.46s`.
- Round 1's two batches, on the bytes of `8a95345b` before a two-line docstring correction:
  - batch A plus `test_family_add_refusals.py`, 14 files (test_family_members_case_variants, test_family_add_refusals, test_family_member_path, test_family_add_excludes, test_family_member_name, test_family_add_member_id, test_family_dry_run, test_family_tracking_branch, test_family_printed_quoting, test_family_bump_remove_rollback, test_family_bump_to_a_tag, test_family_init_landing, test_refusal_default_remediation, test_template_text_placeholders): `561 passed in 862.50s`;
  - batch B, 9 files (test_family, test_validate_family_member_path_glob, test_validate_family_unnamed_mounts, test_validate_family_duplicates, test_validate_family_without_git, test_update_shape_pr_base, test_cli_reference, test_repo_hygiene, test_shape_doctor): `671 passed, 3 skipped in 1228.01s`. The 3 skips are the Windows-host PowerShell lines in test_shape_doctor.

What the tests cover, and how each stays portable across all three systems:

- **Every printed remedy is typed as printed** (`type_remedy`), and the same `add` then pins the member under `members/` alone, with a clean `git status`.
- **Index and HEAD states** are planted with `update-index --cacheinfo`, under `core.ignorecase` true and false. A non-UTF-8 name goes in through `--index-info` with bytes on stdin, and is committed by `write-tree` and `commit-tree`, which never look for it on disk.
- **Disk states** use a holder with no member yet, or the holder's own `members/` renamed in place. No test makes two directories whose names differ only in case.
- **Where the disk decides the answer**, the test either asks the disk it runs on (`disk_folds`, one probe directory) and asserts that disk's answer, or runs in process under `disk_as`, which emulates a folding disk and a Linux disk below the holder root. Both answers are then asserted on every OS.
- **Skips** come before any call that could fail: links on Windows; an unlistable directory on Windows, as root, or on a folding disk; `git mv --sparse` on git older than 2.34.
- **Windows text mode:** files are written with `write_bytes`, and git's stdin is fed bytes.

The review's states (`$Q/v110/rvcase/scen.sh`), rebuilt on a fresh kit (local bare remotes, a `gh` that exits 97, called 0 times). After each refusal, every printed command was typed as printed. Only `<new name>` was filled in: `renamed-N` for a `git mv` (committed, so it stays in the holder) and a path outside the holder for a `mv`. "Linux" is this host. "macOS/Windows" is the same layout judged in process with `isdir`, `samefile` and `lexists` folding case below the holder root.

| scenario | `40eda849` | `8a95345b` |
|---|---|---|
| `macsingle` (Linux) | `git mv -- Members renamed-1` moves Nova: ` D members/Nova`, `member-uninitialized` | `git mv -- Members/README`, commit, `mv -- Members members`: add exit 0, status clean, `family ok` |
| `macsingle` (macOS/Windows) | `git mv -- Members ...` (moves Nova there) | only `git mv -- Members/README <new name>`: Nova's checkout stays in `Members/` with its `.git`; the check then passes |
| `macleft` (Linux) | `mv -- Members ...` moves Nova: ` D members/Nova` | `mv -- Members/Atlas ...`, then `mv -- Members members`: exit 0, clean, `family ok` |
| `macleft` (macOS/Windows) | `mv -- Members ...` | `members/Atlas already exists`, as `linuxleft`; removing it passes the check |
| `linuxleft` | `already exists` | unchanged; removed as it says: exit 0, clean, `family ok` |
| `codex` | add exit 0, holder split (` D members/Nova`, `?? Members/`) | refused; `mv -- Members members`: exit 0, clean, `family ok` |
| `ruleb` | add exit 0, `?? Members/` left | refused; `mv -- Members/Nova ...` and `mv -- Members/README.md ...`: exit 0. ` D members/Nova` stays, because the scenario deleted Nova's checkout; `git submodule update --init` (the validator's own remedy) gives clean and `family ok` |
| `ruleb2` | `mv -- Members ...` | `mv -- Members/README.md ...`: exit 0; Nova as in `ruleb` |
| `idxonly` | `git mv` fails `bad source` | `git rm -r --cached -- Members/README` (nothing to commit): exit 0, clean, `family ok` |
| `sparsemv` | `git mv` fails `not under version control` | `git mv --sparse -- Members/README ...`, commit: exit 0, clean, `family ok` |
| `overlap` | second command fails `mv: cannot stat 'Members'` | `git mv -- Members/README`, commit, `mv -- Members/Atlas`: exit 0, clean, `family ok` |
| `r318` | 3 rounds; leaves `?? Members/Atlas/` | 1 round, `git mv -- Members/Atlas`, commit: exit 0, clean, `family ok`, HEAD holds `renamed-1/README` |
| `r318mac` (Linux) | ends moving Nova | `git mv -- Members/Atlas`, commit, `mv -- Members members`: exit 0, clean, `family ok` |
| `r318mac` (macOS/Windows) | `git rm --cached`, then `mv -- Members` | `git mv -- Members/Atlas`: Nova stays; the check passes |
| `linux362` | `git mv -- Members`: exit 0 | `git mv -- Members/README`, commit: exit 0, clean, `family ok` |
| `twodirs` | `mv -- Members ...` | `mv -- Members/Nova ...`: exit 0, clean, `family ok` |
| `mac-ok` (the control) | exit 0 | exit 0, clean, `family ok` |
| #363 (`i363`) | `git commit`, then exit 0 | `git commit`, the text now naming `Members/README.md` rather than `Members`: exit 0, clean, `family ok` |
| #364 (`i364`) | `mv -- Members ...` | `mv -- Members/Atlas ...`: exit 0, clean, `family ok` |

Every refused first `add` printed nothing on stdout. Not reproduced: a real macOS or Windows disk. The folding disk is emulated (`core.ignorecase`, and `isdir`, `samefile` and `lexists` resolved case-insensitively below the holder), and CI's macOS and Windows jobs run the tests on real disks.

Each probe changed one hunk of this round in a throwaway worktree, then ran the new file (49 tests) and #318's three tests in `test_family_add_refusals.py` (8 cases), 57 in all. At least one test failed every time:

| probe | what it undoes | result | a test that fails |
|---|---|---|---|
| P1 | per-entry targets (back to the spelling's directory) | 19 failed | `test_the_printed_git_mv_leaves_the_members_where_they_are` |
| P2 | the full entry under a name `members/` tracks | 2 failed | `test_an_index_entry_with_nothing_on_disk_is_taken_out_of_the_index[Members/irrs/a-...]` |
| P3 | an unlistable directory read as empty | 1 failed | `test_another_spelling_nobody_may_list_is_named_and_moved_whole` |
| P4 | the alias (`isdir` + `samefile`) | 8 failed | 7 of the 9 folding rows then in the layout table, and `test_the_printed_git_mv_leaves_the_members_where_they_are` |
| P5 | "on disk" by exact listings (back to `lexists`) | 1 failed | layout `a file Members in the index, beside members/-folding` |
| P6 | `git mv --sparse` for a skip-worktree entry | 1 failed | `test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse` |
| P7 | another spelling's directory named entry by entry | 13 failed | `test_a_tracked_path_on_disk_takes_one_committed_git_mv[Members/README]` |
| P8 | an empty directory not named | 6 failed | `test_a_tracked_file_deleted_from_disk_is_taken_out_and_committed` |
| P9 | the move back of a renamed `members/` | 6 failed | `test_a_members_directory_renamed_by_hand_is_moved_back` |
| P10 | the alias's own entries judged as `members/`'s | 6 failed | layout `members/ renamed Members (Codex's P2)-folding` |
| P11 | never renaming the alias itself | 1 failed | layout `and a gitlink named Members in the index-folding` |
| P12 | the move back kept when the index holds things under it | 2 failed | layout `and Members/README in the index (#362, B1)-linux` |
| P13 | a disk path dropped when the index holds things under it | 3 failed | `test_a_tracked_path_on_disk_takes_one_committed_git_mv[Members/docs/notes]` |
| P14 | U4: a HEAD-only name shown with U+FFFD | 1 failed | `test_a_name_head_holds_that_is_not_utf8_is_shown_replaced` |
| P15 | no commit printed after removing a staged-only entry | 12 failed | `test_an_index_entry_with_nothing_on_disk_is_taken_out_of_the_index[...]` |
| P16 | the commit after a removal once the pending change is committed | 1 failed | `test_every_place_is_named_and_the_pending_change_is_committed_first` |
| P17 | `already exists` in the alias only where the index holds no variant | 1 failed | layout `and Members/IRQS/README in the index (#318)-folding` |
| P18 | #318's text only for variants with nothing on disk (S3) | 3 failed | `test_a_tracked_path_on_disk_takes_one_committed_git_mv[Members/IRQS/README]` |
| P19 | U2: a link not entered | 1 failed | `test_a_spelling_that_is_a_link_is_named_and_not_entered` |
| P20 | U3: `a symlink` named | 1 failed | `test_a_spelling_that_is_a_link_is_named_and_not_entered` |
| P21 | N4's wording | 29 failed | every refusal naming `family-members-dir-variant` |

The other hunks are docstrings, which change no behaviour.

`git merge-tree --write-tree 8a95345 <ref>`, after a fetch at round 3. Every result is clean. Round 3's commit changes only `tests/test_family_members_case_variants.py`, which no ref below touches.

- origin/main `f5fc7056166d` (round 2 checked 64fc9a8 and 7d9bec9, both clean): clean. Since 64fc9a8, main has not changed `tests/test_family.py`, `tests/conftest.py`, `tests/test_family_add_refusals.py` or any script these tests load, so round 2's run on the merge with 64fc9a8 still holds for them. #561 has landed, so it is no longer checked as a PR head.
- #568 `fix/family-bump-to-checks` `8024f9c914fd`: clean.
- #571 `fix/family-remove-gitmodules-missing` `ebe6b512db02`: clean.
- #576 `fix/link-source-chain-defaults` `a707d4ba3f54`: clean.
- #578 `fix/family-siblings-tracking-branch` `0112dcb63391`: clean.
- Lane 2's `fix/537-dry-run-asks-remotes` `69c58aae2a04` and `fix/539-validators-lockstep-remedies` `0680799c7b21`: clean.

The tests on the merge with origin/main are in the Tests section.

- **#363 option 2, a check of the commit after the fact** (`git ls-tree HEAD` must hold the gitlink at exactly `members/<P>`, else undo). The ruling refuses before the mount, so nothing here needs it. It would still catch a cause nobody has foreseen.
- **#363 item 3, the validator option:** `validate-family.py` would say that a gitlink in HEAD at a case variant of a row's path is why the gitlink is absent. That is a pinned template file, and lane 2's #539 is editing its remedies.
- **Docs.** `git grep` of AGENTS.md, README.md, `docs/handbook.html` and `templates/family-root/` (README, AGENTS.md, AGENTS-shape.md) found no sentence about `add`'s refusals or about `members/` versus `Members/`, so none became false. AGENTS.md is at its 490-line cap. `templates/family-root/AGENTS-shape.md` is digest-pinned into every holder.
- **`docs/release-notes/v1.0.0.md`** lists #362, #363 and #364 as v1.0.0's known limits. That is still true of v1.0.0, so the file is untouched.
- **Contrived states, not handled:** a gitlink named `Members` in a Linux holder whose `members/` was also renamed `Members`; Windows' trailing-dot and 8.3 aliases of `members`; NFD names. The case-folding is ASCII, as git's `icase` is, and project names are ASCII.

- **`family.py add`: check the commit after the fact (#363 option 2).** After `_commit_one`, read `git ls-tree HEAD` for a gitlink at exactly `members/<P>`. If it is anywhere else, undo this command's commit and the mount, and refuse with the spelling git chose. This catches any cause of a misplaced gitlink, not only the case variants refused here.
- **`validate-family.py`: say a HEAD gitlink is at a case variant of the row's path (#363 item 3).** A holder already pushed in #363's state is told `member-gitlink-absent` in a fresh clone, with nothing about the `Members/<P>` that HEAD holds. Reading HEAD's gitlinks folded for case would name the cause.
- **`validate-family.py`: the `member-gitlink-unnamed` remedy for a hand mount under `Members/`.** The remedy says `git rm` the gitlink, then `family.py add` it. Under a spelling of `members/` other than `members/`, `add` now refuses until that `git rm` is committed, and says so. The validator's sentence could say "commit the removal" before the `add`. Lane 2's #539 is editing these remedies.
- **Docs: name the one-spelling rule for `members/`.** AGENTS.md's family section, the README's family section and the holder's `AGENTS-shape.md` could say that a holder keeps `members/` in one spelling, and that `add` refuses `family-members-dir-variant` until a human renames their `Members/`. AGENTS.md is at its line cap.
- **`family.py add`: a file named `members`, or a sparse checkout that leaves `members/` out, is refused late** (rvcase's `mfile` and `sparse`). Both pass the checks, the member is read and mounted, and `git submodule add` then fails; the unwind restores the holder. Both could be refused up front, the second naming `git sparse-checkout add members`.

- **`family.py add`: a member's own gitlink renamed with `git mv members Members` (NIT-2).** Staged or committed, the gitlink sits at `Members/<M>`, and the refusal names it with the generic `git mv -- Members/<M> <new name>`. Typed with any name, the add then passes and the validator says `member-gitlink-absent`. The right name (`members/<M>`) fails as typed until `mkdir members` (`$Q/v110/rvcase2/scen-gitmv.log`, `stagedmv`, `committedmv`). The holder's own validator already refuses that state before the add. A refusal that named a gitlink `<spelling>/<M>` whose `members/<M>` is a `members:` row could print `mkdir members` and the move back.
- **`family.py add`: a macOS or Windows holder that PULLED a commit adding `Members/README` (NIT-3).** There the directory stays spelled `members` and the file lands at `members/README`. Since exact listings do not find `Members/README`, the remedy is `git rm -r --cached`, not the ruling's committed `git mv`, and the file is left untracked at `members/README` (`$Q/v110/rvcase2/scen-head.log`, `macpulled-mac`, emulated). It is benign: committing that file afterwards still gives `family ok`. Folding the lookup for targets below the spelling, keeping the exact rule only for a bare `Members`, would print the `git mv` there too.
- **`git mv --sparse` needs git 2.34 or later (NIT-4).** The estate states no minimum git (`README.md:215`, `README.md:365`, `docs/handbook.html:617`), and `scripts/shape_materialize.py` keeps a fallback for git older than 2.32. On such a git the sparse remedy fails with an unknown option; nothing is damaged (`$Q/v110/rvcase2-report.md`, NIT-4). The text could say "git 2.34 or later", or fall back to `git rm -r --cached` there.

Review: rvcase found 1 blocker, 3 should and 5 nits on 40eda84, and Codex a P2; fix round 8a95345 fixed them all; re-review rvcase2 found 5 nits, fixed in round 3 (e5aac93, tests only), and Codex's `:2099` thread is answered. CI (Brett Heap's ruling 27): H0 e5aac93 passed tests, tests-macos, tests-windows and SonarCloud; the head 4c21ce2 is a clean update-branch merge of main 40b476d, with tests and SonarCloud green, and the local gate passed on its predicted squash (the family, validate-family and hygiene sets: 1050 passed, 19 skipped, over 66267f0). Landed on Brett Heap's word of 2026-10-15T13:32:47Z, "All five, once green".

Closes #362
Closes #363
Closes #364

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 with `git rm --cached` and a commit, and `add`, `bump` and `remove` refuse a `family.yaml` holding a change HEAD does not, staged or not, both runs alike (#684)

Finishes the WIP commit 7015386 (#684's E2, E5, E3, N4 and E1's staged half) with the rest of the issue and the scope Brett Heap added.

E1's OTHER HALF. A tracked `members` FILE deleted from disk, the deletion not staged, passed every `add` check: the mount's `git submodule add`, putting a directory where the file was, took the entry out of the index, and the commit took `D members` into itself. `_members_file_left_in_head` now refuses it too, as `member-path-unaddable`, before the member is read and under `--dry-run` alike. The remedy is the house #318 one for an index entry with nothing on disk: take it out of the index, which touches no file (`git rm --cached -- members`), and commit the removal on its own first. The issue's `git mv` fails there (`fatal: bad source`, exit 128, measured), and `git checkout -- members` first would put the file back over a `members/` directory a hand made there and take the directory away (measured); the lane's coordinator ruled the remedy for that reason. A link on disk is left to git's own dry run.

A DIRTY `family.yaml` (#459's line from #571's review, comment 6192725913, on Brett Heap's word "refuse a dirty `family.yaml`", relayed by lane 5 on #473 at 2026-10-16T13:56:50Z). `add`, `bump` and `remove` rewrite `family.yaml` as the work tree has it and commit it with `git commit -- <paths>`, so a comment line edited by hand went into their commit beside their own row. `_checked_manifest_committed`, asked right after the operation check in each verb, refuses `family-manifest-uncommitted` where the index holds the file unmerged, holds no copy, or holds one HEAD does not, or where `git ls-files -m` lists the work tree's copy; the remediation names git's two ways out, `git commit -- family.yaml` and `git checkout HEAD -- family.yaml`. It reads the index records, `HEAD:family.yaml` and `ls-files -m`, and no `hash-object`, which a dry run asks only for `bump`'s nothing-to-do. A holder whose HEAD holds no `family.yaml`, one made by hand with no commit yet, is not asked: `add` gives the file its first commit, as it always has. The two `member-path` remedies now say to commit a row put back by hand, or the run again would meet this refusal.

Existing tests that left `family.yaml` changed and expected a later answer now commit that change first, or expect the new refusal: test_family.py's nothing-to-do over an uncommitted edit (now refused, the edit left alone) and #210's half-finished bump (now refused; `git checkout HEAD -- family.yaml` and the same bump finishes it in one commit of both paths); test_family_member_path.py's no-path-line control and its two remedy texts; and the members-line hand edits in test_family_bump_remove_rollback.py and test_family_dry_run.py.

Not in it: #612's review E4 (reftable). This host's git is 2.43, and the issue asks for it to be measured on git 2.45 or later first.

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