Skip to content

family.py: remove and add refuse a .gitmodules the work tree does not have as the index has it (missing, unstaged, untracked, or behind assume-unchanged or a sparse checkout), so neither commits a change to it nobody staged (#407) - #571

Merged
brettheap merged 4 commits into
mainfrom
fix/family-remove-gitmodules-missing
Oct 15, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

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

What was wrong

family.py remove commits .gitmodules as git rm leaves it in the work tree, and git rm edits only a .gitmodules the work tree has. With the work-tree copy deleted and the deletion not committed, git rm edited nothing. _commit_one then staged the deletion, and the commit took .gitmodules out of HEAD, with every other member's section in it. remove exited 0, and the holder it ran in passed its own gate. Once the commit was pushed, a recursive clone left every remaining member empty.

The issue's reproduction, run on bfed143 (I added one git switch -c, because #487 landed after the issue was filed):

registration keys before: Atlas 2, Nova 2
dry run: exit 0
gitmodules   none: git reads no section of .gitmodules in the work tree as giving members/Nova, so none is removed
remove: exit 0
  committed 766cfa532913: .gitmodules, members/Nova, family.yaml
 .gitmodules  |  6 ------
HEAD holds .gitmodules: no
registration keys after: Atlas 2, Nova 0
family ok: Fam (fam), 1 member(s)
recursive clone: exit 0
the clone's members/Atlas holds 0 entries
REFUSED member-uninitialized: members/Atlas is not an initialized submodule checkout, ...
fatal: No url found for submodule path 'members/Atlas' in .gitmodules

What changed

One check, _checked_gitmodules_as_staged, runs before any git command that writes. The dry run makes it too, in the same words. It reads the index's records for .gitmodules (stage, blob and git's -v flag letter) in one git ls-files -s -v -z, which writes nothing.

  • gitmodules-missing (remove): the work tree has no .gitmodules, while the index (at any stage) or HEAD has one. The remediation names the restore git takes in that state:

    • git checkout -- .gitmodules (the index's copy);
    • git checkout HEAD -- .gitmodules, where the deletion is staged;
    • for an unmerged file, by the stages the index holds: git checkout --merge -- .gitmodules where it has both sides' copies; git checkout --ours where only this side's is left (the other side deleted it); git checkout --theirs where only the other side's is; git checkout-index -f --stage=1 where both sides deleted it. Each is followed by git add .gitmodules.
  • gitmodules-unstaged (remove and add): the work tree's copy is not the index's. That means one of:

    • git lists it as modified (git ls-files -m). Where git add would still stage the index's own blob from it (git hash-object --path=.gitmodules says so, and it is a regular file), the refusal adds that the difference may be its line endings alone;
    • it is unmerged;
    • its deletion is staged while the file is still on disk;
    • the index marks it assume-unchanged, which ls-files -m does not look past, and its content is not the index's blob;
    • it is untracked: neither the index nor HEAD holds one. This includes the first member's add in a new holder.

    The remediation offers both ways out: stage the work tree's copy, or put back the index's (or HEAD's) copy. Behind assume-unchanged, staging first takes the mark off (git update-index --no-assume-unchanged -- .gitmodules), because git add stages nothing while it is on. An untracked file is staged with git add -f .gitmodules, since an operator's global excludes may match it (and, if wanted, committed on its own first), or moved out of the holder.

  • gitmodules-sparse: the index marks .gitmodules skip-worktree, as a sparse checkout that leaves it out does. git neither compares nor stages that file, and git checkout -- .gitmodules matches nothing. The remediation is git sparse-checkout disable.

    • add refuses it whatever the file's state, since git submodule add cannot stage it.
    • remove refuses it where the file is missing or is not the index's copy, and commits a clean copy on disk rightly, as it did on bfed143.

Every refusal ends with "No git command that writes was run and nothing was written.", and every remediation ends with "run the same command again". Typed as printed, each one leads to the right commit (the tests do exactly that).

remove asks this first, before #360's member-path-registered-twice. add asks it after its mount-path refusals (#242, #300), which already name what they find in the same file. Two of those, member-name-taken and gitmodules-unreadable, have the person mend the file by hand and run add again. They now say "then git add .gitmodules", as #300's and #360's remediations already did. Without that step, the run again was this PR's own gitmodules-unstaged. The holder's validate-family.py keeps its words: it reads the work tree's copy, so re-running it needs no stage.

Round 2: the review's points and Codex's

Point What changed
S1 member-name-taken and GITMODULES_UNREADABLE say "then git add .gitmodules". New test: each one's state, mended by hand, the printed stage typed, and add then mounts the member. The text tests are updated (test_family_add_excludes.py's member-name-taken test, and test_refusal_default_remediation.py, which now holds add's remedy equal to the gate's but for the one step). test_family_add_refusals.py::test_a_gitmodules_git_cannot_read_is_a_refusal_not_a_pass pins the validator's text, which is unchanged, so it is untouched, and so is the :518 test, which asserts only the REFUSED line.
N1 The line-endings clause, only where hash-object gives the index's blob from a regular file. Tested with a CRLF copy (clause) and a link to the very same bytes (no clause: hash-object reads through the link, but git would stage the link).
N2, sparse gitmodules-sparse, with git sparse-checkout disable. Tested by a real git sparse-checkout set --no-cone '/*' '!/.gitmodules', for both verbs, typed as printed.
N2, modify/delete The per-stage restores above. Tested for stages 1+2 (--ours), 1+3 (--theirs) and 1 alone (checkout-index), each typed as printed.
N3 test_a_holder_whose_head_has_no_gitmodules_is_not_asked asserts only that no gitmodules- refusal is made. Its docstring says the real run fails there, a known case from before #407 that goes to #459.
N4 The base split below is corrected.
N5 unmerged() and typed_as_printed() run git through raw(root, env, ...), under the module's isolated environment. raw() gained a stdin argument for update-index --index-info.
N6 held() and assert_held() are gone. Refusals are read with test_family_dry_run.state() and assert_as_it_was(); state() now reads a missing .gitmodules as None (three lines).
Codex, :4476 The untracked .gitmodules is gitmodules-unstaged, for both verbs, dry run alike. Tested: the first add in a holder with no member, and remove in the holder #407 leaves behind with the old bytes put back untracked. Staged as printed, each commits.
Codex, :4519 assume-unchanged: compared by content (hash-object against the stage-0 blob) and refused. skip-worktree: gitmodules-sparse, as for N2. Tested for both verbs: the assume-unchanged edit kept through the printed update-index and add, and the skip-worktree edit through git sparse-checkout disable, then the restore the next refusal prints.

Round 3: the re-review's nits

Nit What changed
1 remove again commits a clean .gitmodules the index marks skip-worktree, as bfed143 and b1120c3 did: gitmodules-sparse is asked for add whatever the file's state, and for remove only where the file is missing or is not the index's copy. add, an edited file and a missing one stay refused. The function's and the module's docstrings say which verb refuses what. New test: remove with the mark on and the file clean commits exactly .gitmodules, family.yaml and the gitlink.
2 Two cases the round-2 tests left open. X1: add over a file marked both skip-worktree and assume-unchanged (two update-index calls, ls-files -v tag s) is gitmodules-sparse. X2: a clean assume-unchanged file passes, and both verbs commit the right tree. Each set-up asserts the ls-files -v letter it expects.
3 The untracked branch prints git add -f .gitmodules. The untracked add test also runs under an operator's core.excludesFile holding * (set as #242's tests set it), asserts that the file is ignored there, types the remedy, and add commits.
4 Decision 5's wording, and the note for the squash message below. This round's commit subject says only what it does.

Decisions and evidence

  1. Refuse, not rebuild (the issue's first option). The holder is left byte for byte as it was, and the person picks the copy they mean. Building this member's section out of the index's blob would commit a .gitmodules the work tree does not have. The holder would then show .gitmodules as deleted, and the person's next git commit -a would delete it after all. For a modified copy it would also have to guess which copy the person means.
  2. A staged change still goes in. The remediations of family.py remove, when .gitmodules gives the member's path to two sections, takes out only the one git finds by that path (the later in the file) and exits 0, so its commit still registers members/<P> with no gitlink and no row, validate-family.py passes the result (after #317 it reported the doubled file and stops), and the next add of that member is refused member-path-registered or silently reuses the leftover section and the .git/config registration deinit never touched #360 and family.py: add never reads .gitmodules, so a section under ANOTHER name whose path is already members/<P> (a "ghost" section) does not stop it: it exits 0 and commits two sections for one path, validate-family.py says family ok, and a recursive clone takes the LATER section, so with the ghost second it dies fetching the ghost's URL #300 tell the person to edit .gitmodules, git add it, and run the same command again. So the rule is "the work tree's copy must be the index's", not "must be HEAD's".
  3. "Modified" is git's own answer. It comes from git ls-files -m, the comparison git status uses and the one git rm refuses an unstaged change with. On bfed143, an unstaged edit, a CRLF-only copy and a symlink were each refused by git rm only after git submodule deinit -f had emptied the member (then rolled back), and each dry run exited 0. The CRLF case is still refused (as git rm refuses it), and now says why it may look like no change: its hash-object is the index's blob (probe: both 26f54704).
  4. Assume-unchanged is compared by content; skip-worktree is refused outright. Probes against round 1's check (states-wip.log):
    • assume-unchanged, clean: both verbs commit correctly, so it is not refused. Edited: ls-files -m is empty and both verbs committed the edit. git add .gitmodules with the mark on exits 0 and stages nothing (the hash stays a2d4d491 against the index's 26f54704), so the keep-remedy takes the mark off first. git checkout -- .gitmodules restores it with the mark on.
    • skip-worktree, file on disk: remove committed a hidden edit; add failed at its mount even for a clean file (git add refuses a path outside the sparse-checkout definition, then rollback). git add and git checkout -- both fail while the mark is on. So add refuses any skip-worktree .gitmodules, on disk or not. remove refuses one that is missing or is not the index's copy, and commits a clean one on disk rightly, as it did on bfed143 and b1120c3 (round 3; round 2 refused that one too).
  5. git sparse-checkout disable is the one command that works as typed both in a sparse checkout and where only the mark is left. git sparse-checkout add /.gitmodules is narrower in a real non-cone sparse checkout, but fails for a mark set by hand (fatal: no sparse-checkout to add to). With sparse checkout on, git checkout --ignore-skip-worktree-bits -- .gitmodules and git update-index --no-skip-worktree + git checkout -- both put the file back, but remove then failed at its commit's git add ("outside of your sparse-checkout definition"). disable worked with sparse checkout on and with only the mark left, and keeps a copy already on disk as it is. The refusal names the state in its own words. It is a dozen lines, not a sparse-definition reader.
  6. An untracked file is staged, not committed by the printed command, and staged with -f. add's branch check (family.py add, bump and remove commit straight onto the holder's tracking branch, and add's NEXT pushes a <branch> that does not exist, though AGENTS.md says to land each as a pull request #487) comes after this refusal, so a printed git commit could land on the tracking branch. Staged, the file goes into the verb's commit; the remediation says to commit it on its own first if that is not wanted. An operator's global excludes may match the untracked file (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 *), and a plain git add then refuses it, so this branch prints git add -f .gitmodules. The tracked branches keep the plain git add, which stages a tracked file under the same excludes.
  7. The dry run (N9). In the issue's state, remove --dry-run prints the same refusal, exit 2. Past the refusal, the work tree's file is the index's copy, or no copy exists anywhere. So deinit reads the same name the dry run reads.
  8. bump is not affected. Its commit is [path, MANIFEST] and never stages .gitmodules. Probe: bump with the work-tree .gitmodules deleted committed family.yaml and members/Atlas only.

Tests

tests/test_family_remove_gitmodules.py, 35 tests:

  • remove over each state the work tree lacks (6): deleted (the issue), deletion staged, and unmerged with the file deleted at stages 1+2+3, 1+2, 1+3 and 1. Both runs refuse alike, exit 2, nothing on stdout, and the holder unchanged: test_family_dry_run.state(), which is HEAD, ls-files -s -z, status --porcelain=v1 -z, family.yaml, .gitmodules, .git/config, the member's HEAD, every work-tree byte and the holder's objects. The remedy is typed as printed; remove then exits 0, HEAD's .gitmodules gives only the other member's path, and the gate passes;
  • the issue's clone: after the restore and remove, a push and a fresh clone --recurse-submodules populate the other member, and the clone's gate exits 0;
  • each work-tree copy that is not the index's, for both verbs (12): edited, CRLF only, a link to the same bytes (skipped on Windows), assume-unchanged and edited, unmerged, deletion staged with the file kept. One printed remedy is typed, and the verb then commits;
  • sparse (4): a real sparse checkout for both verbs, and add over a clean file marked skip-worktree by hand and over one marked both ways (ls-files -v tag s); skip-worktree with an edit (2), both verbs;
  • a clean file behind a mark, committed with exactly .gitmodules, family.yaml and the member's gitlink (3): skip-worktree (remove), assume-unchanged (remove and add);
  • untracked (3): add in a new holder, with no excludes and under an operator's *; remove in a holder family.py remove, in a holder whose work-tree .gitmodules has been deleted and the deletion not committed, commits that deletion with the member's removal, so every other member's section leaves HEAD while remove exits 0 and the holder's validate-family.py passes, and once pushed a recursive clone exits 0 with each remaining member's directory empty and its own validate-family.py refusing member-uninitialized #407 already damaged;
  • add over a missing file is still git's own refusal, with its dry run's note;
  • a holder whose HEAD lost .gitmodules: no gitmodules- refusal (N3);
  • S1 (2): member-name-taken and gitmodules-unreadable, mended by hand, the stage typed as printed, add then mounts.

Round 2's 29 tests on round 1's head b1120c3: 15 failed, 14 passed in 80.15s. The 15 are exactly round 2's cases: CRLF's clause (2), sparse (2), skip-worktree (2), untracked (2), S1 (2), the --ours, --theirs and checkout-index restores (3), and assume-unchanged (2). On bfed143 with them: 27 failed, 2 passed in 59.67s; the 2 that pass pin behaviour the fix keeps (add leaves a missing file to git, and N3's holder is not asked).

Round 1's base proof, corrected (the review's N4): on bfed143 with round 1's 14 tests, 12 failed, 2 passed. Of the 12, 8 failed on "HEAD changed" (the verb committed; the clone test is among them), 3 on ".git/config changed" (lacks[deletion staged], [edited-remove] and [crlf-remove]: the run failed after deinit and the rollback re-wrote the registration) and 1 on "status changed" ([unmerged-remove]). In lacks[deletion staged] it was not git rm that failed after deinit but _commit_one's git --literal-pathspecs add -f -- .gitmodules family.yaml (pathspec '.gitmodules' did not match any files).

On this head (ebe6b51), Linux, /usr/bin/python3 -m pytest, the files round 3 touches and the importers of the changed helpers: tests/test_family_remove_gitmodules.py tests/test_refusal_default_remediation.py tests/test_family_add_excludes.py tests/test_family_dry_run.py tests/test_family_member_path.py: 182 passed in 224.37s (0:03:44). The new file alone: 35 passed in 46.50s.

Round 2's targeted runs, on cb159b1 (round 3 changes family.py's skip-worktree condition and one remedy, and the new test file):

  • tests/test_family_remove_gitmodules.py tests/test_family_bump_remove_rollback.py tests/test_family_dry_run.py tests/test_family.py tests/test_cli_reference.py tests/test_repo_hygiene.py tests/test_family_member_path.py tests/test_family_tracking_branch.py tests/test_family_printed_quoting.py tests/test_family_member_name.py tests/test_family_bump_to_a_tag.py tests/test_family_init_landing.py tests/test_family_init_values.py tests/test_template_text_placeholders.py: 706 passed, 19 skipped in 1013.94s (0:16:53) (the skips are test_family_init_values.py's template-quoting cases, as on main);
  • tests/test_family_add_refusals.py tests/test_validate_family_duplicates.py tests/test_validate_family_member_path_glob.py tests/test_validate_family_unnamed_mounts.py tests/test_validate_family_without_git.py tests/test_family_add_excludes.py tests/test_family_add_member_id.py tests/test_refusal_default_remediation.py tests/test_agents_md_codes.py tests/test_shape_doctor.py: 632 passed, 3 skipped in 1482.84s (0:24:42) (the skips are the Windows-only PowerShell line).

Windows: every file the tests write is written with write_bytes, git's stdin gets bytes, and nothing compares against a file a helper wrote with write_text. The holder's attributes keep .gitmodules LF in the work tree (i/lf w/lf attr/text=auto eol=lf). The symlink case skips on Windows, where git checks a link out as a plain file. macOS: no case variants are involved.

Reproductions

The issue's script, with local bare remotes, a gh that exits 97, no global or system git configuration, and the registrations read from .git/config by grep:

  • on round 2's head (cb159b1): dry run: exit 2 and remove: exit 2, both REFUSED gitmodules-missing: ... with the same stderr, and a full snapshot of the holder (every file, .git included) identical after each. The registration sections stay 1 and 1. After a push of the unchanged holder, the recursive clone exits 0, members/Atlas holds 15 entries, and the clone's gate prints family ok.
  • round 1 typed the remedy as printed (git checkout -- .gitmodules): remove: exit 0, a commit with .gitmodules | 3 --- (Nova's section only), the holder's gate exits 0, and after a push the recursive clone populates Atlas and its gate exits 0. The lacks and clone tests repeat that on this head.
  • round 2's states, on cb159b1: for remove and add, a real sparse checkout, a skip-worktree edit, an assume-unchanged edit (kept, and dropped), CRLF and a link; for remove, an assume-unchanged deletion, the unmerged file deleted at stages 1+2, 1+3, 1 and 2+3, and the untracked file in a damaged holder; for add, the untracked file in a new holder. Each is refused alike by both runs (identical stderr), each printed command exits 0 as typed, and the run again commits what the remedy chose. Round 3 changes two of these paths (a clean skip-worktree file for remove, and the untracked remedy's -f), and its tests on ebe6b51 hold both.

Nothing here needs real GitHub or a cloud tool.

Mutation probes

Round 2's code hunks, each reverted alone in a throwaway worktree at cb159b1, with the new file run against it:

hunk reverted result new tests that fail
M1 S1, GITMODULES_UNREADABLE's stage 1 failed, 28 passed S1 [gitmodules-unreadable]
M2 S1, member-name-taken's stage 1 failed, 28 passed S1 [member-name-taken]
M3 the untracked refusal (Codex, :4476) 2 failed, 27 passed both untracked tests
M4 gitmodules-sparse 4 failed, 25 passed sparse [remove], [add]; skip-worktree [remove], [add]
M5 the per-stage restores (always --merge) 3 failed, 26 passed lacks[our side only, deleted], [their side only, deleted], [neither side, deleted]
M6 the line-endings clause (N1) 2 failed, 27 passed [crlf-remove], [crlf-add]
M7 _stages_as's regular-file guard 2 failed, 27 passed [a link to the same bytes-remove], [...-add]
M8 the assume-unchanged branch (Codex, :4519) 2 failed, 27 passed [assume-unchanged, edited-remove], [...-add]
M9 the flags read as H (_gitmodules_in_index) 6 failed, 23 passed the 2 assume-unchanged, 2 sparse and 2 skip-worktree cases
M10 the return where nothing holds a .gitmodules 29 errors every test, at the family fixture's first add into a holder with no .gitmodules anywhere, which is then refused as untracked

Round 1's hunks are still held by the same tests (round 1's probes M1 to M8). The other hunks are docstrings and comments.

Round 3's hunks, each reverted alone at ebe6b51, with the new file run against it:

hunk reverted result new tests that fail
R1 the narrowing (always refuse skip-worktree) 1 failed, 34 passed [skip-worktree-remove] of the clean-behind-a-mark test
R2 its verb == "add" arm 2 failed, 33 passed sparse [marked by hand, clean-add], [marked both ways, clean-add]
R3 X1, tag == "S" without .upper() 1 failed, 34 passed sparse [marked both ways, clean-add]
R4 X2, every assume-unchanged copy refused 3 failed, 32 passed [assume-unchanged-remove], [assume-unchanged-add], sparse [marked both ways, clean-add]
R5 plain git add in the untracked remedy 3 failed, 32 passed the three untracked tests (the pinned text)
R5b plain git add in the remedy AND the test's expected text 1 failed, 2 passed (-k untracked) [*]: typing git add .gitmodules fails, "The following paths are ignored by one of your .gitignore files ... Use -f"

Merge checks

git merge-tree --write-tree of this head (ebe6b51) with each of these is clean:

Left out, and checklist candidates

For the squash message

  • Round 2's commit subject (cb159b1) says "no printed command leads into another refusal". That is inexact: for an edit hidden by skip-worktree, git sparse-checkout disable leads, by design, into gitmodules-unstaged, and test_an_edit_skip_worktree_hides_is_refused_then_named_as_modified pins that chain. The squash message must not repeat it.

Closes #407

🤖 Generated with Claude Code

Summary by Sourcery

Make family.py add and remove verify .gitmodules consistency before writing so they commit only staged changes and cannot remove or corrupt unrelated submodule registrations.

Bug Fixes:

  • Prevent family.py add and remove from committing missing, unstaged, unmerged, untracked, assume-unchanged, or sparse .gitmodules changes that were not staged, avoiding corruption of other submodule registrations.
  • Provide actionable refusals and restore commands for missing, conflicted, modified, ignored, and sparse .gitmodules states, consistently in dry runs and real executions.

Enhancements:

  • Ensure staged .gitmodules edits remain eligible for the command’s commit and update related refusal guidance to stage hand-edited files before retrying.
  • Add comprehensive coverage for .gitmodules work-tree/index mismatches, sparse checkouts, index flags, unmerged stages, untracked files, recursive clones, and remediation behavior.

Documentation:

  • Document the .gitmodules consistency requirements and refusal behavior for add and remove.

Tests:

  • Add regression tests covering all affected .gitmodules states and verifying dry-run parity, repository preservation, printed remediations, and successful retries.

…as not as the index has it, before it writes anything and under `--dry-run` alike, and `add` refuses the second, so `remove` no longer commits the deletion of an uncommitted-deleted `.gitmodules` with every other member's section in it, and neither verb commits a change to the file nobody staged (#407)

`remove` commits `.gitmodules` as `git rm` leaves it in the work tree, and
`git rm` edits only a file the work tree has. With the work-tree copy
deleted and the deletion not committed, the commit took `.gitmodules` out of
HEAD, and every other member's section with it, with exit 0. The holder it
ran in still passed its own gate, and once the commit was pushed a recursive
clone left each remaining member empty. Its dry run printed no
registration, though `git submodule deinit` dropped one (the review's N9).

Now `_checked_gitmodules_as_staged` runs before any git command that writes:

- `gitmodules-missing` (`remove`): the work tree has no `.gitmodules` while
  the index (at any stage) or HEAD holds one. The remediation names the
  restore: `git checkout -- .gitmodules`, or HEAD's copy where the deletion
  is staged, or `git checkout --merge` where the index holds it unmerged.
- `gitmodules-unstaged` (`remove` and `add`): the work tree's copy is not
  the index's. Either git lists it as modified (`git ls-files -m`, git's own
  comparison, which `git rm` refuses an unstaged change with), or it is
  unmerged, or its deletion is staged while the file is kept. `add`
  committed such a change beside the member's section; `remove`'s `git rm`
  refused some of them only after `deinit` had emptied the member.

A change somebody has STAGED still goes into the commit: the remediations of
#300 and #360 have the person edit the file and `git add` it, then run the
verb again. `add` leaves a missing work-tree file to `git submodule add`,
which refuses it at the mount, and whose refusal its dry run names in a note,
as before. `bump` never stages `.gitmodules` and is unchanged.

The dry run makes both refusals in the real run's words. Past them, the work
tree's file is the index's copy (or no copy exists anywhere), which is where
`deinit` reads the name too, so the registration line the dry run prints is
the one `deinit` drops.

tests/test_family_remove_gitmodules.py holds each state, in both runs, with
the holder byte for byte as it was. Each remedy is typed as printed and the
same command then runs. After the issue's restore, a push and a fresh
recursive clone populate the remaining member and pass its gate.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-14T23:19Z) @codex review

@sourcery-ai

sourcery-ai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR hardens family.py add and remove by validating that the work-tree .gitmodules matches the intended index state before any write, preventing accidental deletion or inclusion of unrelated changes while providing consistent dry-run refusals and remediation guidance, with extensive regression and clone-integrity tests.

Sequence diagram for safe family remove validation

sequenceDiagram
    actor User
    participant CLI as family.py
    participant Git as Git repository
    participant Commit as Commit staging

    User->>CLI: remove or remove --dry-run
    CLI->>Git: _index_records()
    CLI->>Git: _head_oid()
    CLI->>Git: ls-files -m -z
    alt .gitmodules missing or differs from index
        CLI-->>User: Refusal with remediation
        CLI-->>User: No git command that writes was run
    else work-tree copy matches index
        CLI->>Git: deinit / git rm
        CLI->>Commit: _commit_one()
        Commit-->>User: Removal committed
    end
Loading

Sequence diagram for safe family add validation

sequenceDiagram
    actor User
    participant CLI as family.py
    participant Git as Git repository
    participant Commit as Commit staging

    User->>CLI: add or add --dry-run
    CLI->>Git: _checked_mount_path()
    CLI->>Git: _checked_gitmodules_as_staged()
    alt .gitmodules differs from index
        CLI-->>User: Refusal with remediation
        CLI-->>User: No git command that writes was run
    else file state is acceptable
        CLI->>Git: git submodule add
        CLI->>Commit: _commit_one()
        Commit-->>User: Addition committed
    end
Loading

Flow diagram for gitmodules validation outcomes

flowchart TD
    Start[add or remove begins] --> Held{Index or HEAD holds .gitmodules?}
    Held -->|No| Proceed[Continue without validation refusal]
    Held -->|Yes| Exists{Work-tree .gitmodules exists?}
    Exists -->|No and verb is remove| Missing[Refuse gitmodules-missing]
    Exists -->|No and verb is add| GitAdd[Let git submodule add refuse]
    Exists -->|Yes| Match{Work-tree copy matches index?}
    Match -->|No| Unstaged[Refuse gitmodules-unstaged]
    Match -->|Yes| Proceed
    Missing --> Remedy[Restore or resolve .gitmodules, then rerun]
    Unstaged --> Remedy
    Proceed --> Write[Run the command's git writes]
Loading

File-Level Changes

Change Details Files
Add a shared preflight check that prevents remove and add from writing an unintended .gitmodules state.
  • Detect missing, modified, unmerged, and staged-deletion states by inspecting the index, HEAD, work tree, and Git's modified-file result.
  • Refuse before any write operation, consistently in real and dry-run modes, with actionable remediation commands and exit status 2.
  • Allow already-staged changes to proceed, while preserving add's existing delegation for a missing work-tree file and doing nothing when neither index nor HEAD contains .gitmodules.
  • Invoke the check at the appropriate points in remove and add, and document the interaction with dry-run and submodule deinitialization.
scripts/family.py
Add comprehensive regression coverage for .gitmodules consistency and preservation of repository state.
  • Cover missing, staged-deletion, unmerged, edited, CRLF, and deletion-staged-but-present states for both real and dry-run commands.
  • Verify refusals leave HEAD, index, status, configuration, work-tree bytes, and objects unchanged, then validate printed remediations and resulting commits.
  • Reproduce the recursive-clone failure scenario and confirm the repaired commit preserves remaining submodules.
  • Preserve and test existing behavior for missing .gitmodules during add and for holders whose HEAD no longer contains the file.
tests/test_family_remove_gitmodules.py

Assessment against linked issues

Issue Objective Addressed Explanation
#407 Prevent remove from committing a .gitmodules deletion or other unstaged change it did not make; refuse before any write when the work-tree copy is missing or differs from the index. ✅
#407 Make remove --dry-run accurately reflect the real command in the affected state, including reporting the refusal rather than implying no registration will be removed. ✅
#407 Have validate-family.py detect a member row whose path has no corresponding .gitmodules section in HEAD. ❌ The PR explicitly leaves this validator change out of scope. A holder whose HEAD already lacks .gitmodules can still pass its gate despite having a member row that cannot be populated by a recursive clone.

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T23:22:51.695232Z b1120c3 Manual request
ℹ️ About Codex in GitHub

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

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

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1120c3b81

ℹ️ About Codex in GitHub

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

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

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/family.py Outdated
Comment on lines +4475 to +4476
if not stages and not in_head:
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject an untracked .gitmodules before add

When adding the first member to an empty holder, neither the index nor HEAD contains .gitmodules; if an untracked .gitmodules already exists, this early return skips the new protection. git submodule add then appends its section and stages the entire file, so _commit_one silently commits any pre-existing comments or unrelated submodule sections that the user never staged. Distinguish a genuinely absent work-tree file from an untracked one and refuse the latter.

AGENTS.md reference: AGENTS.md:L392-L398

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(lane openRepoShape-5, 2026-10-15T12:35Z) Fixed in cb159b1, with round 3 in ebe6b51. An untracked .gitmodules, one that neither the index nor HEAD holds, is now refused gitmodules-unstaged by add and remove. The refusal comes before the first write, and the dry run refuses it too.

The remedy says to stage it with git add -f .gitmodules. The -f is there because an operator's global excludes may match the file (#242). The remedy also says to commit it on its own first if the command's commit should carry only its own change. It prints no git commit, because #487's branch check runs later and a printed commit could land on the tracking branch.

Tests in tests/test_family_remove_gitmodules.py:

  • test_add_refuses_an_untracked_gitmodules_in_a_holder_with_none covers your case: the first add into an empty holder. It runs once with no excludes and once with core.excludesFile holding *. Each time it types the remedy as printed and checks that add then commits.
  • test_remove_refuses_an_untracked_gitmodules_in_a_holder_head_lost_it covers remove.

On b1120c3 the same state committed the file silently, with exit 0.

Comment thread scripts/family.py
Comment on lines +4518 to +4519
elif GITMODULES.encode() in _git_bytes(["ls-files", "-m", "-z"],
root).split(b"\0"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not trust ls-files for assume-unchanged entries

If a tracked .gitmodules is marked with git update-index --assume-unchanged and then edited, git ls-files -m returns no path—the local update-index -h describes this flag as “mark files as not changing.” The guard therefore accepts the file, while the later explicit-path commit records its working-tree contents, silently including the unstaged edit alongside the member change. Inspect the index flag and refuse it, or compare the work-tree content and mode directly against the stage-0 entry rather than relying solely on ls-files -m.

AGENTS.md reference: AGENTS.md:L392-L398

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(lane openRepoShape-5, 2026-10-15T12:35Z) Fixed in cb159b1, with round 3 in ebe6b51. The check no longer trusts ls-files -m alone. It reads the index flags with git ls-files -s -v -z -- .gitmodules.

Tests in tests/test_family_remove_gitmodules.py:

  • test_a_work_tree_copy_that_is_not_the_indexs_is_refused, whose assume-unchanged, edited case runs for both verbs and types each printed remedy;
  • test_a_clean_gitmodules_behind_a_mark_is_committed_as_before;
  • test_an_edit_skip_worktree_hides_is_refused_then_named_as_modified;
  • test_a_gitmodules_a_sparse_checkout_leaves_out_is_refused.

Mutants that drop the flag read, the content compare or the .upper() are each caught.

… an edit hidden by assume-unchanged or skip-worktree, and a file a sparse checkout leaves out, and every restore and remediation they print about the file works as typed, so neither verb commits a `.gitmodules` change nobody staged and no printed command leads into another refusal (#407)

The review of #571 and Codex's two comments, round 2:

- S1. `member-name-taken` and `gitmodules-unreadable` (`GITMODULES_UNREADABLE`)
  have the person mend `.gitmodules` by hand and run `add` again. Since round
  1, a mended file nobody staged is `gitmodules-unstaged`, so both now say
  "then `git add .gitmodules`", as #300's and #360's remediations do. The
  holder's `validate-family.py` keeps its own words: it reads the work tree's
  copy and needs no stage. `test_refusal_default_remediation.py` now holds
  the two remedies equal but for that one step.
- N1. Where `git ls-files -m` lists the file but `git hash-object
  --path=.gitmodules` reads it as the index's own blob (a regular file, not a
  link), `gitmodules-unstaged` says the difference may be its line endings
  alone.
- N2. `gitmodules-missing`'s restore for an unmerged file is the one git takes
  in that state: `--merge` needs both sides' copies, so stages 1 and 2 print
  `git checkout --ours`, 1 and 3 `git checkout --theirs`, and 1 alone `git
  checkout-index -f --stage=1`, each then `git add .gitmodules`.
- N2 and Codex at :4519. A `.gitmodules` the index marks skip-worktree, on
  disk or not, is the new `gitmodules-sparse`, for both verbs: git neither
  compares nor stages it, `git checkout -- .gitmodules` matches nothing, and
  the remedy is `git sparse-checkout disable`, which works as typed whether
  sparse checkout is on or only the mark is left.
- Codex at :4519. An edit behind assume-unchanged, which `ls-files -m` does
  not list, is compared by content (`hash-object` against the stage-0 blob)
  and refused as `gitmodules-unstaged`. Its remedy takes the mark off first,
  `git update-index --no-assume-unchanged -- .gitmodules`, because `git add`
  stages nothing while the mark is on.
- Codex at :4476. An untracked `.gitmodules`, where neither the index nor HEAD
  holds one, is `gitmodules-unstaged` for both verbs: `git submodule add` and
  `git rm` stage all of it. The remedy is to stage it (and commit it on its
  own first if wanted), or move it out.
- The flags, stages and blob are read in one `git ls-files -s -v -z`, with no
  pathspec, which writes nothing; so is `hash-object` without `-w`.

Tests: `tests/test_family_remove_gitmodules.py` gains each state above, each
remedy typed as printed and the command then run (29 tests). The review's N3:
the HEAD-lost-`.gitmodules` test asserts only that no `gitmodules-` refusal is
made, and says the real run fails there (#459). N5: `unmerged()` and
`typed_as_printed()` run git through `raw()` under the module's environment;
`raw()` takes `stdin`. N6: `held()` is gone; refusals are read with
`test_family_dry_run.state()`, which now reads a missing `.gitmodules` as
None. The `member-name-taken` text test in `test_family_add_excludes.py` and
the unreadable-file tests in `test_refusal_default_remediation.py` carry the
new words.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap changed the title family.py: remove refuses a .gitmodules missing from the work tree or not as the index has it, and add refuses the second, so neither commits a change to .gitmodules it did not make (#407) family.py: remove and add refuse a .gitmodules the work tree does not have as the index has it (missing, unstaged, untracked, or behind assume-unchanged or a sparse checkout), so neither commits a change to it nobody staged (#407) Oct 15, 2026
…ks skip-worktree, and the untracked-file remedy stages it with `git add -f`, so `remove` no longer refuses a holder bfed143 handled rightly and the printed stage works as typed under an operator's `*` excludes (#407)

The re-review of round 2 (cb159b1), APPROVE WITH NITS:

- Nit 1. `gitmodules-sparse` is now asked for `add` whatever the file's state,
  and for `remove` only where the file is missing from the work tree or is not
  the index's copy (`_stages_as`). A clean, by-hand skip-worktree `.gitmodules`
  on disk was committed rightly by `remove` on bfed143 and b1120c3, and
  cb159b1 refused it. `add` stays refused: `git submodule add` cannot stage
  that file, and on bfed143 it failed at the mount after writing. The
  function's and the module's docstrings say which verb refuses what.
- Nit 3. The untracked branch of `gitmodules-unstaged` prints `git add -f
  .gitmodules`. An operator's global excludes may match the file (#242's `*`),
  and then a plain `git add` refuses it. The tracked branches keep the plain
  `git add`, since a tracked file stages under the same excludes.
- Nit 2 (tests). A clean `.gitmodules` marked skip-worktree (`remove`) or
  assume-unchanged (`remove` and `add`) is committed, and the commit changes
  exactly `.gitmodules`, `family.yaml` and the member's gitlink. `add` refuses
  a clean file marked skip-worktree by hand, and one marked both ways (`ls-files
  -v` tag `s`), as `gitmodules-sparse`. The untracked `add` test also runs
  under an operator's `*` excludes, typing `git add -f` as printed.

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

Copy link
Copy Markdown

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

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

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

You can request another review in 1 day and 4 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 40b476d into main Oct 15, 2026
3 checks passed
@brettheap
brettheap deleted the fix/family-remove-gitmodules-missing branch October 15, 2026 18:56
brettheap added a commit that referenced this pull request Oct 15, 2026
…e `--link-source` is the first chain link's tree alone, and a source that names no path is refused (#441, #442) (#576)

**README change.** This PR edits one README paragraph. #551 is on main and this branch has merged it, so the handbook step follows #551's rule: the three page parts that read the README section "The three legs" record its new hash. No page text changes.

### #441: one bare tree answered for two links

`link_pins_from_trees` passed its `default_source` to every link of a recorded chain. That value is the naming gate's bare `--link-source PATH`, and the scaffold's `--pin-source`. In the chain `openXdox → openYdox → openInk`, with one bare tree that is `openXdox`'s, `openYdox` was read from the same `project.yaml`. That file declares `openYdox`, not `openInk`, so the gate said "openYdox declares openYdox, not openInk". For `codexInk`, which holds no direct pin on its referent, that was a FINDING, exit 1, for a chain that holds. The scaffold refused the same chain as `chain-not-declared`.

Now:

- **Which links read a bare value (for #540 and anyone else relying on it):** only the FIRST link of each chain, `links[0]`, the pin the project holds. That is true for a chain of any length, one link included: a chain of one link has only its first link, and that link reads the bare tree as before. A later link is never offered the bare value. It is read from its own `LINK=PATH`, its `SHAPE_PIN_SOURCE_<LINK>` or a checkout beside the project; otherwise it is declared-unverified (a warning, exit 0). For the first link the order is unchanged: keyed, variable, checkout beside the project, then the bare value.
- `link_pins_from_trees` and `resolve_link_source` keep their signatures and return shapes. The only code change in `repo_shape.py` is `None if index else default_source` in the loop.
- The two-link case (`openXdox → openDox` with a bare path) reads exactly as before and prints the same lines. The one difference is inside the returned mapping, and only for a chain of two or more links: the chain's END link no longer gets an entry read from the bare tree. Nothing reads that entry; only holders' declarations are read. In a chain of one link the only link is both the first and the end, and it still reads the bare tree, as before.
- In the naming gate, `LinkSources` finds the first link of every chain a name will READ before any chain is read. `_first_links_read` uses the same `reads_link_declarations` test the lookup uses, and counts a chain only if it has two or more links: a chain of one link has no holder, reads no declaration, and so has no first link to count. The bare value is offered only while there is at most one first link: with a lone one-link chain there are none, and the bare tree is still returned for that chain's link to read, as before (nothing reads what it finds, so the output is unchanged). If a run reads two chains that begin at different links (a name on the command line plus `--project`, or one project with two legs), the bare value is read for neither. Before, one tree answered both, and `used` counted it once.
- A bare value no link took is a NOTE saying why. In a two-link run the sentence is the one it always was. In a longer chain it reads "a bare PATH is the tree of a chain's first link alone, openXdox, which had a more specific source (...)". With two first links it reads "the chains read here begin at openXdox, openYdox: name the link it is, as --link-source <link>=PATH".

### #442: an empty path was the current directory

`--link-source openXdox=` and `--pin-source openXdox=` became `Path("")`, which is `.`. From a project root, the naming gate read the project's own manifest as `openXdox`'s and called the chain broken. `validate-pins.py` asked the project root for the pinned commit, and its finding blamed `.`.

Now both tools refuse, exit 2, naming the key:

```
REFUSED link-source-no-path: `--link-source openXdox=` names no path for openXdox. An empty path would be read as the directory this runs in. Give the tree's path (`--link-source <link>=<path>`), or `.` to mean this directory.
REFUSED pin-source-no-path: `--pin-source openXdox=` names no path for openXdox. An empty path would be read as the directory this runs in.
Remediation: give the checkout's path (`--pin-source <product>=<path>`), or `.` to mean this directory.
```

- An empty bare value (`--link-source ''`, `--pin-source ''`) is refused the same way.
- A typed `.` still means the current directory.
- A value whose key is NOT a chain link or a declared pin (`openZdox=`) is still the bare PATH `openZdox=`, as #338 and #295 decided. It is not empty, so it is not refused.
- **Behaviour change: an empty value is refused even when a later value replaces it.** `--link-source openXdox= --link-source openXdox=<tree>` was a NOTE ("the later `--link-source ...` replaced it"), exit 0. It is now `REFUSED link-source-no-path`, exit 2. `validate-pins.py` changes the same way: `--pin-source openXdox= --pin-source openXdox=<clone>` was `pins ok`, exit 0, and is now `REFUSED pin-source-no-path`, exit 2. A typo is a typo, and the refusal names it when it is typed; a script that passed an empty value first and the real one after must drop the empty one.
- The naming gate refuses in `main`, right after the values are read and before stdin is read or any name is classified, like its own `REFUSED naming-no-target`.
- `validate-pins.py` raises a `Refusal` from `PinSources`. It reaches `main`'s existing handler with its own remedy, not the lockstep one. **It is raised late:** `PinSources` is built inside `_check_neutral_product_pins`, which `_run_all_checks` calls after `_check_leg` and `_check_shape_pin`. So an earlier refusal wins: a damaged `contracts/spec-pin.yaml` plus `--pin-source openXdox=` prints `REFUSED pin-tag-only`, and the empty path is named on the run after that is fixed. The hunk stays inside `PinSources` on purpose, to stay clear of the lines #539 edits in `_run_all_checks`. The refusal also has to wait for the declared pins, because they decide whether `openXdox=` is keyed or a bare PATH. Nothing is printed before the refusal, and the exit is 2 as in every other refusal of that tool.

### Docs

- `--help` for `--link-source`, the naming validator's module docstring (exit code 2 included), `resolve_link_source`'s rule 4, `link_pins_from_trees`'s docstring and the scaffold's `_declaration` docstring say what a bare value means in a longer chain.
- The `--link-source` help also says that a bare PATH is read for neither link when the chains read begin at two different links, and that a later link is read from its own `PRODUCT=PATH`, its `SHAPE_PIN_SOURCE_<PRODUCT>` or a checkout beside the project (it said "needs its own `PRODUCT=PATH`").
- `docs/cli.md` is regenerated.
- README's lookup-order sentence now reads "then a bare `--link-source <path>`, for the first link's tree only: the order `validate-pins.py` uses below for the same checkout." The paragraph keeps its 12 lines, and README keeps its line count.
- The handbook names no `--link-source`. The three parts that read "The three legs" (the masthead, "Reading private legs in CI", "The three legs, and naming") move to that section's new hash, 6ebed796 → 39ec2c23. No other README section changed.
- `validate-pins.py`'s `--help` and EXIT CODES docstring do not name `pin-source-no-path`. Nothing in them is false: the exit-2 list gives examples. I left them, because the module docstring sits near lines #539 edits.

The review of 04c9d77 (APPROVE WITH NITS) found five nits. Each, and what this commit does:

1. **A chain of one link counted as a first link.** `--referent-chain openInk` beside a project whose leg records `openXdox → openQuill`, with a bare `openXdox` tree, gave two first links, so the bare tree went to neither. `codexQuill`, verified through it on bfed143, was declared-unverified, with a NOTE naming `openInk` as a first link. Both places now count `chain[0]` only when the chain has more than one link: `_first_links_read`, and the `firsts` update in `LinkSources.link_pins`. The run is verified again, with no WARNING and no NOTE, as on bfed143. New test `test_a_chain_of_one_link_is_no_first_link_read`.
2. **A test gap: the project half of `_first_links_read`.** Reverting `if data is not None:` to `if False:` (mutation M14) left all 20 tests passing, yet it changes the output for one project whose two legs begin at different links: the first classification pass counted the bare value used, and the NOTE went missing. New test `test_a_project_whose_legs_begin_at_two_links_reads_a_bare_tree_for_neither`: `openXdox → openInk` and `openYdox → openQuill` in one project, a bare `openXdox` tree, two WARNINGs and the two-first-links NOTE. M14 now fails it (see the mutation probes).
3. **`pin-source-no-path` is raised late in `validate-pins.py`.** No code change. The behaviour and the reason are written above, under #442.
4. **The `--link-source` help.** It now says a bare PATH is read for neither link when the chains read begin at two different links, and "needs its own `PRODUCT=PATH`" now names the variable and the checkout beside the project too. `docs/cli.md` is regenerated, and `render-cli-reference.py --check` is ok.
5. **This body.** It now says: #539 is on origin and merges clean (see the merge checks); an empty value is refused even when a later one replaces it; the END-link sentence holds for chains of two or more links; and the checklist candidate below.

The commit changes `scripts/validate-repository-naming.py`, `docs/cli.md` and `tests/test_link_source_chain_defaults.py` only.

The re-review of 31a6f21 (APPROVE WITH NITS) found that all five nits are fixed, and two new ones. This commit changes `tests/test_link_source_chain_defaults.py` only.

- **A test gap at the two-link boundary of `link_pins`.** Changing `if len(target.chain) > 1:` there to `> 2` passed the whole new file and `tests/test_naming_policy.py` (264 passed), yet it changes the output for a name read from stdin whose chain has two links: the first-link list is empty, and the NOTE for an unused bare value reads "a bare PATH is the tree of a chain's first link alone, , which had a more specific source". New test `test_a_two_link_chain_read_from_stdin_counts_its_first_link`: stdin `codexInk`, `--referent-chain openXdox,openInk`, `openXdox=<tree>` keyed and a second tree bare, asserting the whole stderr, the one NOTE "each link whose own declaration is read had a more specific source (...)". The `> 2` mutant fails it (see the mutation probes). The existing three-link stdin test and the new one share a helper, `stdin_naming`.
- **This body.** "exactly one first link" now reads "at most one", and the merge paragraph names the current main.

- **First link only, not a refusal.** The coordinator recommended this rule. Nothing documented or tested depended on a bare value answering a later link: no test passes `default_source` to `link_pins_from_trees`, and every scaffold test with a three-link chain sets `SHAPE_PIN_SOURCE_<LINK>` for each link. A refusal "when more than one link would take it" would depend on what is on the disk (checkouts beside the project, variables). #338 ruled that the reading is decided by the chains, never by the disk.
- **Refuse #442, not note it.** A note would still have to stop the value reading `.`. A refusal names the typo when it is typed, and both tools already exit 2 for a question they cannot ask.
- **The scaffold is fixed too.** It reads its `--pin-source` through the same function, so its three-link refusal goes away (see the reproduction). Its `--pin-source` is the pinned product's checkout, which is the first link's tree.

New file `tests/test_link_source_chain_defaults.py`: 23 tests (22 until round 3). On bfed143, 17 fail and 5 pass (the controls: the one-link rows and the rows that already worked). On 04c9d77, the one-link test fails and the other 21 pass.

Targeted runs (each `run9.sh`: `gh` stub, bogus token, empty `GH_CONFIG_DIR`, git-filter-repo 2.47.0), on the head 31a6f21 unless a row says otherwise. Round 3 (head a707d4b) changes the new test file only, and its own run is the last row:

| set | result |
|---|---|
| new file, naming policy, referent-chain links, pin-source env collisions, pin-source unreadable, CLI reference, repo hygiene, README sections, AGENTS.md codes, validate-pins without git, unreadable workflow | `774 passed in 117.48s` |
| manifest validator, scaffold pin and reuse, YAML flow quotes, neutral pin validator | `205 passed in 91.75s` |
| CLI reference alone | `22 passed in 26.08s` |
| template text placeholders, adopt plan leg-path help | `187 passed in 87.54s` |
| predicted merge with origin/main 7d9bec9 (tree 908b3214): repo hygiene, README sections, CLI reference, new file, naming policy | `415 passed in 53.10s` |
| round 3, head a707d4b: new file, naming policy, CLI reference | `287 passed in 95.29s` |

Round 1 (head 73c4c255, before the merge of #551 and this commit; `docs/handbook.html`, `README.md` and the validators are unchanged by this commit apart from `validate-repository-naming.py`):

| set | result |
|---|---|
| doctor, doctor validator rows, scaffold e2e, setup-project.py, setup.sh, adopt e2e, adopt pins, adopt spec-only, adopt submodules, adopt ignored shape files, adopt plan YAML quoting, adopt root symlinks, scaffold leg-path collisions, update-shape landing | `27 failed, 1796 passed, 3 skipped in 1433.51s`; every failure the same `UnicodeDecodeError` from a bytecode file my own `py_compile` probe had left in `templates/assembly-root/scripts/__pycache__`. With it removed, the two files that held all 27 (`test_adopt_root_symlinks.py`, `test_scaffold_leg_path_collisions.py`) gave `253 passed in 140.50s` |

The three skips are `test_shape_doctor.py:9119`, which runs only on a Windows host. None of those files passes `--link-source`, and the one-link change and the help text cannot change a run that gives no bare value.

**The 27 failures.** `template_values()` and its twin in `test_scaffold_leg_path_collisions.py` read every file under `templates/assembly-root` as UTF-8. A `validate-pins.cpython-312.pyc` had appeared there the second I ran `python3 -m py_compile` over the changed files. Its first byte is `0xcb`, the 3.12 bytecode magic, which is the byte every failure names. CI never runs `py_compile`.

| row | bfed143 | this head |
|---|---|---|
| #441: one bare `--link-source <openXdox tree>`, chain `openXdox → openYdox → openInk`, `codexInk` | `FINDING ... is broken (openYdox declares openYdox, not openInk)`, exit 1 | `domain-descendant/assembly`, one `declared-unverified` WARNING for openYdox naming `--link-source openYdox=<path>`, exit 0 |
| #441: each link keyed | exit 0 | exit 0, unchanged |
| #441: no `--link-source` | two WARNINGs, exit 0 | unchanged |
| #441, scaffold `--dry-run`, same chain, `--pin-source <openXdox clone>` | `REFUSED chain-not-declared`, exit 2 | WARNING declared-unverified for openYdox, plan printed, exit 0 |
| #441, one link: `--referent-chain openInk` beside a leg `openXdox → openQuill`, bare openXdox tree | no warnings, exit 0 | the same: no warnings, exit 0 (04c9d77 printed a WARNING and a NOTE) |
| #441, one project, legs `openXdox → openInk` and `openYdox → openQuill`, bare openXdox tree | `FINDING codexQuill: the declared chain openYdox → openQuill is broken (openYdox declares openInk, not openQuill)`, exit 1: the bare tree answered both first links | two WARNINGs and the two-first-links NOTE, exit 0 |
| #442: `--link-source openXdox=` from the project root | `FINDING ... (openXdox declares openXdox, not openInk)`, exit 1 | `REFUSED link-source-no-path`, exit 2 |
| #442: the same from another directory | WARNING about `.`, exit 0 | `REFUSED link-source-no-path`, exit 2 |
| #442: `--link-source ''` from the project root | FINDING, exit 1 | `REFUSED link-source-no-path`, exit 2 |
| #442: `--link-source openXdox= --link-source openXdox=<tree>` | NOTE "the later ... replaced it", exit 0 | `REFUSED link-source-no-path`, exit 2 (the behaviour change above) |
| #442: `--link-source openXdox=.` from the project root | FINDING (reads the project's own manifest, as typed) | unchanged: a typed `.` is read |
| #442: `--pin-source openXdox=` (scaffolded `codexDox`) | `FINDING neutral-pin-source-unreadable: ... . (--pin-source openXdox=...)`, exit 1 | `REFUSED pin-source-no-path`, exit 2 |
| #442: `--pin-source ''` | the same FINDING, exit 1 | `REFUSED pin-source-no-path`, exit 2 |
| #442: `--pin-source openXdox= --pin-source openXdox=<clone>` | `pins ok`, exit 0 | `REFUSED pin-source-no-path`, exit 2 |
| control: `--pin-source openXdox=<clone>` | `pins ok`, exit 0 | unchanged |

No row needs real GitHub. Round 1 ran every row except the two new ones and the two "a later value replaces it" rows. The one-link and two-legs rows are the new tests: on bfed143 the one-link test passes and the two-legs test fails; on 04c9d77 the one-link test fails and the two-legs test passes. The two "a later value replaces it" rows are the review's reproductions of bfed143 against 04c9d77; this commit does not change them.

Each hunk was reverted alone in a throwaway worktree, and the new file run against it.

Round 1 (20 tests), every hunk fails at least one test:

| hunk reverted | tests that fail |
|---|---|
| `repo_shape.py`: offer `default_source` to every link again | 6: the long-chain row, the later-link NOTE, the stdin NOTE, the lookup `[two]` and `[three]`, the scaffold |
| `LinkSources._bare_path`: drop the two-first-links gate | 1: two chains beginning at two links |
| `_not_asked_why`: drop the two-first-links sentence | 1: the same test |
| `_not_asked_why`: always the old two-link sentence | 2: the later-link NOTE and the stdin NOTE |
| `main`: drop the `link-source-no-path` refusal | 6: every `--link-source` no-path row |
| `PinSources`: drop the `pin-source-no-path` refusal | 3: every `--pin-source` no-path row |
| `link_pins`: drop `firsts.setdefault` (stdin names) | 1: the stdin NOTE |
| `_first_links_read`: return nothing | 1: two chains beginning at two links |
| naming `no_path`: keyed values only | 2: the two empty bare rows |
| pins `no_path`: keyed values only | 1: the empty bare row |

Round 2 (22 tests, head 31a6f21):

| hunk reverted | tests that fail |
|---|---|
| `_first_links_read`: `if data is not None:` → `if False:` (the project's legs; the review's M14, which survived 04c9d77) | 1: `test_a_project_whose_legs_begin_at_two_links_reads_a_bare_tree_for_neither`; stderr has the two WARNINGs and no NOTE |
| `LinkSources.link_pins`: count a one-link chain's first link again | 1: `test_a_chain_of_one_link_is_no_first_link_read` |
| `_first_links_read`: count a one-link chain again | 1: `test_a_chain_of_one_link_is_no_first_link_read` |

Round 3 (23 tests, head a707d4b):

| hunk changed | tests that fail |
|---|---|
| `LinkSources.link_pins`: `if len(target.chain) > 1:` → `> 2` (the re-review's boundary mutant, which survived 31a6f21) | 1: `test_a_two_link_chain_read_from_stdin_counts_its_first_link` (`1 failed, 22 passed in 10.59s`); stderr has "first link alone, , which had" where the test expects "each link whose own declaration is read had" |

The existing `test_a_link_source_no_link_used_is_a_note_and_changes_no_exit` in `tests/test_naming_policy.py` holds the two-link NOTE sentence byte for byte, and it passes.

- origin/main 9f80939 (it holds the README, handbook and `tests/readme-cap.d/550.txt` of f6eaab7): clean. The re-review found it clean at 31a6f21 (tree 71ea0d30), and `test_repo_hygiene.py`, `test_readme_sections.py` and `test_cli_reference.py` gave `151 passed in 58.81s` on that merged tree, with `render-cli-reference.py --check` ok. The README cap there is 1427 = 1422 + 5 from main's `tests/readme-cap.d/550.txt`, and this PR adds 0 README lines; its three handbook markers for "The three legs" are 39ec2c23 on the merged tree. a707d4b changes the test file only: merge-tree of it with 9f80939 is clean (tree dfc650f). Earlier mains were clean too: 7d9bec9 (after #552, #553, #554, #561 and #562; `415 passed` on the merged tree) and bbcfd34 (tree 10acc9c). The two `docs/cli.md` regenerations (this PR's `--link-source` help, #561's `bump-leg.py`) touch different tools' sections.
- Lane 2's `fix/539-validators-lockstep-remedies` (#575, 93b68f6), now on origin: clean. The reviewer ran the lockstep-lines test, the new file, `test_validate_pins_without_git.py` and `test_validate_pins_unreadable_workflow.py` on the merge with 04c9d77: `103 passed in 48.45s`. This commit does not touch `validate-pins.py` or any of those tests. The `validate-pins.py` hunk stays inside `PinSources`, away from #539's regions (`:99-544` and `:1109-1143`).
- Lane 2's `fix/540-pin-source-unread` (#570, 79dfd33): clean. On a merge with 04c9d77, the new file, `test_pin_source_unreadable.py`, `test_scaffold_referent_chain_links.py`, `test_scaffold_leg_path_collisions.py`, `test_cli_reference.py` and `test_adopt_pins.py` gave `371 passed`. #540 counts any `--referent-chain` as a reader of `--pin-source`; that stays true, because every chain's first link reads it. One sentence of #540 needs a word once both are in: `pin-source-unused` says the clone is read for "each --referent-chain link's declaration" (`scripts/shape_materialize.py:2706-2707` and the docstring at `:2681-2683`, and `tests/test_pin_source_unreadable.py:445`, the `UNUSED` text at `:455-457` and `:498`). After this PR it is the FIRST link's alone. Whichever lands second should change "each" to "the first".
- The other PR heads #556 to #582 that git still serves (23 heads; #561 is on main): all clean, except #577 (`install.sh`, d0cdbf4). #577 conflicts with main itself, in `docs/handbook.html` and `tests/test_repo_hygiene.py`; against this head the same two files conflict. This PR is not the cause. Both edit `README.md` and the handbook's hash markers, so they land one at a time. Only #575 touches a file this PR changes (`validate-pins.py`, clean).
- Lane 5's other branches: #568 `fix/family-bump-to-checks` 8024f9c, #571 `fix/family-remove-gitmodules-missing` b1120c3, #574 `fix/family-members-case-variants` 40eda84 and #578 `fix/family-siblings-tracking-branch` 43d32eb: clean.

- The scaffold's and `adopt-project.py`'s own `--pin-source ''` (see the checklist candidates).
- `validate-pins.py`'s `--help` and module docstring: nothing in them became false, and the module docstring sits near lines #539 edits.
- Making `validate-pins.py` refuse the empty path before its leg and shape-pin checks: it would put the hunk in `_run_all_checks`, where #539 edits.
- The v1.0.0 release notes keep #441 and #442 as known limits of 1.0.0, which they are.

- **`scaffold-project.py` and `adopt-project.py`: `--pin-source ''` is `Path("")`, so the current directory is read as the pinned product's clone.** Both take `--pin-source` as `type=Path`, and `shape_materialize.declared_pin_values` computes the digest from it. The scaffold also hands it to `link_pins_from_trees` as the first link's tree. Run from the product's own clone, `scaffold-project.py --pin openXdox@<sha> --pin-source '' --referent-chain openXdox,openDox --dry-run` plans with `local-clone (.)`, exit 0; run from anywhere else it reads whatever directory it stands in. This is #442's shape in the two tools that create a project, and neither was in #442. (The review confirmed it for the scaffold on the head; `adopt-project.py` was not re-run.)
- **The scaffold's `declared-unverified` warning names remedies the scaffold does not have.** It says "(a sibling checkout, SHAPE_PIN_SOURCE_<LINK>, or --link-source <link>=<path> would let it be read)". `scaffold-project.py` has no `--link-source` (none in its `--help`) and reads no sibling checkout: `link_pins_from_trees(chain, None, ...)` at `scaffold-project.py:799` passes no root. The text is `_chain_link_findings`' (`scripts/repo_shape.py:1419`). It was already printed on bfed143 for a two-link scaffold with no manifest. Since this PR, every chain of three or more links scaffolded with `--pin-source` alone prints it for the second link, where only `SHAPE_PIN_SOURCE_<LINK>` helps. The wording should name only the remedies the caller has.
- **`tests/test_cli_reference.py` alone writes `templates/family-root/scripts/__pycache__/bootstrap.cpython-312.pyc`** (proved on 04c9d77 and on two merged trees: `22 passed`, then the file is there). A `.pyc` under `templates/` breaks `template_values()` and its twin in `test_scaffold_leg_path_collisions.py` in any later adoption run in the same tree: round 1's 27 failures were the same kind of break, from a `.pyc` under `templates/assembly-root`. #554's `load_without_bytecode` does not cover it. It is outside #441 and #442, and is named here only.

Review: rv441 found 5 nits on 04c9d77 (APPROVE WITH NITS), fixed in 31a6f21; re-review rv441b found all five fixed or answered and 2 new nits (a test gap at the two-link boundary of `link_pins`, and this body), fixed in a707d4b. CI (Brett Heap's ruling 27): H0 a707d4b passed tests, tests-macos, tests-windows and SonarCloud; the head 8971ad5 is a clean update-branch merge of main bcfa364, with tests and SonarCloud green, and the local gate passed on its predicted squash: the scaffold, pins, naming, link-source, setup-by-hand and hygiene sets, 1174 passed, over bcfa364; then the hygiene, AGENTS.md-code, README-section, CLI-reference, link-source and naming sets, 468 passed, over 526cd2c; and 334 passed over b9f65be. Landed on Brett Heap's word of 2026-10-15T13:32:47Z, "All five, once green".

Closes #441
Closes #442

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 15, 2026
…n-source-unread

Merges origin/main 66267f0 so the PR's CI merge ref and the fix that follows
run on current main. The fourteen landings since 2bf8fd4: #597, #578, #593,
#589 (#449), #586, #573 (#537), #575 (#539), #566 (#528), #571, #588, #569
(#538), #582 (#523), #616 and #576 (#441, #442).

Those that touch this PR's files:
- adopt-project.py: #589 (`check` recomputes each pin's digest in the clone
  `plan` read it from: LOCAL_CLONE_RE, _recorded_clone, _why_not_rechecked,
  _recomputed_pin_finding, _pin_digest_findings), #582 (what `plan`,
  `execute` and AGENTS.md say around a run) and #566 (a source's own
  `single-repository.yaml`).
- scaffold-project.py and docs/cli.md: #573, #569 and #576 (the
  `_declaration` docstring: `--pin-source` verifies the first chain link
  alone, which this PR's N3 wording already says).
- tests/test_adopt_pins.py: #589's thirteen cases.
- tests/test_scaffold_leg_path_collisions.py: #573.

This commit holds the textual resolution only. One hunk conflicted, the
`from shape_materialize import (...)` list at adopt-project.py:261. It is
resolved as the union of both sides' changes to the list: main's added
`pin_digest_from_source` (#589), and this PR's added `DIGEST_FROM_CLONE`,
`DIGEST_FROM_GH`, `pin_source_path` and `refuse_an_unread_pin_source` and
its removal of `one_printable_line`, whose last caller this PR deleted and
to which main added none (keeping it is pyflakes' "imported but unused").
pyflakes adopt-project.py is clean on the merge. Every other file merged
without a marker, and `scripts/render-cli-reference.py --check` is ok.

The merge alone does not keep #589 working: this PR's `plan` records a bare
`local-clone`, which names no clone for #589's `check` to read, and `check`
refuses `local-clone (<path>)` as `plan-pin-malformed`. The next commit
reconciles the two.

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