Skip to content

bootstrap.py, validate-family.py and siblings.py: a root or holder whose .git this user cannot read is refused git-repo-unreadable, exit 2, not a traceback or not-a-git-repo, and the doctor's members row names it once, never drift (#1148) - #1172

Open
brettheap wants to merge 16 commits into
mainfrom
fix/1148-pinned-find-repo-root-refuses

Conversation

@brettheap

@brettheap brettheap commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What

The four other pinned callers of find_repo_root, which #1146 left as they were (its Known limits): the assembly root's scripts/bootstrap.py, and the family holder's validate-family.py, bootstrap.py and siblings.py. Also the doctor's members row over a holder whose own .git cannot be read.

Was stacked on #1147 (base fix/1146-root-git-unreadable-not-drift, whose head was b75babee when this was measured and reviewed). #1147 has landed, as its squash 51a9c5f1 on main, and this branch was restacked onto main by real merges, with its own change unchanged (see "Restacked after #1147 landed"). This PR is outside v1.2.0 by Brett Heap's ruling of 2026-10-21T10:32:04Z ("leave the follow-ups out of v1.2.0"). It lands after the tag, on a later word.

Measured in a scaffold's recursive clone and a family holder, with base b75babee (#1147's head then) and this head, on Python 3.12.3. It was also measured under a 3.14-like sitecustomize, in which Path.is_file, is_dir, exists and is_symlink answer False to any OSError. Each tool ran from the root, as make runs it.

Tool .git a link into a folder at 000 .git a directory at 000 readable
assembly bootstrap.py, base 3.12: PermissionError traceback, exit 1. Shim: REFUSED not-a-git-repo: no .git found at or above <root>, exit 2 step (a) says no gitlink recorded; leaving as is of each leg. Then pins refuses git-repo-unreadable and bootstrap REFUSED, exit 2 bootstrap ok
assembly bootstrap.py, head REFUSED git-repo-unreadable: .git: cannot be read (Permission denied) and the chmod cure, exit 2, no traceback, nothing on stdout, both Pythons the same refusal, before anything is placed bootstrap ok
validate-family.py (and --no-members), base 3.12: traceback, exit 1. Shim: not-a-git-repo, exit 2 REFUSED gitlinks-unreadable over git's not a git repository, with the remedy to ask git status, exit 2 family ok
validate-family.py (and --no-members), head REFUSED git-repo-unreadable: .git: cannot be read (Permission denied), exit 2, both Pythons the same family ok
family bootstrap.py, base 3.12: traceback, exit 1. Shim: not-a-git-repo, exit 2, with or without --no-fetch --skip-members plain: FETCH INCOMPLETE (git exit 128) with the advice to set SHAPE_LEGS_TOKEN, each member fails its pins, family bootstrap FAILED, exit 1. With --no-fetch --skip-members: family bootstrap ok, exit 0, over a run that read nothing family bootstrap ok
family bootstrap.py, head REFUSED git-repo-unreadable: .git: cannot be read (Permission denied), exit 2, both Pythons, with or without the flags the same, with or without the flags family bootstrap ok
siblings.py (plain and --dry-run), base 3.12: traceback, exit 1. Shim: not-a-git-repo, exit 2 places every member, siblings ok, exit 0; the holder's branch shown ? siblings ok
siblings.py (plain and --dry-run), head REFUSED git-repo-unreadable: .git: cannot be read (Permission denied), exit 2, both Pythons unchanged, by design: places every member, exit 0 siblings ok
the doctor over the holder, base 3.12: INVALID (family). The family row says crashed ... PermissionError, and members says .git: cannot be read through run_checks' generic handler. Shim: DRIFTED (a member is not at its pin), members says checked out at ? DRIFTED (a member is not at its pin) on both Pythons; family refuses gitlinks-unreadable COMPLIANT
the doctor over the holder, head INVALID (family), both Pythons. members: FINDING .git: cannot be read (Permission denied), so no gitlink is read and no member is compared with its pin, NEXT this doctor again, WHY the chmod cure. family: the project's own scripts/validate-family.py refuses (exit 2): REFUSED git-repo-unreadable: .git: cannot be read (Permission denied) the same COMPLIANT

The doctor's other family rows (manifest-kinds, shape-currency, agent-files) were ok in every state, on both sides. placement is unchanged (placement is n/a for a holder).

Lane 1's ruling. The family bootstrap's new refusal under --no-fetch --skip-members over a .git directory at 000 is ACCEPTED. Base said family bootstrap ok there, over a run that read nothing. Head refuses git-repo-unreadable, exit 2, as the other tools that read the holder's repository through git do.

The gate's regression, fixed in the test's fixture. Lane 1's gate found tests/test_validate_family_unreadable.py failing 6 of 53 at 05e6ab0f, where base passes all 53.

  • What failed. Each row expects REFUSED member-unreadable: members/IRRS/.git: cannot be read (Permission denied), and got REFUSED git-repo-unreadable: .git: cannot be read (Permission denied).
  • The tool was right and the fixture was stale.
    • Those rows run validate-family.py's main in process after refused_stat(monkeypatch, ".git"). That call refuses the stat of every path named .git: the holder's own as well as the member's.
    • On base, only find_repo_root asked the holder's .git, and the file's gate() stubs it to answer the holder.
    • This PR's _run_checks asks _refuse_an_unreadable_root(start) first. The stub did not cover it, so it met the holder's refused .git and refused it by its own name, .git.
  • The fix. gate() now gives the holder as the whole root: find_repo_root answers it, and _refuse_an_unreadable_root asks nothing. No assertion changed.
  • A real run never reaches a member. The walk asks only the start and the folders above it. The file's mode rows (member-000, members-000, variant-000, staged-removal-000) run the whole gate in a subprocess, the new check included. They passed at 05e6ab0f too.
  • The other three tools. Each carries its own copy of the walk, from the same start upward. None of their tests runs main in process after refusing .git by name. Their .git rows call one function alone (checkout_tracking_branch, unfetchable, run_member_target, fetch_members, _print_pinned_row), and the gate passed them.

Why

Tests

tests/test_pinned_root_git_unreadable.py, new, has 76 rows.

  • Mode cases. A real mode, each tool run as a person runs it. They skip on Windows and as root (CANNOT_RUN_AS_ROOT), and restore the mode after:
    • the issue's case, a .git linked into a folder at 000, for each tool;
    • .git at 000 and at 444, for each git reader;
    • a gitfile into a locked folder, named by its git directory, whole;
    • the doctor over the holder, link and directory: INVALID (family), members held whole, never checked out at ?.
  • Stand-in cases. In process, on every platform and as root (refused_stat, refusing, as_python_314):
    • each tool, both causes (permission and I/O error);
    • the walk, per tool: a .git above the --root given, named whole; the same under a folder whose name holds an ESC, the whole path spelled; a relative --root . run from contracts/; a .git above the root never asked; a stop at a readable gitfile, and at a gitfile that names no directory (added after review r1: test_the_walk_stops_at_a_gitfile_that_names_no_directory, one id per git reader);
    • per git reader, what .git is or names: a git directory's HEAD, a gitfile that cannot be opened, a git directory outside the root and inside it, a gitfile ending in CR LF, an unprintable folder (POSIX only);
    • siblings.py asks only the stat;
    • the members row in process, both causes, and drifted_reasons with a readable member beside an unread one.
  • Controls. A readable root and holder: COMPLIANT, each tool goes on past the question. Five roots that answer absent or that git answers for: no tool refuses. siblings.py over a .git directory at 000 still places the members.

Base vs head. Base is b75babee with the new file laid over it.

3.12.3 3.14-like shim
base b75babee 74 failed, 2 passed 74 failed, 2 passed
head 5aa0f116 76 passed 76 passed

The 2 rows that pass on base are controls: test_siblings_still_places_the_members_of_a_holder_whose_git_is_at_000 and test_roots_whose_git_can_be_read_are_unchanged.

The other rows fail on base for two reasons:

  • 38 on base's own answers (unchanged by the review round): the 3.12 traceback and the shim's not-a-git-repo (link at 000, every tool, in process, --root contracts, --root ., and the ESC folder); no gitlink recorded, gitlinks-unreadable, gitmodules-unreadable and FETCH INCOMPLETE (the git readers at 000, 444 and through a gitfile); DRIFTED (a member is not at its pin) and a members row that is ok, not FINDING; drifted_reasons counting the unread entries;
  • 36 only on a name base does not have (_refuse_an_unreadable_root, AttributeError): the 33 before review r1, and the 3 ids of test_the_walk_stops_at_a_gitfile_that_names_no_directory.

Mutants: 58 of 58 killed (the 55 below, and the 3 review r1 added). Each was applied once to a scratch clone of the head (never the worktree), the new file was run there with -x on 3.12, and the file was put back in a finally. The clones were checked clean after.

  • Per git reader (A, V, F), the mutants were: the call removed; continue made return on absent; no .resolve(); the final return removed; CR not stripped; no normpath; the git directory named as .git; the HEAD step removed; relative naming removed or named against the candidate; the ValueError fallback removed; spelled removed (F); the code changed; the reads or what text dropped.
  • siblings.py (S): the call removed; the return removed; an absent .git stopping the walk; no resolve; the relative naming and its fallback; spelled removed; the code dropped or ignored; the what text dropped.
  • The doctor (D): no root_git_read in check_members; the drift filter removed; the null keys dropped; why or next None; status ok; the reason changed; the line not carried.
  • Review r1's three (issuecomment-6351621763). It ran 42 of its own 85 mutants: 39 killed and 3 survived. The 3 were A-, V- and F-gitfile-naming-nothing-continues: in each git reader, the return made continue where .git is a gitfile that does not start with gitdir: . The 73 rows had no row that held that line. test_the_walk_stops_at_a_gitfile_that_names_no_directory was added for them: the root's .git is a gitfile reading no gitdir, the parent's .git is refused with as_python_314, and _refuse_an_unreadable_root(root) answers None. Run in a scratch clone of this tree, the file with -x on 3.12.3, each mutant is KILLED by its own tool's id: [assembly-bootstrap], [validate-family], [family-bootstrap] (1 failed, 45 passed, 1 failed, 47 passed, 1 failed, 46 passed), restored in a finally. The review's other 43 mutants were not run by it and were not run again here.
  • 54 were killed by the file's first 69 rows. S-spelled-removed survived those (69 passed), because the only unprintable row was the git readers'. The row test_a_root_git_named_whole_is_spelled was added for it, over all four tools, and its [siblings] id kills it.

The regression in tests/test_validate_family_unreadable.py, on 3.12.3:

b75babee (base) 05e6ab0f (before the fix) 3a389565 (the fix; 53 passed again at the head 5aa0f116, measured by r1172b)
53 passed 6 failed, 47 passed 53 passed
  • The six rows: test_a_path_whose_stat_is_refused_is_refused_by_name at [member-checkout], [member-checkout-pins], [unregistered-member-checkout], [staged-removal-checkout] and [variant-checkout], and test_a_stray_whose_rm_takes_no_f_asks_nothing_of_its_git.
  • Two mutants of the fix, each in a scratch clone of the head, with the files put back in a finally:
    • The new stub removed. Killed by test_a_path_whose_stat_is_refused_is_refused_by_name[member-checkout], and by the other five, as before the fix.
    • validate-family.py's check walking from members/IRRS instead of the start. Killed by the mode row test_a_folder_this_user_cannot_search_is_refused_by_name[member-000]. A root check that reached a member's .git is still seen with the stub in place.
  • The new file, again at 3a389565: 73 passed on 3.12.3, and 73 passed under the shim. At 5aa0f116, with review r1's 3 rows: 76 passed on 3.12.3, and 76 passed under the shim; tests/test_validate_family_unreadable.py, 53 passed.

The gate, all five calls, on 3.12.3.

  • How it ran. Each call was collected to node ids first (--collect-only; the count of ids equals pytest's own "collected" line). The ids were then run in shards of at most 300 ids, each under 9 minutes, as a person runs them: no gh, a stub gh first on PATH, GIT_SSH_COMMAND=false.
  • Which head. The calls ran at 05e6ab0f. The fix that followed, 3a389565, touches only tests/test_validate_family_unreadable.py. After review r1 the new call ran again at 5aa0f116 (the head, which adds 3 rows to the new file and nothing else).
    • Only the big-22 shard holds that file, and no other test imports it.
    • So new and big-22 ran again at 3a389565, and the other shards stand.
    • tests/test_family_hand_mount_git_rm_lines.py, which names the file in its docstring, also ran again at 3a389565: 29 passed.
Call Files Ids Result
new test_path_guard_hygiene, test_root_git_unreadable, test_pinned_root_git_unreadable, test_doctor_unreadable_pin, test_doctor_unreadable_paths 255 255 passed at 05e6ab0f, 255 again at 3a389565, and 258 passed at 5aa0f116 (3 rows added)
docs test_scaffold_leg_path_collisions and the doc trio (test_agents_md_codes, test_cli_reference, test_repo_hygiene) 274 274 passed
pin test_pinned_copy_text, test_shape_pin_*, test_update_shape* (22 files) 945 in 4 shards 945 run: 237 passed, 234 passed with 3 skipped (junctions are NTFS's, test_update_shape_add_symlink.py:832), 237 passed, 234 passed; 0 failed
sd test_shape_doctor.py, in 4 shards 448 445 passed, 3 skipped (the PowerShell line, printed only on a Windows host), 0 failed
big the 116 other files naming validate-family.py, siblings.py or bootstrap.py, and 6 that read the members row or its keys 8076 in 27 shards 8037 passed, 39 skipped, 0 failed, with big-22 at 3a389565 (300 passed). At 05e6ab0f, big-22 failed 6 of 300: the regression above. The other 26 shards passed

The 39 skips in big are this bench's:

  • git 2.43 here, where CI runs 2.55 (and the reftable backend needs 2.45);
  • rows for Windows or macOS only;
  • GNU make's jobserver form;
  • rows whose template already quotes the value, so the rule has nothing to refuse.

The new file was also run in full under the 3.14-like shim: 73 passed, at 05e6ab0f and again at 3a389565, and 76 passed at 5aa0f116. tests/test_path_guard_hygiene.py with tests/test_repo_hygiene.py: 149 passed at 5aa0f116. The pin, docs, sd and big calls were not run again after review r1, since the only file it changes is the new test file, which no other test imports.

Pinned copy

The four files are in scripts/shape_materialize.py's COPIED_VERBATIM (the assembly bootstrap) and FAMILY_COPIED_VERBATIM (the three holder files). No file is added to or taken from a list, and no digest of them is recorded in the tree, so there is no pin-record edit, as in #1147. Measured against a standard one commit past the pin of a project and a holder scaffolded from base:

Merge with #1147's new head

#1147's head was then a2fc1da8: main with #1200 merged in (569ea056), then placement giving the root's line ahead of #1200's refusal of a leg's git directory. It is merged here as 2eabec8e ("Merge #1146's branch into #1148's branch"), parents 5aa0f116 and a2fc1da8, with no conflict. git merge-tree gave tree c473b237, and the merge commit has that tree. Four files changed on both sides since the merge base b75babee (shape-doctor.py, both bootstrap.py files and validate-family.py), and git merged each with no conflict. Compared line by line, per file, the merge adds to this PR's 7 files exactly the lines a2fc1da8 brings, and keeps exactly the lines this PR added: 902 added and 26 removed, as before.

The members row and #1200 agree. #1200 names a member whose own git directory cannot be read, through mount_read in member_state. This PR's check comes first in check_members: with the holder's own .git unreadable, root_git_read answers and the row returns before any member is read. So the two never meet, and the order is the one a2fc1da8 gives placement: the root's line first. On the merge:

Tests on 2eabec8e, in the probe environment, on Python 3.12.3 and git 2.43.0:

Call Files Result
own tests/test_pinned_root_git_unreadable.py, tests/test_validate_family_unreadable.py 129 passed
unread tests/test_root_git_unreadable.py, tests/test_doctor_unreadable_paths.py 118 passed
scans test_path_guard_hygiene, test_no_auto_maintenance, test_scaffold_leg_path_collisions 283 passed, 2 skipped (test_no_auto_maintenance.py:222: the session began with GIT_CONFIG_GLOBAL set)
members 8 of the ids above: the members rows of both PRs and the readable control, with each row logged 8 passed

That is 532 ids: 530 passed and 2 skipped. Lane 1's gate on a2fc1da8 covers #1147's side: 1,018 passed and 5 skipped. The pin, docs, sd and big calls above were not run again on the merge.

Restacked after #1147 landed

#1147 landed as 51a9c5f1 on main, the squash of its final head 95ea5646. That head is update-branch's merge of main 801a7d6d into 4d299dd3, #1147's last fix: placement gives the root's line only to a leg whose mount mount_read got into, so a mount this user can search and not list keeps spec: cannot be listed. The squash's tree, 2d6db67c, equals 95ea5646's. The branch was restacked from 2eabec8e by real merges, with no rebase, squash, amend or force-push:

  1. 81bc91f8 merges 95ea5646. No conflict. Its tree, f48d7e9e, is the one git merge-tree --write-tree gave beforehand. It brings 4d299dd3 (shape-doctor.py, and a row in tests/test_root_git_unreadable.py) and main's 18 commits after a2fc1da8.
  2. ba81fd7d merges the squash 51a9c5f1, with this branch's side kept for shape-doctor.py, validate-pins.py and validate-manifest.py: a root whose own .git this user cannot read is named .git: cannot be read (<reason>), so the doctor never reads it as DRIFTED, and both pinned validators refuse it, exit 2 (#1146) #1147's files. Those are the five files of git diff --name-only 51a9c5f1^ 51a9c5f1: shape-doctor.py, validate-pins.py, validate-manifest.py, tests/test_doctor_unreadable_paths.py and tests/test_root_git_unreadable.py. git ls-files -u was empty, and git write-tree gave f48d7e9e, the tree of 81bc91f8: the merge changes no file.
  3. No merge of main was needed. Fetched again before this step, at 18:34Z, origin/main was still 51a9c5f1.

The PR's own change is unchanged. The + and - lines of git diff -U0 b75babee 5aa0f116 (the delta r1172b found READY) and of git diff -U0 51a9c5f1 ba81fd7d are the same 928 lines, in the same 7 files and the same order: 902 added and 26 removed. Both have sha256 9eef7395.

Tests on ba81fd7d, in the probe environment, on Python 3.12.3 and git 2.43.0:

Call Files Result
own tests/test_pinned_root_git_unreadable.py, tests/test_validate_family_unreadable.py 129 passed
unread tests/test_root_git_unreadable.py, tests/test_doctor_unreadable_paths.py, tests/test_doctor_unreadable_pin.py 171 passed
scans test_scaffold_leg_path_collisions, test_path_guard_hygiene, test_no_auto_maintenance 283 passed, 2 skipped (test_no_auto_maintenance.py:222: the session began with GIT_CONFIG_GLOBAL set)
docs test_agents_md_codes, test_cli_reference, test_repo_hygiene 171 passed
sd tests/test_shape_doctor.py, 449 ids in 4 shards 446 passed, 3 skipped (test_shape_doctor.py:10516: the PowerShell line, printed only on a Windows host), 0 failed

That is 1,205 ids: 1,200 passed and 5 skipped, none failed. The pin and big calls above were not run again on the restack.

Known limits

Not in this PR

Closes #1148

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Summary by Sourcery

Refuse unreadable repository roots consistently across pinned family tools and report them accurately in the shape doctor's members check.

New Features:

  • Refuse unreadable repository roots consistently across the remaining pinned bootstrap, family validation, and sibling tools with the git-repo-unreadable error and exit code 2.
  • Report an unreadable family holder in the doctor's members row once without treating its members as drift.

Bug Fixes:

  • Prevent permission errors in repository-root discovery from becoming tracebacks, misleading missing-repository errors, or misleading fetch and gitlink failures.
  • Ensure unreadable holder repositories are detected before any family members are fetched, validated, cloned, or compared.

Enhancements:

  • Align root readability checks across pinned copies while preserving siblings' ability to place members when only repository contents are inaccessible.
  • Keep unreadable-member details structurally complete and exclude them from drift-reason aggregation.

Tests:

  • Add comprehensive mode-based and in-process coverage for unreadable root git links, directories, gitfiles, path traversal, error causes, doctor output, and unchanged readable-root behavior.
  • Update validate-family unreadable-path fixtures to account for the new root check.

Brett Heap and others added 7 commits October 22, 2026 02:33
…ose own `.git` this user cannot read is named, `.git: cannot be read (<reason>)`, so the doctor says INVALID and not DRIFTED, and both pinned validators refuse it by name, exit 2, with no traceback (#1146)

The case: an assembly root whose `.git` is a link into a folder at mode 000.
On main 038dafb the doctor said DRIFTED (2 leg(s) not at the pin), its
`legs` row said `no gitlink recorded` of each leg, and the root's own
`scripts/validate-manifest.py` and `scripts/validate-pins.py` ended in a
`PermissionError` traceback out of `find_repo_root` (3.12), or refused
`not-a-git-repo` (3.14-like shim).

- validate-pins.py and validate-manifest.py (pinned copies): each `.git`
  `find_repo_root` would ask is asked first with the file's own `_is_a`,
  and one whose `stat` is refused is `git-repo-unreadable`, exit 2, in the
  file's `_unreadable_file` words. validate-pins.py, which reads the
  gitlinks through git, also asks the first `.git` that is there what it
  asks a leg's (#968): a gitfile it cannot open, a git directory whose
  `HEAD` it cannot reach.
- shape-doctor.py: `root_git_read` asks the same; the `legs` row says the
  gate's line once, NEXT this doctor again, WHY the gate's advice, each leg
  keeps it in `gitlink_cannot_be_read`, which `drifted_reasons` does not
  count; `placement` says it, n/a.
- tests/test_root_git_unreadable.py: 35 rows, mode and stand-in.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… legs' `.git`, since a root whose own `.git` is refused too is #1146's case, which the `legs` row names once; the new file's one-path stand-in answers `is_file` and `is_dir` as 3.14 and puts `sys.path` back (#1146)

`test_a_mount_whose_git_cannot_be_reached_is_not_one_without_a_git` used
`refused_stat(monkeypatch, ".git")`, which refuses every `.git` by name, the
root's own among them. Its case is a mount this user can list and not
search, so it now refuses `spec/.git` and `code/.git` alone, by path
(`as_python_314`), and says the same lines it always did, on base and head.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… audits a leg whose own repository git reads, and gives the root's line only to a leg git cannot read through it; each `legs` entry carries every key, null; the tests pin both gates' walk (#1146)

r1147's S2. `placement` declined every leg once the root's `.git` could
not be read, so a leg with a `.git` directory of its own lost the path in
the wrong leg the base reported, and the verdict read `INVALID (pins)`
where the ladder's answer is `MISPLACED (1 path)`. `audit_leg` now gives
`root_git_read`'s line to a leg whose `.git` is there and leads to a
repository git names no top level for (`gitlink_cannot_be_read`, and its
`state`); a leg git reads is audited, a leg with no `.git` of its own
keeps git's answer, and a leg refused for its own reason keeps that. The
row says the root's line once, `n/a`, only where it is every leg's.

N7. The `legs` row's entries for an unreadable root carry `pin`,
`populated`, `head`, `own_git` and `unborn`, each null, beside `gitlink`,
so a reader of `--json` finds every key a leg's entry has.

tests/test_root_git_unreadable.py, 35 rows to 45:
- S1: per gate, a relative `--root .` run from `contracts/`, and the walk
  stopping at a readable gitfile with a `.git` above it refused;
- N1: a gitfile ending in CR LF, and one naming an unprintable folder;
- N2: `placement`'s `--json` entries for the root's line;
- N3: `validate-pins.py`'s git-directory step under a subfolder `--root`;
- S2: a leg audited beside one read through the root (MISPLACED), a leg
  with no `.git` of its own, and an in-process row on every platform.
The stand-in row that refuses every `.git` now expects each leg's own
line in `placement`.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as root, only a leg git names no repository for is given the root's line, and each `legs` entry for an unreadable root is held whole, nulls included (#1146)

The rule `audit_leg` follows, and the `legs` entries' keys (r1147's N7),
were held only by the mode cases, which skip on Windows and as root. One
row puts git's answers in by hand beside a refused root `.git`: the code
leg, whose `.git` is there and git names no top level for, is given the
root's line; the spec leg keeps git's answer where git answers from
another repository, or where it has no `.git` of its own. The stand-in
row that refuses every `.git` now compares each `legs` entry whole.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and the doctor's members row, with a new test file not yet run

Work in progress, stacked on #1147 (b75babe).

Code, smoke-measured by a probe on 3.12 and a 3.14-like shim:
- templates/assembly-root/scripts/bootstrap.py, templates/family-root/
  scripts/validate-family.py and bootstrap.py: `_refuse_an_unreadable_root`
  asks each `.git` find_repo_root would ask, then what validate-pins.py
  asks of the first that is there (a gitfile it cannot open, a git
  directory whose HEAD it cannot reach); `git-repo-unreadable`, exit 2, in
  each tool's own words.
- templates/family-root/scripts/siblings.py: the stat step only, as
  validate-manifest.py asks it; `member_unreadable` takes a `code`.
- shape-doctor.py: `members` says a holder's unreadable `.git` once
  (`members_git_unread_row`), and `drifted_reasons` counts no member that
  carries `gitlink_cannot_be_read`.

tests/test_pinned_root_git_unreadable.py is NEW and HAS NOT BEEN RUN: it
may fail. No mutants, no gate yet.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, from a folder under one whose name holds an ESC, is spelled by each of the four tools, so dropping `spelled` from siblings.py's own call is seen (#1148)

The 55th mutant, `S-spelled-removed`, survived the file as it stood: its
only unprintable row was the git readers' gitfile row. The new row walks
from `<ESC folder>/root/contracts` with the root's `.git` refused, as
Python 3.14's `exists()` answers it, and expects the whole path spelled.
Base: 4 failed (`not-a-git-repo`); head: 4 passed, 3.12 and the 3.14 shim.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the holder as its root whole, so `_refuse_an_unreadable_root` asks nothing there and a row's `.git` refused by name is a member's again, `member-unreadable` (#1148)

Lane 1's gate of #1148 failed 6 of 53 here, 53 of 53 on #1147's head
b75babe: `REFUSED git-repo-unreadable: .git: cannot be read (Permission
denied)` where each row expects `REFUSED member-unreadable:
members/IRRS/.git: ...`.

The cause is the fixture, not the tool. `refused_stat(monkeypatch, ".git")`
refuses the `stat` of EVERY path named `.git`, the holder's own with the
member's. `gate()` stubbed only `find_repo_root`, the one asker of the
holder's `.git` on base (validate-family.py:4193 there). #1148's
`_run_checks` asks `_refuse_an_unreadable_root(start)` first
(validate-family.py:4258), which the stub did not cover, so it met the
holder's refused `.git` and answered for it, named `.git`, relative to the
holder. The six rows are the ones that run `main` in-process after that
refusal; the rows that call one function alone never reach `_run_checks`.

In a real run the walk asks only `start` and the folders above it, never a
member: the mode rows (`member-000`, `members-000`, `variant-000`,
`staged-removal-000`, ...) run the whole gate in a subprocess, the new check
included, and passed on #1148's head before this change.

`gate()` now gives the holder as the root whole: `find_repo_root` answers
it and `_refuse_an_unreadable_root` asks nothing. No assertion changes.
The holder's own `.git` stays held by
tests/test_pinned_root_git_unreadable.py (73 passed).

Mutants, in a clone: the stub removed is killed by
`test_a_path_whose_stat_is_refused_is_refused_by_name[member-checkout]`
(and the five others); validate-family's root check walking from
`members/IRRS` is killed by
`test_a_folder_this_user_cannot_search_is_refused_by_name[member-000]`.

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

sourcery-ai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implementation

Add root .git readability checks to the pinned bootstrap, family validator, and siblings callers so an unreadable repository is refused by name before those tools proceed.

  • templates/assembly-root/scripts/bootstrap.py L117-120, L468-523, L2094-2101
  • templates/family-root/scripts/bootstrap.py L184-187, L399-462, L2411-2414
  • templates/family-root/scripts/siblings.py L367-415, L1608-1611 (+4 small edits)
  • templates/family-root/scripts/validate-family.py L522-527, L532-590, L4330-4333 (+2 small edits)

Make the doctor report an unreadable holder .git once in the members finding and exclude those entries from drift reasons.

  • shape-doctor.py L11214-11245, L11260-11268, L12696-12702 (+2 small edits)

Tests

Add coverage for unreadable root repositories across the pinned callers and doctor, and adapt the gate fixture to distinguish holder-level from member-level .git refusals.

  • tests/test_pinned_root_git_unreadable.py L1-562
  • tests/test_validate_family_unreadable.py L51-56, L100-104, L109-110

Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-22T21:23:31Z) Independent review r1172 of #1172 at 3a389565, partial, cut short when the shared window reached STOP: NOT READY as far as it got. It found no MUST and one SHOULD.

  • SHOULD: in all three git readers, no row holds the line that stops the walk at a .git gitfile naming nothing. Three mutants survive; one cheap row fixes it.
  • NITs: 4.
  • f1148's fixture stub: sound.
  • The four tools: they match the body's table on 3.12 and under the 3.14 shim.

Lane 1 runs the fix round in a later window.

Review of #1172 (r1172, lane openRepoShape-1): partial, cut short by STOP

Verdict: NOT READY, as far as I got. I found no MUST. There is one SHOULD: a key line in all three git readers has no test that catches a change to it. It needs one cheap test row. Coverage is incomplete; see the last section.

State at stop: no runs are live. Each mutant was restored and its blob checked against HEAD. git status --untracked-files=no shows nothing in clone, clone2 or the review checkout l1-r1172, all at 3a389565a.

Your three points

1. f1148's stub (commit 3a389565): sound
  • Before the stub: at 05e6ab0f, tests/test_validate_family_unreadable.py gave 6 failed, 47 passed. They are the six rows the body names, each failing with REFUSED git-repo-unreadable: .git: cannot be read (Permission denied).
  • Base: b75babee gave 53 passed.
  • Head: 3a389565 gave 53 passed on 3.12 and 53 passed under my 3.14-like shim ($S/shim314/sitecustomize.py).
  • The 05e6ab0f run is the "stub removed" mutant. That commit is head without the stub.
  • The stub cannot hide a member's .git. The real walk asks only [path, *path.parents] of the resolved start, never anything below it. So the stub takes away only the holder's own .git, which refused_stat(".git") refuses as a side effect.
  • The subprocess mode rows still hold a member's .git to member-unreadable. They run the gate with no stub, the new check included, and passed in both head runs above: member-000, member-444, members-000, unregistered-member-000, staged-removal-000, other-checkout-000, variant-000 and hand-mount-committed-444.
  • Not re-run: the body's second fixture mutant (the walk started from members/IRRS).
2. The four tools: no wrong refusal, missed refusal or wrong wording found

I probed each tool as make runs it, from the root, on base and head, on 3.12 and under the shim, in seven states: readable, link into a folder at 000, .git at 000, .git at 444, gitfile into a locked folder, gitfile at 000, and HEAD at 000.

  • The body's What table: every cell matches, both Pythons.
    • Base 3.12, link: traceback, exit 1. Base shim, link: not-a-git-repo, exit 2.
    • Base, .git at 000:
      • the assembly bootstrap said no gitlink recorded, then bootstrap REFUSED: pins (exit 2);
      • validate-family.py refused gitlinks-unreadable;
      • the family bootstrap said FETCH INCOMPLETE, exit 1, and family bootstrap ok, exit 0, with --no-fetch --skip-members.
    • Head: REFUSED git-repo-unreadable, exit 2, nothing on stdout, for every refusal on both Pythons.
    • siblings.py over a .git at 000 places IRRS and says siblings ok, exit 0, on both sides.
  • States the body did not list, all as designed:
    • gitfile at 000: the three git readers refuse .git and siblings.py is unchanged;
    • .git at 444: refused;
    • gitfile into a locked folder: the git directory is named whole.
  • The doctor at head: INVALID (family) in the link, 000, 444, gitfile-locked and gitfile-000 states, on both Pythons.
    • members is a FINDING with the gate's line once, NEXT is this doctor again, and WHY is the chmod advice.
    • family is the holder's validate-family.py refusing git-repo-unreadable.
    • The readable holder is COMPLIANT. The base doctor matches the body.
  • Wording: each tool's stderr matches the test's BEFORE and its advice word for word. siblings.py alone leaves out "inside the root".
  • Bench note, not a PR fault: the plain (fetching) runs of the family bootstrap and siblings.py need protocol.file.allow=always here. git 2.43 refuses the file transport for submodules. With it set, the readable cells say family bootstrap ok and siblings ok.
3. The body's numbers
  • New file: head 73 passed on 3.12 and 73 passed under the shim. Base with the file laid over gave 71 failed, 2 passed on both. The two passing rows are the controls the body names. 33 of the 71 call the new function directly (4+4+18+2+5), which matches the body's 33. Confirmed.
  • Mutants: I ran 42 of my 85 and 39 were killed. The body's 55 of 55 is not confirmed, because my set includes one mutant family the body's set did not have, and it survives (SHOULD 1).

Findings

SHOULD 1: a gitfile that names no directory must stop the walk, and no row holds that
  • Where:
    • templates/assembly-root/scripts/bootstrap.py:511
    • templates/family-root/scripts/validate-family.py:569
    • templates/family-root/scripts/bootstrap.py:448
  • What it guards: each of these lines returns when .git is a gitfile that does not start with gitdir: . find_repo_root stops at that .git, so the check must stop there too.
  • Evidence: I changed each return to continue (r1172/mut/mutants.py, run by run.py with -x on the new file, 3.12). All three survived:
    A-gitfile-naming-nothing-continues  SURVIVED 27s restored=True
    V-gitfile-naming-nothing-continues  SURVIVED 27s restored=True
    F-gitfile-naming-nothing-continues  SURVIVED 26s restored=True
    
    With the mutant, a holder whose .git names nothing would be refused over a .git above it that find_repo_root never asks.
  • Fix: add one row over GIT_READERS, beside test_the_walk_stops_at_a_git_that_is_a_gitfile (line 424):
    • the root's .git is b"no gitdir\n" (the ABSENT arrangement);
    • the parent's .git is refused with as_python_314;
    • assert _refuse_an_unreadable_root(root) is None.
NIT 1: 15 control rows cannot pass on base
  • Where: tests/test_pinned_root_git_unreadable.py, lines 411, 424, 479 and 491. Those are test_a_git_above_the_root_is_never_asked, test_the_walk_stops_at_a_git_that_is_a_gitfile, test_siblings_asks_only_the_stat and test_what_answers_absent_is_left_to_find_repo_root.
  • Evidence: base 3.12 lists all 15 as FAILED with AttributeError: ... has no attribute '_refuse_an_unreadable_root'. The review rule is that a control row passes on both sides. The body is open about these rows (its 33).
  • Fix: drive the controls through main (or run_tool) and assert CODE not in stderr, so base passes them too.
NIT 2: the body says placement was ok in every state
  • Evidence: in my doctor runs, placement is n/a in every state, the readable holder included, on base and head alike.
  • Fix: say "unchanged (placement is n/a for a holder)".
NIT 3: without_working_clone: [] beside entries whose working_clone is null
  • Where: shape-doctor.py:10949.
  • Problem: a --json reader is told that no member lacks a working clone, about a question the row did not ask.
  • Fix: None, or compute it. A working clone sits beside the holder, outside its .git.
NIT 4: Known limits could name HEAD at 000
  • Evidence: with a searchable .git whose HEAD is at 000, the same on base and head and on both Pythons:

    • the assembly bootstrap says REFUSED pin-gitlink-absent: no gitlink (mode 160000) recorded at 'spec';
    • the family bootstrap says FETCH INCOMPLETE, or ok with --no-fetch --skip-members;
    • validate-family.py refuses gitlinks-unreadable.

    These are the kind of misleading answers this PR removes, left out by the limit's "stat of HEAD only".

  • Fix: add a sentence to Known limits naming this case.

Parts not covered

  • 43 mutants not run (all defined in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1172/mut/mutants.py):
    • A: absent-kind-dir, relative-naming-removed, named-against-candidate, reads-dropped;
    • V and F: relative-naming-removed, named-against-candidate, valueerror-fallback-removed;
    • V: reads-dropped, reads-ignored;
    • F: spelled-removed, what-dropped;
    • all 17 siblings.py mutants;
    • all 10 doctor mutants, including D-before-empty-return (the order after the empty-members return);
    • X-walk-from-member. X-stub-removed is covered by the 05e6ab0f run.
  • The gate calls (docs, pin, sd, big; about 9,000 ids) were not re-run. Only test_pinned_root_git_unreadable.py and test_validate_family_unreadable.py were.
  • The Pinned copy section was not measured: the update-shape.py check results and the doctor over a holder that still holds the old copies.
  • Closing form and stacking:
    • Read but not grepped: the body holds one closing line, Closes #1148, and I saw no other close, fix or resolve form before an issue number.
    • Merge-base of head and b75babee is b75babee.
    • Not checked: whether origin's fix/1146-root-git-unreadable-not-drift is still at b75babee (no ls-remote).

Files

  • Scratch: /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1172/
    • mutant results: mut/res1.jsonl, mut/res2.jsonl
    • probe output: probe-{base,head}-{312,314}.jsonl, probe2-{base,head}.jsonl
  • Base worktree: /workspace/projects/openRepoShape-worktrees/l1-r1172-base at b75babee. It holds two untracked test files I copied in; it is not to be committed.

Lane: openreposhape-1 (openRepoShape-1)

…tfile that names no directory stops the walk, so a `.git` above it that cannot be read is never asked, and each git reader's `return` there is now held (#1148)

Review r1 of #1172 (issuecomment-6351621763) found one SHOULD: the line in
each of the three git readers that returns when `.git` is a gitfile not
starting with `gitdir: ` had no test that caught a change to it
(templates/assembly-root/scripts/bootstrap.py, templates/family-root/scripts/
validate-family.py and templates/family-root/scripts/bootstrap.py, each in
`_refuse_an_unreadable_root`). `find_repo_root` stops at that `.git`, so the
question must stop there too. Its three mutants, `return` made `continue`,
all survived the 73 rows.

New row `test_the_walk_stops_at_a_gitfile_that_names_no_directory`, over
GIT_READERS (3 ids): the root's `.git` is a gitfile reading `no gitdir`, the
parent's `.git` is refused with `as_python_314`, and
`_refuse_an_unreadable_root(root)` answers None. A tool that went on to the
parent would raise over a `.git` that `find_repo_root` never reaches. It sits
beside `test_the_walk_stops_at_a_git_that_is_a_gitfile`, which holds the
gitfile that does name a directory.

Mutants, in a scratch clone of this tree, the new file run with `-x` on
3.12.3, the file put back in a `finally`:
- A-gitfile-naming-nothing-continues  KILLED by `[assembly-bootstrap]`
- V-gitfile-naming-nothing-continues  KILLED by `[validate-family]`
- F-gitfile-naming-nothing-continues  KILLED by `[family-bootstrap]`

The file now has 76 rows: 76 passed on 3.12.3 and 76 passed under the
3.14-like shim; with the file laid over #1147's head `b75babee`, 74 failed and
2 passed on both. The new row fails there on the name base lacks
(`_refuse_an_unreadable_root`, AttributeError), as the other 33 do. Also
tests/test_validate_family_unreadable.py 53 passed, and
tests/test_path_guard_hygiene.py with tests/test_repo_hygiene.py 149 passed.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-23T08:09:41Z) Independent re-review r1172b of #1172 at 5aa0f116: READY. No MUST and no SHOULD; two NITs, neither blocking.

Review r1172b of #1172 at 5aa0f116: READY

I found no MUST and no SHOULD. r1172's SHOULD is fixed, NIT-2 and NIT-4 are done, and every claim in the body that I checked matches what I measured. There are two new NITs, and neither blocks the PR.

1. The SHOULD is fixed

  • The diff since 3a389565 is one file, tests/test_pinned_root_git_unreadable.py, with 18 lines added (437-453). It adds test_the_walk_stops_at_a_gitfile_that_names_no_directory over GIT_READERS. Nothing imports that file; test_validate_family_unreadable.py:55 names it only in a docstring.
  • Base vs head. I ran the whole file in scratch clones, with base at b75babee plus the head's file laid over it:
3.12.3 3.14-like shim (mine, r1172b/shim314)
base 74 failed, 2 passed 74 failed, 2 passed
head 76 passed 76 passed
  • The new row's 3 ids fail on base and pass at head, on both Pythons. On base each fails with test_pinned_root_git_unreadable.py:452: AttributeError: module 'pinned_root_git_bootstrap' has no attribute '_refuse_an_unreadable_root', and the same for the other two tools.
  • The three mutants that survived r1172, run in r1172b/clone-head with -x on the whole file. Each file was restored in a finally, and its blob matched HEAD's after:
    A-gitfile-naming-nothing-continues KILLED [assembly-bootstrap] 1 failed, 45 passed restored=True
    V-gitfile-naming-nothing-continues KILLED [validate-family]    1 failed, 47 passed restored=True
    F-gitfile-naming-nothing-continues KILLED [family-bootstrap]   1 failed, 46 passed restored=True
    
    Each one fails with REFUSED git-repo-unreadable: <parent>/.git: cannot be read (Permission denied). That is the wrong refusal the row exists to catch, and the counts match the body's word for word. 3/3 killed.

2. NIT-2 and NIT-4

  • NIT-2 is done. The body now says the other family rows were ok and that "placement is n/a for a holder". r1172's probe files (r1172/probe-{base,head}-{312,314}.jsonl) agree: in all 6 states, on both sides and both Pythons, manifest-kinds, shape-currency and agent-files are ok and placement is n/a.
  • NIT-4 is done. I checked the new Known-limits sentence myself on 3.12, with a searchable .git whose HEAD is at 000. Each tool ran from the root, and base and head gave the same answers:
    • the assembly bootstrap: REFUSED pin-gitlink-absent: no gitlink (mode 160000) recorded at 'spec', then bootstrap REFUSED: pins (exit 2);
    • validate-family.py: REFUSED gitlinks-unreadable, exit 2;
    • the family bootstrap: FETCH INCOMPLETE (git exit 128) using credential source 'none': and fatal: not a git repository, with the SHAPE_LEGS_TOKEN advice, exit 1;
    • with --no-fetch --skip-members: family bootstrap ok, exit 0.

3. Reading the diff adversarially

The row is sound. _is_a uses path.stat(), which as_python_314 patches, so a continue really does reach the parent's refused .git. Nothing in it depends on the Python version, and it runs as root and on Windows. Leaving out siblings is right: siblings' stop at any .git that is there is held by test_the_walk_stops_at_a_git_that_is_a_gitfile[siblings].

4. The body's claims

  • Rows and counts: 76 rows; base 74 failed and 2 passed, head 76 passed, both Pythons. Confirmed.
  • The 2 base passes are the two named controls. Confirmed.
  • Why base fails: 36 rows on the AttributeError and 38 on base's own answers. Confirmed: 74 tb lines, 36 of them AttributeError.
  • Hygiene: test_path_guard_hygiene.py with test_repo_hygiene.py gave 149 passed at head. Confirmed.
  • The regression file: test_validate_family_unreadable.py gave 53 passed at 5aa0f116. Confirmed.
  • Gate arithmetic: the counts add up. pin is 237 + (234+3) + 237 + 234 = 945; sd is 445+3 = 448; big is 8037+39 = 8076; new is 255+3 = 258.
  • Closing form: exactly one, Closes #1148. A regex finds no other close, fix or resolve form before any #N.
    • Title: 246 characters.
    • The outside-v1.2.0 ruling is named.
    • The body ends with the lane line and the generated footer.
    • closingIssuesReferences is [] while the base is not main, which is expected for a stacked PR.
  • Stacking is right:

NIT findings

NIT-A: the row holds return against continue, but not against a fall-through

  • Where: tests/test_pinned_root_git_unreadable.py:449-451, against bootstrap.py:510-511, validate-family.py:568-569 and family/bootstrap.py:447-448.
  • Evidence: return made pass survives the whole file in all three readers (76 passed, restored=True ×3).
    • With that mutant, the code parses b"no gitdir\n"[8:], which is b'r', as a git directory. Where <root>/r/HEAD is refused, it gives the wrong refusal REFUSED git-repo-unreadable: r: cannot be read (Permission denied).
    • The case is contrived, so this is only a NIT.
  • Fix: add root.resolve() / "r" / "HEAD": denied to that row's as_python_314 dict.
    • Measured in a scratch copy of the row (r1172b/clone-head/tests/test_r1172b_probe_strong_row.py), it gives 3 passed at head.
    • It kills all three pass mutants (1 failed …) and still kills the three continue mutants.

NIT-B: two stale phrases in the body's Tests section

  • The regression table's header labels `3a389565` (head), but the head is 5aa0f116. Fix: say "3a389565 (the fix)", or add a 5aa0f116 column with 53 passed, which I measured.
  • The "Which head" bullet puts "The fix that followed touches only tests/test_validate_family_unreadable.py" after the 5aa0f116 sentence, so it reads as if 3a389565 came after 5aa0f116. Fix: move it before that sentence.

What I did not cover, and two slips

  • Not re-run: the pin, docs, sd and big gate calls, the new call as a whole, and r1172's other 43 mutants. The diff is a single test file that nothing imports, so those results stand as the body states them.

  • Load rule slips:

    • two of my pytest calls started while the container held 4 pytest processes: the first mutant run (about 07:57Z), and the strengthened-row run (about 08:07Z, after my wait loop timed out);
    • /workspace stood at 12G free during the last runs.

    All of my runs have ended.

State

  • l1-r1172 is clean at 5aa0f116.
  • l1-r1172-base is untouched: b75babee, with r1172's two untracked files.
  • My scratch is /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1172b/:
    • logs: logs/, mutant results: mut/res-*.jsonl;
    • clones clone-head and clone-base, each with untracked probe files only and no tracked diff.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026
brettheap added a commit that referenced this pull request Oct 23, 2026
…w pins path-twice's wording of whose file it puts back, the index's or HEAD's as the index holds none, a row pins the value check's reading of HEAD's where the deletion of `.gitmodules` is staged, and `_listed_as_git_reads`'s docstring says it returns nothing where the work tree's file is on disk (#1181) (#1208)

It follows #1124 (for #1077), which landed as `dea964a`; W1181r restacked it onto main with only its own change (#1208 issuecomment-6371837537). It is the follow-up of RV1124b's review of #1124 (see #1150 for the neighbouring follow-ups from the first review).

## What changed

- `tests/test_validate_family_index_gitmodules.py`
  - **PT7 (NIT 1).** In `test_1077_path_twice_records_its_put_back_for_the_next_finding`, after the existing `` `{put_back}` `` assertion, a second assertion pins the whole put-back wording: `back first from {GONE[how][0]}, `{put_back}`, so that every other section stays`. It rides on the two existing rows, `[deleted]` (the index's) and `[deletion-staged]` (HEAD's, as the index holds none). The row's docstring says path-twice names whose file it puts back.
  - **S1c (NIT 2).** A new row, `test_1077_a_value_head_gives_where_the_deletion_is_staged_puts_head_s_back`. `update = !true` is committed in `members/IRRS`'s section and the deletion of `.gitmodules` is staged, so the index holds no file and git reads HEAD's. The row checks the premise (`git submodule status` exits 128), then that `found_alike(holder, *MODES)` returns the one finding as the issue gives it, whole. It types the two lines in order, runs `git add -- .gitmodules` and a plain `git commit`, and checks that `!true` is not in HEAD, `head_paths` is `{ROW, OTHER_ROW}`, `git status --porcelain` is empty, and `ends_well` passes (the gate in every mode, and a plain clone).
- `templates/family-root/scripts/validate-family.py` (NIT 3): the docstring of `_listed_as_git_reads` only. It opens "Where the work tree's .gitmodules is gone from disk, ..." and says it returns `(b'', '')` where the work tree's file is on disk. The script has no pinned copy and no checked-in digest. Its AST is equal to `8dfa32bd7`'s once that one docstring is dropped (sha256 `63f4b9d5...` there, `30abb4ea...` here).

## Counts

- The rows file has 16 rows: 15 old and 1 new. PT7's assertion adds no row.
- On this head: 16 passed.
- Gate, 14 files, one pytest run on this head: **777 passed** (exit 0). The files are the rows file, `tests/test_repo_hygiene.py`, the eight other test files #1124 changed (`test_family_add_refusals`, `test_family_gitmodules_values`, `test_family_hand_mount_git_rm_lines`, `test_family_holder_bootstrap_texts`, `test_family_holder_remedies_compose`, `test_family_unnamed_gitlink`, `test_validate_family_remedies_typed`, `test_validate_family_unnamed_mounts`), the three more that name the functions and codes it touched (`test_family_add_spelling_leftovers`, `test_family_bootstrap_gate_order`, `test_validate_family_typed_states`), and `test_pinned_copy_text.py`.
- The same 14 files on a scratch merge commit of `origin/main` (`961640c68`) and this head: **778 passed** (exit 0). The one extra id is a new row in `test_repo_hygiene.py` on `main`.

## Mutants

Each mutant was applied to `validate-family.py` in a scratch worktree of this head, run against the new rows file (16 rows) and a copy of #1124's original 15-row file (31 rows together), and restored with its sha256 checked (`30abb4ea...` before and after).

| Mutant | Edit | New file (16 rows) | #1124's 15 old rows |
|---|---|---|---|
| PT7 | path-twice's `f"from {whose}, ` written `f"from the index's, ` | 1 failed: `[deletion-staged]`; `[deleted]` passes | 15 passed (survives) |
| S1c | `_listed_as_git_reads` reads `spec = f":{GITMODULES}"` alone, with no fallback to HEAD's | 1 failed: the new S1c row | 15 passed (survives) |

## Merge-trees

`git merge-tree --write-tree` of each ref against this head was clean:

- `origin/main` (`961640c68`)
- #1124's head (`8dfa32bd7`)
- #1166's head (`5102b4294`) and #1172's head (`5aa0f116d`), the two other open PRs that touch `validate-family.py`; and the head of #1172's base branch (`b75babee3`)

No other open PR touches `tests/test_validate_family_index_gitmodules.py`.

Closes #1181

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ory is inside the root's own the root's line ahead of `mount_read`'s refusal of that directory, so a root's `.git` this user cannot read is said once again, and a leg's own `.git` directory keeps its line (#1146)

Merged with main at 50869f3, #1200's `mount_read` asks each leg's git
directory, and `audit_leg` returned on its refusal,
`code: .git/modules/code: cannot be read (<reason>)`, before #1147's
`root_line` was reached. Where the root's own `.git` cannot be read, the
legs' git directories under it cannot either, so 5 of the 47 rows of
tests/test_root_git_unreadable.py failed on the `placement` reason: the
row named a symptom once per leg instead of the root's line once (lane 3's
note on #1147).

`audit_leg` now gives the root's line first, where `root_line` is set,
`mount_read` refused the leg, and `read_through_the_root` says the leg's
git directory is inside the root's own: under the root's `.git`, or under
the directory a root's gitfile names, which is where git keeps a
submodule's for a root that is a linked worktree or a submodule itself
(measured: `../../main/.git/worktrees/wt/modules/spec` and
`../../.git/modules/root/modules/spec`). Every other refusal keeps
`mount_read`'s line: a mount, a `.git` or a gitfile this user cannot read,
and a leg's own `.git` directory, whose repository is not the root's. With
the root's `.git` readable, `root_line` is None and #1200's refusal stands.
validate-pins.py already refuses the root before any leg, and
validate-manifest.py asks no leg's git directory, so neither changes.

tests/test_root_git_unreadable.py: 3 rows, in process, on every platform
and as root, with every `HEAD`'s `stat` refused: the root's line once for
legs under the root's `.git` and under the directory its gitfile names,
and the control, a leg's own `.git` directory keeping `spec/.git: cannot
be read`. Each fails on the merge.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1147's head a2fc1da (over #1200, `placement` gives a leg whose git
directory is inside the root's own the root's line), clean by
`git merge-tree` (tree c473b23).

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T13:46:07Z) #1172's head is now 2eabec8e: it merges #1147's head a2fc1da8, and lane 1's check finds nothing else in it.

Lane: openreposhape-1 (openRepoShape-1)

brettheap added a commit that referenced this pull request Nov 8, 2026
…py: an `id:`, a date or a `repository:` ending in a line feed is refused by name, and a pin's `source_repository:` that is no path or `owner/repo` name is named as the pin's (#1139) (#1166)

The value patterns the issue names refuse a value that ends in a line feed, by name, as #1126 (#1017) makes `COMMIT_RE`, `SHA256_RE` and `COMMIT_40` do:
- `DATE_RE` and `QUALIFIED_RE` in `validate-manifest.py` and `validate-family.py`, `MEMBER_RE` in `scripts/family.py`, `DATE_RE` in `scripts/shape_advisory.py` and `REPOSITORY_RE` in `update-shape.py`;
- `PROJECT_ID_RE`, read with `fullmatch` where the two validators and `family.py add` read an id from a file.

A shape pin's `source_repository:` that is neither a path on this machine nor an `owner/repo` name is refused in the pin's name, not `--upstream`'s. Values without a line feed keep their outcome, and no refusal code is added or renamed. Two inputs do move from one existing code to another (see "Behaviour changed", below).

**What this PR does not cover, and the title does not claim:**
- the naming-policy patterns that `scripts/repo_shape.py` reads from `contracts/repository-naming.yaml`, which `validate-repository-naming.py` still reads with `.match`;
- `update-upstream-not-a-repo`, which still says `--upstream` when a pin names a folder that exists and is not a repository.

Both are follow-ups, with the other findings of the independent review, under "Follow-ups (not in this PR)".

This does not need #1126 and does not touch its files or its test file.

Measured on main `038dafba` (Python 3.12.3, git 2.43.0) by a probe of each tool, before any change. Each value is written in the file as a YAML double-quoted `"...\n"`, which the reader takes as the value and a line feed.

| Read from | Value | On main |
|---|---|---|
| `project.yaml` (`validate-manifest.py`) | `id: "atlas\n"` | only `FINDING manifest-topic: topic is 'xf-project-atlas', expected 'xf-project-atlas\n' derived from id 'atlas\n'`, exit 1 |
| `project.yaml` | `elected_on: "2026-09-02\n"` | `manifest ok`, exit 0 |
| `project.yaml` | a leg's `repository: "testorg/Atlas-spec\n"` | `manifest ok`, exit 0 |
| `family.yaml` (`validate-family.py`) | `id: "inkrouter\n"` | `family ok: InkRouter (inkrouter` and a line break, exit 0 |
| `family.yaml` | `created_on: "2026-09-04\n"` | `family ok`, exit 0 |
| `family.yaml` | a member's `repository: "InkRouter/IRRS\n"` | `FINDING member-repository-name: ... whose name is 'IRRS\n'`, exit 1 |
| a member's `project.yaml`, read by `family.py add` | `id: "irqs\n"` | mounted and committed, exit 0, with the row `id: "irqs\n"`; the holder's gate then said `family ok` |
| `contracts/shape-pin.yaml`, `update-shape.py check` with no `--upstream` | `source_repository: "opensoft/openRepoShape\n"` | `REFUSED unsafe-value: --upstream is 'opensoft/openRepoShape\n'`, exit 2 |
| the same | `source_repository: "opensoft/open Repo"` | `REFUSED update-upstream-unresolvable: --upstream 'opensoft/open Repo' is neither ...`, exit 2 |
| the same | `source_repository: "-opensoft/openRepoShape"` | `REFUSED unsafe-value: --upstream is '-opensoft/openRepoShape'`, exit 2 |
| an adoption plan (`adopt-project.py check`) | `id: "northwind\n"` | `FINDING plan-manifest-invalid: id is 'northwind\n', which the project's own validate-manifest.py refuses: manifest-topic: ...`, exit 1; `execute` refused it, exit 2, before any leg |

In-process on the same main: `repo_shape.PROJECT_ID_RE.match('atlas\n')`, `shape_advisory.DATE_RE.match('2026-09-02\n')` and `REPOSITORY_RE.match('a/b\n')` each matched, and `date.fromisoformat('2026-09-02\n')` raised `ValueError`.

- **`DATE_RE` and `QUALIFIED_RE`** in `templates/assembly-root/scripts/validate-manifest.py` and `templates/family-root/scripts/validate-family.py` end in `\Z`. So do `MEMBER_RE` in `scripts/family.py` (the third spelling `tests/test_family_member_name.py` holds equal to the two `QUALIFIED_RE`), `DATE_RE` in `scripts/shape_advisory.py` and `REPOSITORY_RE` in `update-shape.py`.
- **`PROJECT_ID_RE` keeps its `$`.** Every id refusal prints its text (`must match ^[a-z0-9][a-z0-9-]*$`, pinned in six test files), the validators run against each project's own copy of `repo_shape.py`, and its definition sits inside #1126's hunk. The three callers that read an id from a file ask it with `fullmatch` instead: `validate-manifest.py` (`manifest-id`), `validate-family.py` (`family-id`) and `family.py`'s reading of a member's `project.yaml` (`member-bad-id`, which `add` now says before anything is mounted).
- **`update-shape.py`.** `open_upstream(spec, pin_path=None)` takes the pin the value was read from, which `prepare` passes when no `--upstream` was given. Then `update-upstream-unresolvable` says ``<root>/contracts/shape-pin.yaml: `source_repository:` is '...', which is neither a path on this machine nor an `owner/repo` name``, with a remedy to write the pin's field or pass `--upstream`, and `checked_value` names the same field. **Code kept:** `update-upstream-unresolvable`, the existing code for a value that is not an `owner/repo` name. A line feed now reaches it, since the pattern no longer takes one. Before, `checked_value` refused it as `unsafe-value`.
- **One existing test line:** `tests/test_family_init_values.py` finds validate-family's `DATE_RE` line by its text, which now ends `\Z`.
- **One existing stand-in:** the `temporaries` fixture in `tests/test_update_shape_unreadable.py` stands in for `open_upstream` with `def opening(spec)`, which the new two-argument call refused with `TypeError: ... takes 1 positional argument but 2 were given`. It now takes `pin_path=None` and hands it on to the real `open_upstream`, so `test_a_read_refused_in_prepare_closes_the_upstream[check|apply]` asks what it asked before.
- **The rows** are in a new file of their own, `tests/test_value_pattern_line_break.py`.
- **A second guard, in a second commit (`5102b4294`, a test only, no production file).** The first guard holds the four pattern names this PR ends in `\Z`, and nothing held `PROJECT_ID_RE`, which keeps its `$`. `test_every_reader_of_the_id_pattern_is_in_the_table` reads every tracked `.py` outside `tests/` with `ast` (it runs none) and holds each call of a method of `PROJECT_ID_RE` (`PROJECT_ID_RE.x(...)` or `repo_shape.PROJECT_ID_RE.x(...)`) to the table `ID_READERS`:
  - the three `fullmatch` readers of an id from a file (`validate-manifest.py`, `validate-family.py`, `scripts/family.py`);
  - the modules that still `match` it, each with its reason: argv `--id` in `scaffold-project.py` and `scripts/family.py`, where `checked_value` refuses a line feed after the pattern; argv `--id` in `adopt-project.py`, which the project's own `validate-manifest.py` refuses (`adopt-manifest-invalid`, below; `ID_READERS` still gives it the `checked_value` reason); and `adopt-project.py`'s two `plan-bad-id` readers, of the plan's `id:` and of the id derived from `legs.assembly`, which a line feed never passes (below).

  A new reader of `PROJECT_ID_RE`, `fullmatch` included, fails it by file and method until it is listed in `ID_READERS` with its reason. The table is exact both ways, so a reader taken away fails it too.

**Left as they are, and why:**
- the argv readers of `PROJECT_ID_RE` (`--id` in `scaffold-project.py`, `family.py init` and `adopt-project.py`). The first two hold the id to `checked_value` after the pattern, which refuses a line feed as `unsafe-value` naming `--id`, and `tests/test_derived_id_refusals.py` pins that answer for them. `adopt-project.py` never calls `checked_value` on `--id`: `plan` with `--id` set to `northwind` and a line feed exits 2 with `adopt-manifest-invalid`, in the words of the project's own `validate-manifest.py` (`manifest-id`), and writes no plan (lane 5's re-check, issuecomment-6371408915, NIT 2; measured on `plan` only);
- `adopt-project.py`'s reading of a plan's `id:`: `check` and `execute` hold it to the project's own `validate-manifest.py`, which refused it on main (above) and now names it in `manifest-id`'s words. It is the same value, refused under the same code, `plan-manifest-invalid`, before any leg exists. The id derived from `legs.assembly` is read with `.match` too, and a line feed in `legs.assembly` is `unsafe-value` before that id is asked (the review measured both, on main and here alike). This keeps the open PRs on `adopt-project.py` (24 at 12:02Z) clear;
- `PIN_ARG_RE`, and `NAME_RE`/`REF_RE`/`PATH_RE` in `setup-project.py`, as the issue says;
- `update-upstream-not-a-repo` still says `--upstream`. A pin's value reaches it only when it names a path that exists, not through the pattern.

**Behaviour changed, stated plainly, and what the issue's Verify means by "No refusal code changes":** the issue's Verify line reads as no code added or renamed, and none is. Two inputs do move from one existing code to another, exit 2 both before and after:
- `update-shape.py --upstream "opensoft/openRepoShape<LF>"` was `unsafe-value` and is now `update-upstream-unresolvable`, still naming `--upstream`;
- a pin's `source_repository:` that ends in a line feed, with no `--upstream`, was `unsafe-value` naming `--upstream` and is now `update-upstream-unresolvable` naming the pin's field.

No test pinned either old answer: every gate below passed. `family.py add --member` is stripped before `MEMBER_RE`, so `add` answers as before.

**Not changed, seen while measuring:** with `--upstream` passed, `update-shape.py` does not read the pin's `source_repository:` through `REPOSITORY_RE` at all, and it keeps the value as the upstream's repository name. `validate-family.py` does not hold a member row's `id:` to `PROJECT_ID_RE`; it compares the row with the member's manifest. Neither is a `$` question. Both are under "Follow-ups (not in this PR)".

All in `tests/test_value_pattern_line_break.py`, run against main's production code: the five production files swapped in place from `origin/main` (`961640c68`), then put back with their sha256 matching (`dirty=5` in the run's START line is those five files). On the head `5102b4294` (30 rows) the result is **20 failed, 10 passed in 23.12s, git 2.43.0**. The 10 that pass are baselines: the seven `test_a_pattern_takes_its_value` rows, the spelling-table guard and the two double-quoted-without-a-line-feed rows.

The first 19 are the first commit's rows (19 failed, 10 passed in 22.12s on its 29 rows at `038dafba`). The independent review reproduced them from main's bytes of the same five files (19 failed, 10 passed in 26.78s, the same 19 ids). The 20th, `test_every_reader_of_the_id_pattern_is_in_the_table`, is the second commit's: it fails on main because the three readers of an id from a file are `.match` there, not `.fullmatch`.

- `test_a_pattern_refuses_its_value_and_a_line_feed[manifest-DATE_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[manifest-QUALIFIED_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[family-DATE_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[family-QUALIFIED_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[family.py-MEMBER_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[shape_advisory-DATE_RE]`
- `test_a_pattern_refuses_its_value_and_a_line_feed[update-shape-REPOSITORY_RE]`
- `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[id]`
- `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[elected_on]`
- `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[leg-repository]`
- `test_adopt_check_names_a_plan_id_that_ends_in_a_line_break_as_an_id`
- `test_a_family_value_that_ends_in_a_line_break_is_a_finding[id]`
- `test_a_family_value_that_ends_in_a_line_break_is_a_finding[created_on]`
- `test_a_family_value_that_ends_in_a_line_break_is_a_finding[member-repository]`
- `test_add_refuses_a_member_whose_id_ends_in_a_line_break`
- `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[line-feed]`
- `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[space]`
- `test_a_pins_source_checked_value_refuses_is_named_as_the_pins`
- `test_an_upstream_passed_with_a_line_feed_is_named_as_passed`
- `test_every_reader_of_the_id_pattern_is_in_the_table`

**13 of 13 killed.** Each mutant was applied alone to one file of the branch's production bytes (the same bytes as the first commit and as the head, which adds a test only), the rows were run, and the file was put back with its sha256 checked (`restored=yes` for all 13). Each run is `tests/test_value_pattern_line_break.py` plus `tests/test_family_member_name.py::test_every_spelling_of_the_member_name_rule_is_the_same_pattern`, git 2.43.0.

| Mutant | File | Killed by |
|---|---|---|
| M1 `DATE_RE` back to `$` | `validate-manifest.py` | 2: `test_a_pattern_refuses_its_value_and_a_line_feed[manifest-DATE_RE]`, `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[elected_on]` |
| M2 `QUALIFIED_RE` back to `$` | `validate-manifest.py` | 3: `...[manifest-QUALIFIED_RE]`, `...[leg-repository]`, `test_every_spelling_of_the_member_name_rule_is_the_same_pattern` |
| M3 the id read with `.match` | `validate-manifest.py` | 2: `test_a_manifest_value_that_ends_in_a_line_break_is_a_finding[id]`, `test_adopt_check_names_a_plan_id_that_ends_in_a_line_break_as_an_id` |
| M4 `DATE_RE` back to `$` | `validate-family.py` | 2: `...[family-DATE_RE]`, `test_a_family_value_that_ends_in_a_line_break_is_a_finding[created_on]` |
| M5 `QUALIFIED_RE` back to `$` | `validate-family.py` | 3: `...[family-QUALIFIED_RE]`, `...[member-repository]`, `test_every_spelling_of_the_member_name_rule_is_the_same_pattern` |
| M6 the id read with `.match` | `validate-family.py` | 1: `test_a_family_value_that_ends_in_a_line_break_is_a_finding[id]` |
| M7 `MEMBER_RE` back to `$` | `scripts/family.py` | 2: `...[family.py-MEMBER_RE]`, `test_every_spelling_of_the_member_name_rule_is_the_same_pattern` |
| M8 a member's id read with `.match` | `scripts/family.py` | 1: `test_add_refuses_a_member_whose_id_ends_in_a_line_break` |
| M9 `DATE_RE` back to `$` | `scripts/shape_advisory.py` | 1: `...[shape_advisory-DATE_RE]` (its pattern row alone: `date.fromisoformat` refuses the line feed after the pattern, so no tool row sees it) |
| M10 `REPOSITORY_RE` back to `$` | `update-shape.py` | 3: `...[update-shape-REPOSITORY_RE]`, `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[line-feed]`, `test_an_upstream_passed_with_a_line_feed_is_named_as_passed` |
| M11 `prepare` passes no pin | `update-shape.py` | 3: `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[line-feed]` and `[space]`, `test_a_pins_source_checked_value_refuses_is_named_as_the_pins` |
| M12 `checked_value` names `--upstream` again | `update-shape.py` | 1: `test_a_pins_source_checked_value_refuses_is_named_as_the_pins` |
| M13 the pin's refusal sentence removed | `update-shape.py` | 2: `test_a_pins_source_that_is_no_owner_repo_is_named_as_the_pins[line-feed]` and `[space]` |

No mutant is added for the stand-in in `tests/test_update_shape_unreadable.py`: it is a test change.

The independent review re-ran M3, M8, M11 and M12, and one mutant of its own (X1: `prepare` names the pin even when `--upstream` is passed). All five were killed, and each file was put back with its sha256 checked. The other nine of the 13 are this writer's runs alone.

**The second commit's guard: 6 of 6 probes caught.** Each was applied alone to one tracked file at `5102b4294`, `test_every_reader_of_the_id_pattern_is_in_the_table` was run, and the file was put back with its sha256 checked (`restored=yes` for all six). The first commit's guard, `test_every_spelling_of_a_value_pattern_is_in_the_table`, was run with each plant too: it passes every one of them, which is the gap the second guard closes.

| Probe | File | The new guard | The first guard |
|---|---|---|---|
| P1 a new `PROJECT_ID_RE.match(value)` reader appended | `update-shape.py` | failed: `('update-shape.py', 'match'): 1` is more than the table holds | passed |
| P2 a new `repo_shape.PROJECT_ID_RE.search(value)` reader appended | `scripts/shape_advisory.py` | failed | passed |
| P3 `fullmatch` back to `match` | `templates/assembly-root/scripts/validate-manifest.py` | failed | passed |
| P4 `fullmatch` back to `match` | `templates/family-root/scripts/validate-family.py` | failed | passed |
| P5 `fullmatch` back to `match` | `scripts/family.py` | failed | passed |
| P6 main's bytes of those three files together (the 20th row above) | the three | failed | not run |

**The second commit `5102b4294` (dirty=0)**, Python 3.12.3, with the same stub `gh`, bogus token and no global git config:

| Run | Git | Result |
|---|---|---|
| `tests/test_value_pattern_line_break.py` | 2.43.0 | **30 passed** in 23.00s |
| `tests/test_value_pattern_line_break.py` | 2.47.1 | **30 passed** in 23.30s |
| `tests/test_pinned_copy_text.py`, `tests/test_path_guard_hygiene.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` | 2.43.0 | **306 passed** in 61.54s |

That commit changes only the test file (62 lines added, no assert that joins conditions with `and` or `or`, ASCII), so the first commit's runs below are not repeated: they hold the production files and every other test file, and those are unchanged. CI on `5102b4294`, and an independent look at the second commit, were not done when this was written.

**The independent review of the first commit** (rv1166c, READY, read-only) reports these gates on `2593952c9`, all passing: the stand-in gate's 6 files (298 passed), the new file on 2.47.1 (29 passed), the five hygiene files (306 passed), 15 more test files that name a changed function, pattern or code (1611 passed, 19 skipped) and `tests/test_shape_doctor.py` (445 passed, 3 skipped). It also read CI on that head at about 20:04Z: `tests` (the full suite on 3.14), `tests-git-bash`, `package` and SonarCloud all passed.

**The first commit's gates ran on `2593952c` (dirty=0)**, Python 3.12.3, with the real `gh` unreachable (a stub first on `PATH`, a bogus token) and no global git config:

| Run | Git | Result |
|---|---|---|
| the files that held every failure and setup error of the shards below: `tests/test_family.py`, `tests/test_update_shape_pr_base.py`, `tests/test_update_shape_unreadable.py`, `tests/test_update_shape_header_comments.py`, `tests/test_update_shape_messages.py` and the new `tests/test_value_pattern_line_break.py` | 2.43.0 | **298 passed** in 343.60s |
| `tests/test_value_pattern_line_break.py` | 2.47.1 | **29 passed** in 21.31s |
| `tests/test_pinned_copy_text.py`, `tests/test_path_guard_hygiene.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` | 2.43.0 | **306 passed** in 69.12s |

**The two shards, on the uncommitted tree** holding the same production bytes as the first commit (the 7 files' sha256 checked before and after), git 2.43.0: the validators' test files, the `REPOSITORY_RE`/`source_repository` files and the `update-shape.py`, `shape_advisory.py` and `family.py` files, 125 in all.

- shard 1 (62 files): 4 failed, 5290 passed, 13 skipped in 56 min;
- shard 2 (63 files): 3 failed, 4 setup errors, 3761 passed, 29 skipped in 50 min.

Every failure and setup error is in the five existing files of the first row of the table above, which all pass on the first commit:
- 2 were real: `test_a_read_refused_in_prepare_closes_the_upstream[check|apply]`, the `opening(spec)` stand-in under the two-argument call. The stand-in is fixed in the first commit.
- The other 5 failures and the 4 setup errors were `update-conflict` on `scripts/validate-family.py` ("not a verbatim copy ... at the pinned commit"). This is an artifact of an uncommitted tree: `tests/test_family.py`'s `upstream_clone` commits the working tree into its clone, while `family.py init` pins the holder at the real `HEAD`, so an uncommitted `templates/family-root/` file is never verbatim at its pin. On the first commit they pass.

Every other file of the two shards passed on these production bytes, so the shards were not re-run.

`git merge-tree --write-tree <other> 5102b42` (git 2.43.0) at 2026-10-23T12:08Z, after re-fetching main (still `961640c6`; read again at 12:10Z, just before the push) and re-listing the open PRs at 12:02Z (55 open). Main has moved from the `038dafba` that this body measured to `961640c6` by one file, `docs/release-notes/v1.2.0.md`. Each open PR that touches one of this PR's 8 paths, at its current head, plus #1126:

| Against | Head | Shares | Result |
|---|---|---|---|
| origin/main | `961640c6` | | clean, tree `4613729516ff` |
| #1098 | `dad7961a` | `scripts/family.py` | clean, tree `4d1ff9517588` |
| #1117 | `10dc011c` | `scripts/family.py` | `scripts/family.py` merges clean; conflict in `docs/handbook.html` only, which this PR does not touch (see below) |
| #1124 | `8dfa32bd` | `validate-family.py` | clean, tree `cb2ec1073646` |
| #1126 | `4c1f80cf` | none (its `scripts/repo_shape.py` and tests) | clean, tree `e82c8945ac37` |
| #1133 | `a7bfee1b` | `update-shape.py` | clean, tree `a710870d1ffc` |
| #1144 | `89486f69` | `scripts/shape_advisory.py` | clean, tree `2d3e3e1ef43d` |
| #1147 | `b75babee` | `validate-manifest.py` | clean, tree `a82ac3b47a3f` |
| #1159 | `66e68f94` | `scripts/shape_advisory.py` | clean, tree `4abeb7b38665` |
| #1164 | `74096eb4` | `scripts/family.py` | clean, tree `ee285e1cb8cf` |
| #1172 | `5aa0f116` | `validate-family.py` | clean, tree `aa32d41e6e2c` |
| #1187 | `cc8a87ee` | `update-shape.py`, `tests/test_update_shape_unreadable.py` | clean, tree `25711eb1cac4` |
| #1201 | `eac177af` | `scripts/family.py` | `scripts/family.py` merges clean; conflict in `docs/handbook.html` only (see below) |

- #1117 and #1201 conflict in `docs/handbook.html` alone. #1201 is stacked on #1117 (their merge-base is #1117's head `10dc011c`). Each of them against origin/main alone gives the same conflict in the same file, so it is theirs: #1117's, from its older base `6da757cb`, which #1201 inherits.
- #1187 and #1201 are new since the first merge-trees. #1187 shares `tests/test_update_shape_unreadable.py` with this PR; its diff adds or removes no line that names `open_upstream`, `def opening` or `REPOSITORY_RE`, so it brings no one-argument stand-in for the two-argument call. That was read for all 55 open heads at 12:12Z; only this PR's own lines name them.
- No other open PR adds or removes a call of a method of `PROJECT_ID_RE`, which the second guard counts: the lines each of the 55 open heads adds or removes outside `tests/`, `docs/` and `*.md` were read at 12:04Z. Only this PR's own name it, and #1201's one new `MEMBER_RE.fullmatch` is another pattern. So a sibling landing first does not move the guard's table.

Each is a candidate for its own issue. They come from the independent review (rv1166c, READY at `2593952c9`, issuecomment-6352173084), which measured each of them on main and at the head unless the item says it did not. The review's finding numbers are in brackets.

- **The naming-policy patterns [1].** `scripts/repo_shape.py` reads the families, the roles and the topic pattern of `contracts/repository-naming.yaml` with `.match` (`family["_re"].match(name)`, `family["_split_re"].match(name)`, `role["_re"].match(name)`, and `topic_pattern`, which defaults to `^xf-project-[a-z0-9-]+$`). `validate-repository-naming.py --project project.yaml` runs in the root template's `Makefile` and `validate.yml`. At the head, and on main, it exits 0 on a leg `repository: "testorg/Atlas\n"` and prints the line feed inside its table; `-spec` and `-code` the same. The project's gate as a whole still fails, because `validate-manifest.py` now refuses that manifest.
- **`update-upstream-not-a-repo` with a pin [3].** With no `--upstream`, a pin whose `source_repository:` names a folder that exists and is no repository (the review measured `contracts` and `scripts/repo_shape.py`) is `REFUSED update-upstream-not-a-repo: --upstream contracts exists but is not a git repository`, exit 2, on main and here. `pin_path` is in scope at `update-shape.py:686-687`, so that refusal can name the pin as `update-upstream-unresolvable` now does.
- **A pin's line feed when `--upstream` is passed [9].** `update-shape.py check --upstream <clone>` over a pin whose `source_repository:` ends in a line feed exits 0, `nothing to do`. The value is kept as `upstream.repository`, which reaches the README shape line's check, `apply`'s commit subject and the pull request words (`update-shape.py:2525`, `3017` and `5121`). The review measured `check`, not `apply`. It belongs with the previous item.
- **A member row's `id:` [4].** On main, `add` of a member whose `project.yaml` reads `id: "irqs\n"` commits the row `id: "irqs\n"`, and the holder's `validate-family.py` then says `family ok`. Under mutant M8 (this branch with main's `.match` in `add`) the same row is written, and the gate still says `family ok`. By code reading, not measured end to end: `validate-family.py` compares a row's id with the member manifest's by `str` equality (`member-id-mismatch`, `validate-family.py:1608`), and `_check_member_ids_unique` does the same, so neither asks `PROJECT_ID_RE` of a row. This PR makes `add` refuse such a member, so no new holder gets such a row.
- **Values that no pattern reads [8].** The review's sweep found these `manifest ok` or `family ok`, exit 0, with a trailing line feed, at the head and on main alike:
  - in `project.yaml`: `name`, a spec or code leg's `path`, `shape.repository` and `shape.digests.digest_algorithm`;
  - in `family.yaml`: `org`, the family's own `repository`, `tracking_branch`, and the shape's and each member pin's `digest_algorithm`.

  No `^...$` pattern reads them, so they are not this PR's. A leg's `path` is checked only by `_leg_path_findings`, which asks no pattern, and the family's `tracking_branch` is refused in `project.yaml` (`manifest-tracking-branch`, U+000A) but not in `family.yaml`. `shape.commit` and `tree_sha256` with a line feed are also `manifest ok`; that is #1126's.

The first commit's message says "the three readers of an id from a file ask it with `fullmatch` (`manifest-id`, `family-id`, `member-bad-id`)". That is true of those three, but it reads as if there were no others [2]. What the review measured instead:
- `adopt-project.py` still reads the id derived from `legs.assembly` (`:6780`) and the plan's `id:` (`:6791`), both `plan-bad-id`, with `.match`;
- neither lets a line feed through. A plan `id: "northwind\n"` is `plan-manifest-invalid`, now in `manifest-id`'s words. A plan with no `id:` and `legs.assembly: "NorthWind\n"` is `REFUSED unsafe-value: legs.assembly is 'NorthWind\n'`, exit 2, before the derived id is asked, on main and here alike.

So it is the wording alone that overstates. The second commit's `ID_READERS` lists both readers with that reason.

The title's pin clause is the narrower one (the pin's name only where the value is no path and no `owner/repo` name), and two inputs change from `unsafe-value` to `update-upstream-unresolvable` (see "Behaviour changed", above).

Review: rv1166 and rv1166c, lane 5's independent opus reviewers, at `2593952c` (issuecomment-6352173084); then rc1166b, lane 5's independent sonnet re-checker, which wrote none of #1166: READY at `5102b429`, H0 of this landing (issuecomment-6371408915), with no MUST or SHOULD; its NITs 1 and 2 are corrected in the body. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 5102b42 passed tests, tests-git-bash and SonarCloud; the head 5c8afef is a clean update-branch merge of main 93d2931, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 3c89065 over main 11560eb: the family_init_values, update_shape_unreadable, value_pattern_line_break, repo_hygiene, agents_md_codes, cli_reference sets, 365 passed, 19 skipped. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green".

Closes #1139

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o a leg whose mount `mount_read` got into, so a mount this user can search and not list keeps `spec: cannot be listed` as the text says, and a row pins it (#1146)

Fix round for rv1147c's S1 on #1147 (lane 5's re-review at a2fc1da).

With the spec mount at mode 0100 or 0300, `mount_read` refuses the
listing, and `read_through_the_root` can still read `spec/.git` because the
mount can be searched, so `audit_leg` returned True and gave the leg the
root's line (`.git: cannot be read`) where the text says a mount keeps
`mount_read`'s (`spec: cannot be listed`). Only the text of the `placement`
row changed (the verdict is INVALID (pins) either way), and the leg's own
line came on the run after the root was mended.

`audit_leg` now asks `populated` before it asks `read_through_the_root`.
`populated` is True only where `mount_read` got as far as the mount's
`.git`, so a mount it could not stat or list is never given the root's line,
and every other refusal keeps `mount_read`'s as before. The comment beside
it now says so: "what mends the root's mends it" holds only for a leg whose
mount was read.

The row `test_a_mount_that_cannot_be_listed_keeps_its_own_line` (in process,
on every platform and as root: every `HEAD` refused and the spec mount's
listing refused) fails at a2fc1da and passes with the word.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Base automatically changed from fix/1146-root-git-unreadable-not-drift to main November 8, 2026 18:27
Brett Heap and others added 2 commits November 8, 2026 18:33
#1147's final head 95ea564 is update-branch's merge of main 801a7d6
into 4d299dd, its S1 fix (`placement` gives the root's line only to a
leg whose mount `mount_read` got into). It brings that fix and main.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
51a9c5f is #1147's squash on main. Its tree equals #1147's final head
95ea564, already merged here, so the branch's side is taken for each
of #1147's own files and the tree does not change.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T18:47:46Z) #1172 is restacked onto #1147's squash 51a9c5f1, with its own change unchanged, at head ba81fd7d. It stays READY (r1172b, issuecomment-6360478153). It lands by fast refresh once CI's tests passes at ba81fd7d.

Lane: openreposhape-1 (openRepoShape-1)

@brettheap
brettheap marked this pull request as ready for review November 8, 2026 18:48
@sourcery-ai

sourcery-ai Bot commented Nov 8, 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 6 days and 18 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant