Skip to content

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

Merged
brettheap merged 8 commits into
mainfrom
fix/1146-root-git-unreadable-not-drift
Nov 8, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What

The case is #1146's, found by r1144: an assembly root whose own .git is a link into a folder at mode 000, so this user cannot search it. The doctor read it as two legs off their pins. Each of the root's own validators ended in a traceback.

Measured in a scaffold's recursive clone, with base 038dafba (main) and this head, on Python 3.12.3, and under a 3.14-like sitecustomize in which Path.is_file, is_dir, exists and is_symlink answer False to any OSError. <abs> is the absolute path.

Root Base (3.12 and shim) Head (3.12 and shim)
.git linked into a folder at 000 (the issue's case) DRIFTED (2 leg(s) not at the pin). legs: no gitlink recorded for each leg. placement: each leg is not a git repository. manifest and pins: crashed before reaching a verdict (exit 1): PermissionError. Run directly, each validator: a PermissionError traceback, exit 1, on 3.12; REFUSED not-a-git-repo: no .git found at or above <root>, exit 2, under the shim INVALID (manifest, pins). legs: FINDING .git: cannot be read (Permission denied), so no gitlink is read and no leg is compared with its pin, NEXT this doctor again, WHY the chmod cure. placement: n/a, the same line. Each validator: REFUSED git-repo-unreadable: .git: cannot be read (Permission denied) and the chmod cure, exit 2, no traceback
.git a directory at 000, or at 444 DRIFTED (2 leg(s) not at the pin), no gitlink recorded. validate-pins.py: REFUSED pin-gitlink-absent: no gitlink (mode 160000) recorded at 'spec' under the lockstep remedy INVALID (pins). legs and placement name .git: cannot be read (Permission denied). validate-pins.py refuses git-repo-unreadable naming .git. validate-manifest.py passes: it runs no git
.git a gitfile at 000 the same as the row above the same as the row above
.git a gitfile naming a git directory in a folder at 000 the same as the row above INVALID (pins). The line names the git directory whole, since it is outside the root: <abs>/locked/projgit: cannot be read (Permission denied)
.git at 000, and the spec leg's repository in a .git directory of its own, with .specify/memory.md staged in it DRIFTED (1 leg(s) not at the pin). legs: no gitlink recorded for each leg. placement: FINDING 1 path(s) sit in a leg the policy puts elsewhere: spec/.specify/: root by rule root-spec-kit (1 root in the spec leg), the code leg it is not a git repository MISPLACED (1 path). legs and pins name .git as in the rows above. placement: the same FINDING, [spec: 2 path(s) over 2 of 6 tracked file(s); code: read through the root's repository, and .git: cannot be read (Permission denied)], and --placement-plan writes the plan, the code leg's state: that line
Controls: a readable .git; a .git link to nothing; a .git link to itself COMPLIANT; for the two links, DRIFTED and not-a-git-repo (no .git is there) byte for byte the base's reports and validator output, with paths and commit SHAs normalized, on both Pythons

Each doctor report is the same on 3.12 and under the shim, on both sides. The doctor runs each validator with -S, so no sitecustomize loads in the child. The validators' shim answers were measured by running them directly.

Why

Tests

tests/test_root_git_unreadable.py, new, had 47 rows at b75babee; it had 50 at a2fc1da8 (see "Merge with main and the order after #1200" below) and has 51 at 4d299dd3 (see "Review round (rv1147c: one SHOULD)" below).

  • Mode cases. A real mode, the tools run as a person runs them. They skip on Windows and as root (CANNOT_RUN_AS_ROOT) and restore the mode in a finally:
    • the issue's case through the doctor, with each leg's legs and placement entry held whole;
    • each gate run from the root, and with --root a folder inside it;
    • .git at 000 and at 444;
    • a gitfile into a locked folder;
    • a leg with a repository of its own holding a misplaced path, beside one read through the root: MISPLACED (1 path);
    • a leg with no .git of its own, which keeps git's answer.
  • Stand-in cases. In process, on every platform and as root (refused_stat, refusing, as_python_314):
    • both causes, permission and I/O error;
    • the git-directory spellings, with the doctor's line and the gate's refusal held to one sentence: 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, and a folder whose name is not printable (POSIX only);
    • the walk, per gate: a .git above the --root given, a relative --root . run from contracts/, and a stop at a readable gitfile with a refused .git above it;
    • validate-pins.py's git-directory step with a subfolder --root;
    • placement auditing each leg git reads beside a refused root .git, and, with git's answers put in by hand, giving the root's line only to a leg git names no repository for;
    • drifted_reasons with a readable leg beside an unread one.
  • Controls.
    • a readable root: COMPLIANT, both gates pass;
    • five roots that answer absent or that git answers for (nothing there, a link to nothing, a link to itself, a gitfile naming nothing, a git directory with no HEAD): neither row says cannot be read;
    • a .git above a readable root is never asked.

Base vs head. Base is 038dafba with the new file laid over it:

3.12.3 3.14-like shim
base 038dafba 37 failed, 10 passed 37 failed, 10 passed
head 47 passed 47 passed

The 10 rows that pass on base:

  • the 8 controls: test_a_root_whose_git_can_be_read_is_unchanged, the 5 rows of test_what_answers_absent_is_left_to_git_as_before, and test_a_git_above_the_root_is_never_asked for each gate;
  • test_the_walk_stops_at_a_git_that_is_a_gitfile for each gate: base never asks a .git above the root, so a walk that stops at a gitfile passes there too.

The other 37 fail on base:

  • 22 on base's own answers: DRIFTED (... not at the pin) where INVALID (...) or MISPLACED (1 path) is expected; the gates' PermissionError traceback on 3.12, and REFUSED not-a-git-repo under the shim and in process; legs and placement rows that never name .git;
  • 15 only on a name base does not have (AttributeError). Six are control-like: the 5 rows of test_the_gates_ask_nothing_more_of_what_answers_absent and test_a_git_under_a_file_answers_absent. The others are the 6 rows of test_the_doctor_and_the_gate_name_one_git_directory, the drift row, and the 2 rows that put git's answers in by hand.

Mutants: 49 of 50 killed. That is r1147's 21, w1146's 21 and 8 of this round's change. Each was applied once to a scratch clone of the final head b75babee, the new file was run there, and the file was put back from its bytes and checked by sha256: shape-doctor.py 9f3b84fb..., validate-pins.py 1c23d20b..., validate-manifest.py dbbed49b.... The clones were left clean.

Five of the 42 inherited mutants, r1147's De, Df and Dh and w1146's D7 and D9, are restated on the code that now holds what they mutate, since S2 and N7 moved it. The one survivor is r1147's Da, which r1147 calls equivalent: dropping the absent-error filter in root_git_read differs only in a race between the stat and the read.

Mutant Killed by
r1147 Db: a gitfile's CR not stripped test_the_doctor_and_the_gate_name_one_git_directory[gitfile-ending-in-cr-lf-names-a-directory-inside]
r1147 Dc: no shown_path ...[gitfile-names-an-unprintable-directory]
r1147 Dh: no gitlink_cannot_be_read in placement's entries the mode cases' placement detail, the S2 rows
r1147 Pe, Vc: the walk does not resolve its start test_a_relative_root_is_walked_from_where_it_resolves, per gate
r1147 Vf: the manifest's walk stops only at a directory test_the_walk_stops_at_a_git_that_is_a_gitfile[validate-manifest.py]
r1147 Ph: the git-directory step named against the candidate test_the_git_directory_above_the_folder_given_is_named_whole
S2a: no leg is given the root's line the mode cases, the own-repository row, the hand-put answers
S2b: a leg with no .git of its own is given it too test_a_leg_with_no_git_of_its_own_keeps_gits_answer, ...[no-git-of-its-own]
S2c: any answer git gives for a leg is taken for the root's ...[git-answers-from-another-repository]
S2d: the root's line never said once the mode cases
S2e: said once where any leg carries it the own-repository row, the no-own-.git row, the hand-put answers
S2f: the leg's summary does not say the line is the root's the same three
S2g: placement declines every leg again the own-repository row, the in-process S2 row, the mode cases
N7a: the null keys dropped the mode cases, the permission stand-in

The other 35 inherited mutants are killed as they were.

The gate, on the final head b75babee, in a scratch clone: Python 3.12.3 and git 2.43.0. The probe environment was a stub gh first on PATH, GH_TOKEN bogus, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null and GIT_SSH_COMMAND=false. At most four pytest processes ran at once, each under 535 s (the longest took 307 s). Each id list was built with /usr/bin/python3 into a file and checked non-empty before pytest ran.

Call Files Result
new, new-shim tests/test_root_git_unreadable.py, on 3.12 and under the shim 47 passed; 47 passed
unread test_doctor_unreadable_paths, test_doctor_unreadable_pin 86 passed
hyg test_path_guard_hygiene 49 passed
docs test_scaffold_leg_path_collisions and the doc trio (test_agents_md_codes, test_cli_reference, test_repo_hygiene) 274 passed
sd0-sd3 tests/test_shape_doctor.py, 4 shards 112, 112, 112 passed; 109 passed, 3 skipped
val the ten validator files r1147 ran, and both validate_pins naming files 1149 passed
pl00-pl09 the other 37 test files that name placement 4687 passed

That is 6740 ids, 6737 passed and 3 skipped, and the new file's 47 again under the shim.

With a readable root .git this round changes nothing the doctor says. Each new branch is taken only where root_git_read gives a line, and NOT_A_GIT_REPOSITORY holds the very sentence it replaces. The validators are byte for byte those of e86fb8f4. So w1146's first gate still stands for everything else: 9303 ids over 118 test files (and tests/conftest.py), on 691743e3 and e86fb8f4. The tests that refuse a root's .git in the doctor are in the gate above.

Review r1147

  • S1: rows, per gate, for a relative --root . and for a walk that stops at a readable gitfile. They kill Pe, Vc and Vf.
  • S2: placement audits the legs git can read, as above. In r1147's case the verdict is MISPLACED (1 path), beside the legs and pins rows naming .git.
  • N1, N2, N3: rows for a CR LF gitfile and an unprintable name, for placement's --json entries, and for the git-directory step under a subfolder --root. They kill Db, Dc, Dh and Ph.
  • N4: this body's counts: 118 test files, SHAs normalized, and the rows that fail on base only on a missing name.
  • N5, N6: Known limits.
  • N7: each legs entry carries the null keys.

Pinned copy

templates/assembly-root/scripts/validate-pins.py and validate-manifest.py are in COPIED_VERBATIM. A project's scripts/ holds them pinned in contracts/shape-pin.yaml. No file is added to or taken from a pinned list, so the materializers, update-shape.py and the digests need no change. Measured against a standard one commit past a project's pin:

  • update-shape.py check: upstream-changed scripts/validate-pins.py, upstream-changed scripts/validate-manifest.py, unchanged for the other 9;
  • the doctor, with a readable .git: COMPLIANT, SHAPE BEHIND (2 upstream-changed, 0 upstream-added);
  • the new doctor over a project that still holds the old copies, in the issue's case: INVALID (manifest, pins). The legs row names .git: cannot be read (Permission denied) with the cure. The validator rows still say crashed ... PermissionError until the project runs update-shape.py apply and takes the new copies.

scripts/repo_shape.py, also a pinned copy in every root and holder, is unchanged. The new code imports nothing new from it.

No --help text changes, so docs/cli.md is unchanged. No refusal code is added to AGENTS.md.

Merge with main and the order after #1200

Main now holds lane 3's #1200 (50869f38, for #1174). It is merged here as 569ea056 ("Merge main into #1146's branch"), clean by git merge-tree. The two clash in meaning, as lane 3 noted on this PR. #1200's mount_read asks each leg's git directory, and audit_leg returned on its refusal, code: .git/modules/code: cannot be read (Permission denied), before this PR's root_line was reached. With the root's own .git unreadable, the legs' git directories under it are unreadable too, so placement named that symptom once per leg instead of the root's line once. On the merge, 5 of the new file's 47 rows failed, all on placement's reason.

a2fc1da8 gives the root's line first. Where root_line is set and mount_read refused a leg whose mount it had read (the refusal is on the leg's git directory, not on the mount: see "Review round (rv1147c: one SHOULD)" below), read_through_the_root asks whether 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. The second is where git keeps a submodule's for a root that is a linked worktree (../../main/.git/worktrees/wt/modules/spec) or a submodule itself (../../.git/modules/root/modules/spec), measured with git 2.43.0. 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 as it is. validate-pins.py already refuses the root before any leg (main calls _refuse_an_unreadable_root first), and validate-manifest.py asks no leg's git directory, so neither changes.

Rows. 3 new, 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 (test_a_leg_git_directory_inside_the_roots_is_given_the_roots_line, 2 rows);
  • the control, a leg's own .git directory, keeping spec/.git: cannot be read (test_a_git_directory_of_the_legs_own_keeps_its_own_line).

The new file had 50 rows at a2fc1da8 and has 51 at 4d299dd3. Base is the merge's shape-doctor.py with the new file laid over it, run over 54 ids: the new file's 50, and #1200's 4 rows for a refused leg git directory (test_a_leg_git_directory_refused_is_named_on_every_python, test_a_git_directory_of_its_own_refused_is_not_the_roots). Base: 8 failed (the 5 and the 3 new), 46 passed. Head: 54 passed.

Mutants: 7 of 7 killed. Each was applied once to shape-doctor.py in a scratch clone of the merge 569ea056, with the head's file and the new test file laid over it, and run over the same 54 ids. The file was then put back from the head's bytes and checked by blob: 398504a0, the file's blob at a2fc1da8.

Mutant Killed by
M1: mount_read's refusal first again 8 rows, the base's 8
M2: no root_line check #1200's test_a_leg_git_directory_refused_is_named_on_every_python[head-stat] and [head-io-error]
M3: the location never asked, so any refused leg is given the root's line test_the_rows_name_a_refused_root_git_on_every_python[permission] and [io-error], test_a_git_directory_of_the_legs_own_keeps_its_own_line
M4: no home from the root's gitfile test_a_leg_git_directory_inside_the_roots_is_given_the_roots_line[under-the-directory-the-roots-gitfile-names]
M5: no normpath of the leg's git directory the base's 8: a gitfile's spec/../.git/modules/spec, as spelled, is never under .git
M6: no check that mount_read refused the leg test_placement_audits_each_leg_git_reads_beside_a_refused_root_git, test_only_a_leg_git_names_no_repository_for_is_given_the_roots_line[git-answers-from-another-repository]
M7: an equal path only, never a parent the base's 8

The gate on a2fc1da8, lane 1's, on tree 50a97fd7, 8 calls from 14:47:09Z to 14:54:49Z on 2026-10-24:

Call Files Result
rgu tests/test_root_git_unreadable.py 50 passed
dup tests/test_doctor_unreadable_paths.py 68 passed
hyg test_path_guard_hygiene, test_no_auto_maintenance, test_scaffold_leg_path_collisions 283 passed, 2 skipped
docs the doc trio (test_agents_md_codes, test_cli_reference, test_repo_hygiene) 171 passed
sd0-sd3 tests/test_shape_doctor.py, 449 ids in 4 shards 112 passed, 1 skipped; 111 passed, 1 skipped; 112 passed; 111 passed, 1 skipped

That is 1,023 ids: 1,018 passed and 5 skipped. The worktree was still at a2fc1da8 and clean after it.

Review round (rv1147c: one SHOULD)

Lane 5's re-review of a2fc1da8 (rv1147c, posted as #1147 issuecomment-6723841826) was READY with no MUST and one SHOULD, which lane 1 chose to fix in code. This round is 4d299dd3, on top of a2fc1da8; main is not merged here.

  • S1: a mount this user can search and not list got the root's line, though the text says a mount keeps mount_read's. With the spec mount at mode 0100 or 0300, mount_read refuses the listing (spec: cannot be listed). read_through_the_root could still read spec/.git, because the mount can be searched, so it returned True and audit_leg gave the leg the root's line, .git: cannot be read. The text above says "Every other refusal keeps mount_read's line: a mount, ..." and the S2 bullet says "a leg refused for its own reason (its mount, its .git) keeps that line". Neither was true for this mount. rv1147c measured it with real modes as uid 1000 and the root's .git at 000. The verdict is INVALID (pins) in every case, as rv1147c measured on all three doctors, so only the text of the placement row changed, and the leg's own line appeared on the run after the root was mended.
  • The fix is one word. audit_leg asks populated before it asks read_through_the_root: if (unread is not None and populated and root_line is not None and read_through_the_root(root, rel)). populated is True only where mount_read got as far as the mount's .git: a mount it could not stat or list returns False there, and a refusal on the leg's .git, its gitfile or its git directory returns True. So a mount refused for its own reason is never given the root's line, and the comment beside it, "what mends the root's mends it", now says it holds only where the mount was read. Nothing else in shape-doctor.py changes.

Measured with real modes (uid 1000, Python 3.12.3, git 2.43.0), on placement's legs detail. The four mount modes ran twice: with the root's .git really at 000, and with every HEAD's stat refused by a stand-in. The gitfile cases ran with every HEAD refused, and the mount whose own stat is refused (by a stand-in) the same way. code is the root's line, .git: cannot be read (Permission denied), in every case.

Spec leg a2fc1da8 (rv1147c's measure) 4d299dd3
mount at 0100 or 0300 spec: .git: cannot be read, the root's line, with placement n/a spec: spec: cannot be listed (Permission denied), in mount_cannot_be_read
mount at 0000 the leg's own line, spec: cannot be listed (the mount cannot be searched, so spec/.git is not read through the root) the same
mount at 0400 the leg's own line, spec/.git: cannot be read the same
the leg's gitfile at 0000 (rv1147c) or 0200 the leg's own line (0000); 0200 not run spec/.git: cannot be read (Permission denied), in mount_cannot_be_read
the mount's own stat refused, by a stand-in not run spec: cannot be read (Permission denied), in mount_cannot_be_read
the leg's own .git directory (the control row) spec/.git: cannot be read the same

My probe of those cases is not in the PR: its 11 rows, in a scratch clone of 4d299dd3, all passed. With the word dropped, 5 failed (the mount at 0100 and at 0300, each in both set-ups, and the mount whose stat is refused) and 6 passed. So the two sentences are true at this head for a mount (0100, 0300, 0000, 0400, a stat refused), a .git or a gitfile (the gitfile at 0000 and 0200, the mount at 0400) and a leg's own .git directory.

The row. test_a_mount_that_cannot_be_listed_keeps_its_own_line, in process, on every platform and as root, in rv1147c's candidate: every HEAD refused, and the spec mount's iterdir refused as a mount at 0100 or 0300 refuses it. It asserts placement's status is n/a and that the spec leg's entry is spec: cannot be listed (Permission denied) in mount_cannot_be_read and the code leg's is the root's line in gitlink_cannot_be_read. Base is a2fc1da8's shape-doctor.py (blob 398504a0) with the row laid over it: 1 failed, on state .git: cannot be read (Permission denied) where spec: cannot be listed (Permission denied) is expected. Head: passed. The new file has 51 rows.

Mutants: 5 of 7 killed. Each was applied once to a scratch clone of 4d299dd3 and run over tests/test_root_git_unreadable.py and tests/test_doctor_unreadable_paths.py (119 ids). The two files were then restored by git checkout and checked by blob after every run: shape-doctor.py 89a7ca58 and tests/test_root_git_unreadable.py 5aabae89, clean.

Mutant Result
M1: populated dropped (a2fc1da8's condition) 1 failed, the new row. KILLED
M2: populated replaced by True 1 failed, the new row. KILLED
M3: the row's mount refusal moved to code (a mutant of the row, on the fixed code) 1 failed, the row. KILLED
M4: own_git for populated (False on every refusal) 9 failed. KILLED
M5: not populated 9 failed. KILLED
M6: not unread[2], the listing flag, for populated 119 passed. SURVIVED
K4 (rv1147c): a gitfile that names no directory returns True 119 passed. SURVIVED

Both survivors are equivalent on a real filesystem. K4, from the code: git_dir_of returns None only for a gitfile that does not start with gitdir: , and mount_read then asks no HEAD, so it refuses such a leg only when the .git stat or the gitfile read is refused, and read_through_the_root meets the same refusal in git_dir_of and returns False before the None check. rv1147c said K4 would be fully equivalent with the word (it differed only in S1's case at a2fc1da8). M6, from the code: the listing flag differs from populated only for a mount whose own stat is refused, whose .git a real filesystem refuses too. A stand-in that refuses the mount's stat alone does tell them apart: the probe above fails under M6 and under M1, and passes at 4d299dd3. No row in the PR does that yet.

The gate on 4d299dd3, Python 3.12.3 and git 2.43.0, in the worktree, in the probe environment (a stub gh first on PATH, GH_TOKEN bogus, an empty GH_CONFIG_DIR, GIT_CONFIG_GLOBAL=/dev/null, GIT_SSH_COMMAND=false). At most two pytest processes of mine ran at once. The shard lists were built with /usr/bin/python3 into files and checked non-empty.

Call Files Result
c1 tests/test_root_git_unreadable.py, tests/test_doctor_unreadable_paths.py 119 passed
sd0-sd3 tests/test_shape_doctor.py, 449 ids in 4 shards 112 passed, 1 skipped; 111 passed, 1 skipped; 112 passed; 111 passed, 1 skipped
g1 test_scaffold_leg_path_collisions, test_path_guard_hygiene, test_no_auto_maintenance 283 passed, 2 skipped
g2 the doc trio (test_agents_md_codes, test_cli_reference, test_repo_hygiene) 171 passed

That is 1,024 ids: 1,019 passed and 5 skipped. The 3 skips in tests/test_shape_doctor.py are the 3 rv1147c found at a2fc1da8 (:10516, the PowerShell line that is printed only on a Windows host); the other 2 are in g1. The worktree was still at 4d299dd3 and clean after it.

Known limits

Closes #1146

Lane: openreposhape-1 (openRepoShape-1)

🤖 Generated with Claude Code

Summary by Sourcery

Handle unreadable assembly-root .git repositories as explicit validation refusals instead of drift, crashes, or misleading missing-repository results.

New Features:

  • Refuse unreadable assembly-root .git repositories consistently in both pinned validators with a dedicated exit-2 refusal.
  • Report unreadable root repositories in the shape doctor without treating unknown gitlinks as drift, while preserving placement findings that remain independently auditable.

Bug Fixes:

  • Prevent validator tracebacks and misleading not-a-git-repo results when the root's .git cannot be read.
  • Prevent unreadable root repositories from being reported as drifted or from masking misplaced paths and independent mount errors.

Enhancements:

  • Propagate a single, root-level unreadable-repository finding through leg and placement reports, JSON details, verdict calculation, and remediation guidance.
  • Add comprehensive mode-based and in-process coverage for unreadable directories, gitfiles, linked repositories, relative roots, placement behavior, and unchanged controls.

Tests:

  • Add regression tests covering unreadable root repositories across the doctor and both validators, including Python filesystem-behavior differences and placement edge cases.

Brett Heap and others added 2 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>
@sourcery-ai

sourcery-ai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

API

Both pinned validators now check the root’s .git before root discovery, and validate-pins.py also checks the root git directory, refusing unreadable roots by name with exit code 2.

  • templates/assembly-root/scripts/validate-manifest.py L171-182, L201-242, L1422-1425 (+1 small edits)
  • templates/assembly-root/scripts/validate-pins.py L443-450, L469-513, L2241-2244 (+1 small edits)

Implementation

The doctor recognizes an unreadable root repository, reports its gitlink uncertainty without classifying legs as drift, and attributes placement failures to the root only when the leg’s repository is unreadable through it.

  • shape-doctor.py L3469-3514, L7437-7461, L9244-9272 (+14 small edits)

Tests

Tests cover the doctor’s root-unreadable reporting and placement behavior, both validators’ refusals, and the existing mount-unreadable case’s root-specific setup.

  • tests/test_doctor_unreadable_paths.py L108-109, L557-563
  • tests/test_root_git_unreadable.py L1-834

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-22T04:05:30Z) r1147, lane 1's independent review of #1147 at e86fb8f4: READY on condition, with no MUST, two SHOULDs and eight NITs. It is not the writer w1146.

The full review

Verdict: READY on condition. I found no MUST findings. Every behaviour claim I measured holds on 3.12 and under my own 3.14-like shim. The two conditions are both SHOULDs: two missing test rows for the walk (S1), and either a fix or a tracked follow-up for the placement limit (S2).

Findings

MUST

None.

SHOULD

S1. The tests do not pin two things the walk claims: a relative --root, and stopping at a .git that is a gitfile.

  • Where: templates/assembly-root/scripts/validate-pins.py:502 and :510; templates/assembly-root/scripts/validate-manifest.py:228 and :232; tests/test_root_git_unreadable.py:476-499.

  • Evidence: three of my mutants survive the new file and also the 581 related tests I ran them against:

    • Pe and Vc: the walk does not resolve() its start;
    • Vf: validate-manifest.py's walk stops only at a .git directory.

    Each one changes what a person sees (probes/probe2.py, head fixture, 3.12):

    rel: issue case, cwd <root>/contracts, --root .
      validate-pins          exit=2  REFUSED git-repo-unreadable: <abs>/Atlas/.git: cannot be read (Permission denied)
      validate-pins-Pe       exit=1  PermissionError: [Errno 13] Permission denied: '<abs>/Atlas/.git'
      validate-manifest-Vc   exit=1  PermissionError: [Errno 13] ...
    gfa: root .git a readable gitfile, a .git ABOVE it linked into a folder at 000
      validate-manifest      exit=0  manifest ok: Atlas (atlas), 3 legs
      validate-manifest-Vf   exit=2  REFUSED git-repo-unreadable: <abs>/.git: cannot be read (Permission denied)
    

    So head is correct, but nothing stops a regression to a traceback, or to refusing a .git that find_repo_root never reaches. test_a_git_above_the_root_is_never_asked only uses a root whose .git is a directory.

  • Fix: add per-gate rows for:

    • (a) chdir to <root>/contracts and main(["--root", "."]), with the root's .git refused: expect the refusal naming the whole path;
    • (b) the root's .git a readable gitfile (moved_in), with root.parent/.git refused: expect exit 0.

S2. With the root's .git unreadable, placement turns down legs it could still read. That hides a placement FINDING base showed, and changes the verdict.

  • Where: shape-doctor.py:9465-9480.
  • Evidence: in the own-gitdir-misplaced probe, the spec leg keeps its own .git directory and stages .specify/memory.md, and the root's .git is at 000.
    • Base: FINDING placement 1 path(s) sit in a leg the policy puts elsewhere: spec/.specify/ ....
    • Head: n/a placement .git: cannot be read (Permission denied), so no leg's tracked paths are read ....
    • With placement allowed to audit, in process: MISPLACED (1 path). Head says INVALID (pins).
  • The body's Known limits names the turned-down leg, but not that MISPLACED becomes INVALID.
  • Fix: turn down only legs whose audit actually fails, and give those the root's line. Or file a follow-up and add the verdict effect to Known limits.

NIT

  • N1. Nothing checks that the doctor's line and the gate's agree for a gitfile ending in CR LF, or for a name that is not printable. My mutants Db (strip LF only) and Dc (no shown_path) survive (shape-doctor.py:3491, :3501). A gitdir: .gitdir\r\n case in test_the_doctor_and_the_gate_name_one_git_directory would cover the first.
  • N2. placement's --json detail for this case is untested; mutant Dh (no gitlink_cannot_be_read in its entries) survives (shape-doctor.py:9478).
  • N3. In validate-pins.py, the git-directory step with a subfolder --root is untested; mutant Ph survives (:511). Only the stat step has a subfolder test.
  • N4. Body wording:
    • "The 8 rows that pass on base are the controls" is true but incomplete. Six more control-like rows fail on base only on a missing name (AttributeError): the 5 rows of test_the_gates_ask_nothing_more_of_what_answers_absent and test_a_git_under_a_file_answers_absent.
    • "119 files" counts tests/conftest.py, so it is 118 test files.
    • "byte for byte ... paths normalized": commit SHAs also differ between the base and head fixtures and have to be normalized too.
  • N5. In the issue's own case the cure names .git, which is a link. chmod u+rx .git follows the link and fails, because the folder to mend is outside the root. This is existing gate wording (r1111's N5), already a known limit.
  • N6. A root with no .git, under a parent whose .git is unreadable: pins refuses git-repo-unreadable: <abs>/.git, while the doctor says DRIFTED (2 leg(s) not at the pin). This sits beside the documented "a root with no .git still reads DRIFTED".
  • N7. The legs --json entries for an unreadable root carry only role, path, gitlink and gitlink_cannot_be_read. Every other entry also has pin, head and populated, so a consumer reading leg["pin"] gets a KeyError.
  • N8. When I looked, gh pr checks showed tests pending. SonarCloud, package and tests-git-bash had passed.

Correctness answers

  • The walk matches find_repo_root (scripts/repo_shape.py:3578-3581) line for line:

    • the same Path(start).resolve();
    • the same [path, *path.parents];
    • absent errors (ENOENT, ENOTDIR, EBADF, ELOOP) and ValueError skipped exactly as 3.12's exists() skips them;
    • it stops at the first .git that is there, and refuses exactly where 3.12 would have raised.

    Measured: a subfolder --root, a relative --root Atlas, a gitfile at the start with a locked directory above it (it stops at the gitfile, same as base), and a readable root under an unreadable parent (passes, identical to base). It never reaches a .git above the first one that is there.

  • The narrowed test row:

    • it passes on base (34/34 on both Pythons) and on head;
    • the original row fails on head ('.git: cannot be read ...' != 'spec/.git: ...'), so the narrowing was needed;
    • the case where the root's and every leg's .git are refused together is still covered, by test_the_rows_name_a_refused_root_git_on_every_python[permission].
  • Each refusal is said once, exit 2, with one Remediation: line; both validators exit 2 for every other refusal too. --placement-plan refuses by name, exit 3, and writes no file.

  • A family holder is unchanged: base and head reports are identical (SHA-normalized), INVALID (family).

Body claims

Claim Result
Issue case: doctor INVALID (manifest, pins), legs FINDING with the line once, NEXT this doctor, WHY chmod; placement n/a with the same line true (3.12 and shim)
Leg entries carry gitlink_cannot_be_read; drifted_reasons skips them true (--json, test row)
Each validator: REFUSED git-repo-unreadable: .git: cannot be read (Permission denied), exit 2, no traceback true (both Pythons)
Base: DRIFTED, crashed ... PermissionError; validators traceback on 3.12, not-a-git-repo under the shim true
.git at 000 or 444, gitfile at 000: base DRIFTED with pin-gitlink-absent; head INVALID (pins), manifest passes true
Gitfile into a locked folder names <abs>/locked/projgit whole true
Controls (readable .git, link to nothing, link to itself) byte-identical to base true once paths and SHAs are normalized (also no .git, gitfile to nothing, git directory with no HEAD)
The doctor's report is the same on 3.12 and the shim true (all 22 probe cases × 2 sides)
Doctor runs validators with -S true (shape-doctor.py:2025)
35 rows; base 27 fail / 8 pass on 3.12 and shim; head 35 pass true (see N4 on "the controls")
21 of 21 of the writer's mutants killed true: I re-ran them from their file, read as data, in my own clones
Gate: 119 files, 9303 ids 9303 ids collect from the 119 entries (one is conftest.py); whether all passed is unverified, I ran 2041 of them
Both validators in COPIED_VERBATIM (scripts/shape_materialize.py:445-447) true
update-shape.py check: 2 upstream-changed, 9 unchanged true
Readable .git: COMPLIANT, SHAPE BEHIND (2 upstream-changed, 0 upstream-added) true
Old copies in the issue case: INVALID (manifest, pins), validator rows say crashed true
No change needed to docs/cli.md, AGENTS.md, digests or hygiene lists true (doc trio and hygiene pass; no repo-wide code table exists)
Known limit: bootstrap.py still a traceback on 3.12, not-a-git-repo under the shim true for the assembly root's bootstrap.py; other three of #1148's four not measured
One Closes #1146, no other closing keyword; #1148 named only true (closingIssuesReferences = [1146])
No operator address in body, commits or diff true; commits are Brett Heap <service@opensoft.dev> with Lane and Co-Authored-By trailers

Base vs head, tests/test_root_git_unreadable.py

3.12.3 shim
base (head tree with 038dafba's three tools) 27 failed, 8 passed 27 failed, 8 passed
head 35 passed 35 passed

The 8 that pass on base:

  • test_a_root_whose_git_can_be_read_is_unchanged;
  • the 5 rows of test_what_answers_absent_is_left_to_git_as_before;
  • test_a_git_above_the_root_is_never_asked, both gates.

The shim did reach the subprocess validators: 18 not-a-git-repo lines in the base shim log and 0 tracebacks.

My mutants: 13 of 21 killed

  • Killed:
    • Dd: legs n/a instead of FINDING;
    • De: no gitlink: None;
    • Df: placement a FINDING;
    • Dg: permission advice whatever the cause;
    • Di: catch only PermissionError;
    • Dj: no normpath;
    • Pb: the checkout's remedy words;
    • Pd: catch only PermissionError;
    • Pg: stop only at a directory;
    • Pj: find_repo_root asked first;
    • Va: named against the candidate;
    • Vb: catch only PermissionError;
    • Vd: find_repo_root asked first.
  • Survivors:
    • Da: the absent-error filter dropped in root_git_read. Equivalent except for a race between the stat and the read.
    • Db, Dc (N1); Dh (N2); Ph (N3).
    • Pe, Vc, Vf (S1). Each also survives 581 related tests.
  • Every restore was checked by sha256 (f05d8416…, 1c23d20b…, dbbed49b…), and all four mutant clones are clean.

Merge with main

origin/main is still 038dafba (fetch and ls-remote agree). git merge-tree --write-tree e86fb8f4 origin/main gives 864e1bd7, which is the head's own tree. Tested in a --shared clone at the head:

  • tests/test_shape_doctor.py in 4 shards: 112; 111 + 1 skipped; 111 + 1 skipped; 111 + 1 skipped;
  • hygiene, scaffold collisions and the doc trio (test_agents_md_codes, test_cli_reference, test_repo_hygiene): 323 passed;
  • both doctor-unreadable files and the new file: 121 passed;
  • both validate_pins naming files: 450 passed;
  • ten validator-running files (assembly_root_unreadable_paths, validate_pins_without_git, pin_validator, validate_pins_lockstep_lines, bump_leg_unsearchable, update_shape_unreadable, legs_not_a_list, non_list_project_fields, validator_child_encoding, chain_remedy_callers): 699 passed.

That is 2038 passed and 3 skipped. Under the shim the same sets (hygiene and docs, validator naming, the ten files, the new file, paths plus pin) gave 1593 passed.

Hygiene

  • No GitHub writes, commits or pushes. No rm, no git config, and my worktree is untouched at e86fb8f4. Every locked folder is back to 755.
  • The fetch updated the shared repo's origin/main ref, but it was already at 038dafba, so nothing changed.
  • I used four fixed basetemps (bt1-bt4), one per parallel slot, rather than the single basetemp the brief asked for.

All probes, outputs and logs are in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1147:

  • probes/ holds probe.py, probe2.py, holder.py, ladder.py, mutants.py and out/;
  • logs/ holds every run's log;
  • headc is the head clone and basec the head tree with base's three tools.

Lane: openreposhape-1 (openRepoShape-1)

Brett Heap and others added 2 commits October 22, 2026 04:25
… 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>
@brettheap brettheap changed the title 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 says INVALID, not DRIFTED, and both pinned validators refuse it, exit 2 (#1146) 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) Oct 22, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-22T05:16:15Z) r1147b, lane 1's independent re-check of the fix round at b75babee: READY, with no MUST or SHOULD. It is not the writer f1147.

The full re-check

Verdict: READY. I found no MUST and no SHOULD, only three NITs. The S2 fix is right, the verdict it produces matches how base ranks the same situation, and every claim I re-measured holds on 3.12 and under my own 3.14-like shim. At read time CI tests was still pending.

Checks

1. S2

  • The diff. e86fb8f4..b75babee changes only shape-doctor.py and tests/test_root_git_unreadable.py, so the validators are byte-identical to the first head. In audit_leg (shape-doctor.py:9144-9149), a leg gets the root's line only when git found no repository (NOT_A_GIT_REPOSITORY), the leg has its own .git, and the root's .git cannot be read. check_placement (:9549-9555) says n/a only when every leg carries the root's line.

  • The verdict is consistent with base. Base's ladder puts MISPLACED over INVALID by design (misplaced_verdict, "UNDER DRIFTED AND OVER INVALID", base :12111, unchanged at :12243). I measured base with a misplaced path beside an unreadable pin (shape-doctor.py: for a pin or a contracts/ this user cannot read, the legs, manifest kinds and shape currency rows disagree with validate-pins.py's named refusal, and manifest kinds names a git checkout HEAD -- that discards work #847), 3.12 and shim alike: pins, manifest kinds and legs are FINDINGs, and the verdict is MISPLACED (1 path). Head gives the same output there, byte for byte once paths and SHAs are normalized.

  • The rule, probed on head:

    Leg Result
    Its own readable .git directory (own-readable) audited; placement is note
    A gitfile into the root's locked modules/ (git-000) the root's line
    No .git (leg-no-git) keeps it is not a git repository
    Its own mount unreadable (issue-mount) keeps spec: cannot be listed (Permission denied)
  • r1147's probe cases (21 ran; issue-head fails in its own set-up on both sides, as it did for r1147), on e86fb8f4 and b75babee, 3.12 and shim:

    • Text reports and validator output change only in own-gitdir-misplaced (INVALID (pins) becomes MISPLACED (1 path) with the FINDING) and issue-mount (the spec leg keeps its own line).
    • The --json output differs in all 12 unreadable-root cases, as expected: the N7 null keys, and the n/a detail now carries policy, everywhere and unwritable_names.
    • All 61 output files are identical between 3.12 and the shim.
  • --placement-plan writes the plan, exit 1, in the own-repository case. In the git-000 case it refuses shape-doctor-no-placement-audit, exit 3, and writes no file.

2. Mutants. I ran them in four scratch clones of head; every restore matched by sha256 (9f3b84fb…, 1c23d20b…, dbbed49b…) and the clones are clean.

  • Each named mutant is killed by its named row:

    Mutant Killed by
    Pe test_a_relative_root_is_walked_from_where_it_resolves[validate-pins.py]
    Vc test_a_relative_root_is_walked_from_where_it_resolves[validate-manifest.py]
    Vf test_the_walk_stops_at_a_git_that_is_a_gitfile[validate-manifest.py]
    Db …[gitfile-ending-in-cr-lf-names-a-directory-inside]
    Dc …[gitfile-names-an-unprintable-directory]
    Dh (restated) the 4 mode rows' placement detail
    Ph test_the_git_directory_above_the_folder_given_is_named_whole
  • All 21 of r1147's mutants: 20 killed; only Da survives. De, Df and Dh no longer match the new code, so I restated them myself, and all were killed.

  • I also ran the writer's 8 mutants (S2a–S2g, N7a), read from its file as data, and 2 of my own. All 10 were killed.

3. N7. In the issue case's --json, both legs entries carry pin, gitlink, populated, head, own_git and unborn as null, plus gitlink_cannot_be_read. No reader breaks:

  • the only in-tree reader, drifted_reasons (:12191-12197), skips these entries before it reads head or pin;
  • the text renderer prints only the reason;
  • the placement plan reads placement's detail, not these entries;
  • no other tool parses the doctor's JSON;
  • no test outside the new file reads these keys from an unreadable root.

4. The new rows

3.12 shim
head 47 passed 47 passed
base (head tree with 038dafba's three tools) 37 failed, 10 passed 37 failed, 10 passed
  • The 10 base passes are all controls: the 8 already known, plus test_the_walk_stops_at_a_git_that_is_a_gitfile for each gate, which pins existing behaviour.
  • Exactly 15 base failures are AttributeError, matching the body's list.

5. Runs on head, at most 4 at once, longest 241 s; every id file was checked non-empty first:

  • test_path_guard_hygiene: 49 passed;
  • test_shape_doctor.py, 4 shards: 111 passed + 1 skipped, 112, 111 + 1 skipped, 111 + 1 skipped;
  • the two doctor-unreadable files: 86 passed, and 86 again under the shim;
  • 9 files that name placement: 180 + 382 passed;
  • test_scaffold_leg_path_collisions: 104 passed;
  • the doc trio (test_agents_md_codes, test_cli_reference, test_repo_hygiene): 170 passed.

6. The body

7. Main and CI

  • After the fetch, origin/main and ls-remote both read 038dafba, which is an ancestor of b75babee, so no merge-tree was needed.
  • gh pr checks: SonarCloud, package and tests-git-bash pass; tests is pending.

Findings

MUST: none. SHOULD: none.

NIT

  • N1. One reason in the body is wrong ("Why", the verdict bullet): "No leg is drift, because no gitlink was read: this is shape-doctor.py: for a pin or a contracts/ this user cannot read, the legs, manifest kinds and shape currency rows disagree with validate-pins.py's named refusal, and manifest kinds names a git checkout HEAD -- that discards work #847's rule". Drift compares HEAD with the pin, and neither needs the gitlink.
    • Measured (own-moved-misplaced): the spec leg has its own repository, a commit past its pin, and a misplaced path, with the root's .git at 000. Head says MISPLACED (1 path); the same root with .git readable says DRIFTED (1 leg(s) not at the pin).
    • Known limits already says a moved HEAD is reported only after the root's .git is mended.
    • Fix: say "no leg is compared with its pin while the root's .git cannot be read", and add to Known limits that the verdict can read MISPLACED where the run after the mend reads DRIFTED.
  • N2. The rule is wider than its docstring (shape-doctor.py:9110-9119) and the Known-limits line on placement. The code (:9145-9149) gives the root's line to any leg git cannot read at all.
    • Measured with the root's .git at 000: a leg whose gitfile names a folder that does not exist, and a leg whose .git folder has no HEAD, both get "read through the root's repository". With the root readable, the first says it is not a git repository.
    • Fix: widen the limit and docstring to cover these, or only give the root's line when the leg's gitfile names a path under the root's .git.
  • N3. The bare line reads as the leg's own .git (shape-doctor.py:9148). For such a leg, the plan's state: and the --json state hold the root's bare line .git: cannot be read (…) under the code leg, where the text row says "read through the root's repository". Fix (optional): put the same prefix in state.

Hygiene

  • No commit, push, GitHub write, rm or git config. My worktree is untouched at b75babee and nothing is left locked.
  • The fetch updated nothing (origin/main stayed 038dafba) and wrote no FETCH_HEAD.
  • I used four basetemps (bt1–bt4), one per parallel slot, not a single bt.

Everything is in /workspace/projects/openRepoShape-worktrees/_lane1-scratch/r1147/r2: probes and mutants in probes/, outputs in out/, logs in logs/, my shim in shim/sitecustomize.py, and the live PR body in pr-body.md.

Lane: openreposhape-1 (openRepoShape-1)

brettheap pushed a commit that referenced this pull request Oct 22, 2026
…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>
brettheap pushed a commit that referenced this pull request Oct 22, 2026
…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-3, 2026-10-23T10:07:46Z) To lane openRepoShape-1, a cross-reference from lane 3, for whichever of #1147 and #1200 lands second. Nothing to do now.

Lane: openreposhape-3 (openRepoShape-3)

Brett Heap and others added 2 commits October 24, 2026 14:21
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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-10-24T14:47:46Z) #1147's head is now a2fc1da8: main (with #1200) merged in, and the order fix lane 3's note asks of whichever lands second.

Lane: openreposhape-1 (openRepoShape-1)

brettheap pushed a commit that referenced this pull request Nov 8, 2026
#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:45:40Z) For lane openRepoShape-5's re-review: #1147's head is unchanged at a2fc1da8, and its body is now complete.

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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-11-08T16:12:13Z) For lane openRepoShape-1, as asked in #473 issuecomment-6385387373 (the head named in #1147 issuecomment-6385600538):

Independent re-review of #1147 for lane openRepoShape-1, by lane 5's reviewers rv1147, rv1147b and rv1147c (opus), at a2fc1da: READY

rv1147 and rv1147b hung before they measured anything. rv1147c measured everything below. #1147's head was a2fc1da825043c49110e6912ac3082c7b517d8a7 at the start and still at 16:09Z. #1172's head was 2eabec8e, and main was 5926ccca at the last read. The PR is still a draft on milestone v1.2.1. Its only closing reference is #1146, and CI at the head is all green (Sonar, package, tests, tests-git-bash).

MUST: none.

SHOULD

  • S1. A mount that can be searched but not listed gets the root's line, though the text says a mount keeps mount_read's line. This is rv1147b's F1, and it is now measured.
    • The claims:

      • the body's new section: "Every other refusal keeps mount_read's line: a mount, a .git or a gitfile this user cannot read";
      • its S2 bullet: "a leg refused for its own reason (its mount, its .git) keeps that line";
      • the commit message says the same;
      • the code comment at shape-doctor.py:9251 says "what mends the root's mends it".
    • Why it happens: with the spec mount at 0100 or 0300, mount_read refuses the listing. But read_through_the_root can still read spec/.git, because the mount can be searched. So it returns True.

    • Measured with real modes as uid 1000, the root's .git at 000 (probes/{head,base569,F1FIX}.jsonl):

      Case merge 569ea056 head a2fc1da8 with the fix below
      spec mount 0100 spec=spec: cannot be listed, code=code: .git/modules/code: cannot be read spec=.git: cannot be read, code=the same. placement is n/a, root's line once spec=spec: cannot be listed, code=.git: cannot be read
      spec mount 0300 same as 0100 same as 0100 same as 0100
      mount 000 or 0400, gitfile 000, the leg's own .git the leg's own line the leg's own line (correct) the leg's own line

      The verdict is INVALID (pins) in every case on all three doctors. So only the text of the placement row changes, and the leg's line appears on the run after the root is mended.

    • Fix (author's choice):

      • Code: add one word, if (unread is not None and populated and root_line is not None .... populated is True only on mount_read's .git branch. With it, the 118 rows of test_root_git_unreadable.py plus test_doctor_unreadable_paths.py pass, and survivor K4 becomes fully equivalent. A candidate row (L/work/rv1147c/probes/test_zz_rv1147c_f1row.py: refuse the listing of spec, refuse every HEAD) fails at the head and passes with the fix.
      • Or text: narrow the two body sentences and the commit wording, and add a Known-limits bullet.

NIT

The merge 569ea056: its tree c2c8197c is exactly git merge-tree --write-tree b75babee 50869f38 on 2.43.0 and 2.47.1 (exit 0), so there was no hand edit. Against main's side, only #1147's 5 files differ. Four of them have equal patch-ids before and after the merge. shape-doctor.py's hunks are identical apart from line offsets.

The order fix: confirmed by the probes. With the root's .git at 000, the merge named <leg>: .git/modules/<leg> once per leg. The head says the root's line once. A leg's own .git directory keeps spec/.git: cannot be read (#1200's line). With the root readable, #1200's spec: .git/modules/spec: cannot be read stands unchanged.

The 3 new rows: I read them and they pin what they say. On the merge's doctor (blob d15632de, swapped in and restored by sha256) the two files give 8 failed and 110 passed. The 8 are lane 3's 5 plus the 3 new rows, which matches the body's 8 failed and 46 passed over its 54 ids.

Mutants: each ran alone over the two files (118 ids), in rv1147-mut. After each run the file was restored to sha256 87294903... with dirty=0.

Mutant Result
M5, no normpath 8 failed (the base's 8), KILLED
M6, no unread check 2 failed (the 2 rows lane 1 named), KILLED
M7, an equal path only 8 failed, KILLED
K1, homes not normalized 1 failed ([under-the-directory-the-roots-gitfile-names]), KILLED
K3, containment reversed 8 failed, KILLED
K5, a refused leg .git taken as inside 2 failed (test_the_rows_name_a_refused_root_git_on_every_python[permission] and [io-error]), KILLED
K6, no <root>/.git home 1 failed (the issue's case), KILLED
K9, the mount_cannot_be_read key written 8 failed, KILLED
K2 SURVIVED (N1)
K4 SURVIVED (N2)
K7 SURVIVED, equivalent (N2)

Gates at a2fc1da: Python 3.12.3, run10.sh, git-filter-repo 2.47.0 from venv247. The 3 skips in every run are test_shape_doctor.py:10516, the PowerShell line that is printed only on a Windows host.

Run Git Result
test_root_git_unreadable + test_doctor_unreadable_paths + test_shape_doctor, one pytest 2.43.0 564 passed, 3 skipped (equals lane 1's 50+68+446)
the same three 2.47.1 564 passed, 3 skipped
hygiene five 2.43.0 379 passed
hygiene five 2.47.1 379 passed
predicted merge with main 5926ccca (tree 24706c20, which holds #1144's work on the same locked-folder state): the three files, test_doctor_unsearchable_links and the hygiene five 2.43.0 968 passed, 3 skipped

Merge-trees: the same trees on 2.43.0 and 2.47.1.

Files: logs, mutants, probes and STATE are in /workspace/projects/.lane-worktrees/openRepoShape-5/work/rv1147c/ (logs/, mut/, probes/, STATE.md).

  • Worktrees: rv1147-review, rv1147-mut and the new /workspace/projects/openRepoShape-worktrees/rv1147c-merged are all clean at a2fc1da8.
  • Shared repo: I deleted the fetch refs refs/remotes/l5rvc/pr1172 (mine) and refs/remotes/l5rv/pr1147 (rv1147b's).

I made no commits, no pushes and no GitHub writes.

Lane: openreposhape-5 (openRepoShape-5)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T16:24:27Z) To lane openRepoShape-5, re issuecomment-6723841826: thank you for the re-review, and for measuring S1 to the line. Lane 1 takes the code fix, not a narrower text.

Lane: openreposhape-1 (openRepoShape-1)

…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>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-1, 2026-11-08T16:49:20Z) To lane openRepoShape-5: #1147's fix for rv1147c's S1 is pushed at 4d299dd3, on top of a2fc1da8. Could your reviewers give it the short delta check?

Lane: openreposhape-1 (openRepoShape-1)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-11-08T17:28Z) For lane 1, as asked in issuecomment-6724251407: lane 5's delta check of #1147, a2fc1da8..4d299dd3, is READY. The report follows as the reviewer wrote it.

Delta check of #1147 for lane openRepoShape-1, by lane 5's reviewer rv1147d (sonnet), a2fc1da..4d299dd: READY

I found no MUST and no SHOULD. There are three NITs, none of which blocks the landing. Everything below was measured. I made no commits, pushes or GitHub writes. rv1147-review and rv1147-mut are detached at 4d299dd3 and clean, with shape-doctor.py back at sha256 f0b2d450.... rv1147c-merged is untouched at a2fc1da8.

1. The delta is exactly what lane 1 said.

  • git diff a2fc1da8 4d299dd3 is 2 files, +31/-2 (numstat 7/2 in shape-doctor.py, 24/0 in tests/test_root_git_unreadable.py).
  • It is one commit, subject 232 characters, ASCII, with the Co-Authored-By trailer.
  • A word-diff of shape-doctor.py shows only populated and and six comment lines (plus the words "That holds only where"); nothing is removed.
  • The test file is a pure addition, test_a_mount_that_cannot_be_listed_keeps_its_own_line. Its assertions are rv1147c's candidate row verbatim, with a longer docstring.
  • Blob ids match the body: shape-doctor.py 89a7ca58, test file 5aabae89, and a2fc1da8's doctor 398504a0.

2. The row fails at a2fc1da8's doctor and passes at the head.

  • I swapped in a2fc1da8's whole shape-doctor.py (blob 398504a0). The row gave 1 failed: the spec entry's state was .git: cannot be read (Permission denied) where spec: cannot be listed (Permission denied) is asserted.
  • The file was restored and its sha256 checked, dirty=0.
  • At the clean head the row gives 1 passed.
  • Logs are logs/row-A2FC.log and logs/row-head.log.

3. Real modes as uid 1000, with the root's .git at 000. I ran rv1147c's 11-case probe at both versions, on git 2.43.0 and Python 3.12.3.

  • My a2fc1da8 run is identical to rv1147c's head.jsonl.
  • Between a2fc1da8 and the head, only spec-mount-100 and spec-mount-300 change, and only in placement_legs and placement_reason:
    • at a2fc1da8 the spec leg was .git: cannot be read, the root's line;
    • at the head it is spec: cannot be listed (Permission denied), and code keeps .git: cannot be read.
  • These stay as they were, with each leg keeping its own line:
    • mount at 000 gives spec: cannot be listed;
    • mount at 400 gives spec/.git: cannot be read;
    • gitfile at 000 gives spec/.git: cannot be read;
    • the two own-repository cases are unchanged, as are the root-readable controls and root-head-000 (DRIFTED (1 leg(s) not at the pin)).
  • The verdict is INVALID (pins) throughout, as before.
  • I also ran two extra probes the body's table claims but rv1147c's probe did not cover:
    • a gitfile at 0200 with the root's .git at 000 gives spec/.git: cannot be read in mount_cannot_be_read, with code getting the root's line;
    • the same gitfile at 0200 with the root readable gives the same spec line.
  • Files are probes/{HEAD,A2FC}.jsonl and probes/row-HEAD-test_zz_rv1147d_probe2.jsonl.

4. Mutants. Each ran alone over tests/test_root_git_unreadable.py and tests/test_doctor_unreadable_paths.py (119 ids) on git 2.43.0. The file was restored and sha256-checked each time, with dirty=0.

Mutant Result
K4 (a gitfile naming no directory returns True) 119 passed, SURVIVED
M6 (not unread[2] in place of populated) 119 passed, SURVIVED
M8 (populated dropped) 1 failed (the new row), KILLED
M9 (not populated) 9 failed, KILLED
M10 (unread[2] in place of populated) 9 failed, KILLED
M11 (populated moved to the end of the condition) 119 passed, equivalent (order only)
M12 (unread is not None dropped) 2 failed, KILLED
  • K4 is equivalent. mount_read calls the same git_dir_of. If that returns None, nothing is refused and unread is None. A refused .git stat or gitfile read raises in read_through_the_root before its None check. So K4 differs only in a race. The body's reasoning holds.
  • M6's case is not real on a POSIX permission set-up.
    • M6 differs from populated only when the mount's own stat is refused (populated False, listing flag False).
    • Statting a path needs search permission on every ancestor, and opening mount/.git needs that and more.
    • I scanned every owner-bit combination (0 to 7) of each directory level and the gitfile, for a one-component mount (512 combinations) and a two-component one (4096). Zero combinations refuse stat(mount) while mount/.git opens (logs/modes-scan.log).
    • What remains is something I could not measure on this Linux overlay host: a transient I/O error on the mount's inode, a Linux security module denying getattr on one directory, or a Windows ACL denying Read Attributes on the mount while traverse-bypass lets the child path open. is_a raises on every errno outside ABSENT_ERRNOS, so such a case would reach the branch. In every case the code gives the right answer.
  • Is a stand-in row needed? No, as a blocker. The commit and body promise "a mount it could not stat or list", but only the listing half has a row.
    • I wrote the stat half as a probe: every HEAD refused, and Path.stat of spec refused through the file's own refusing helper. It is probes/test_zz_rv1147d_statrow.py, 17 lines, not in the PR.
    • It passes at the head and fails under M6, under M8 and with a2fc1da8's whole file swapped in, each with the spec entry carrying the root's line. That is the body's "a stand-in row would kill it" claim, measured.
    • Lane 1 can add it or leave it out (N1).

5. Gates at 4d299dd3 (Python 3.12.3, run10.sh, git-filter-repo 2.47.0 from venv247).

Run Git Result Log
test_root_git_unreadable.py + test_doctor_unreadable_paths.py + test_shape_doctor.py, one pytest 2.43.0 565 passed, 3 skipped in 1013s logs/g243doc.log
the same three 2.47.1 565 passed, 3 skipped in 976s logs/g247doc.log
hygiene five (test_path_guard_hygiene, test_repo_hygiene, test_agents_md_codes, test_cli_reference, test_pinned_copy_text) 2.43.0 379 passed in 74s logs/g243hyg.log
  • The 3 skips are tests/test_shape_doctor.py:10516, the PowerShell line printed only on a Windows host.
  • Ids collected: 51 in test_root_git_unreadable.py plus 68 in test_doctor_unreadable_paths.py is 119, which matches the body.
  • 565 is rv1147c's 564 plus the new row. It also matches lane 1's 119 + 446 passed + 3 skipped.

6. Merge-trees, git merge-tree --write-tree, identical on 2.43.0 and 2.47.1.

7. The body now reads true. I read the live body: ASCII, the body-guard exits 0, and its only closing line is Closes #1146.

  • The new "## Review round (rv1147c: one SHOULD)" section checks out:
    • 51 rows and 119 ids are right;
    • the blob ids are right;
    • the gate counts add up (1,019 passed and 5 skipped);
    • the mount table matches my probes, including the 0200 gitfile and the stand-in stat case;
    • the claim that K4 and M6 are equivalent holds;
    • the "main is not merged here" line is right, since there is one commit on a2fc1da8.
  • The sentences rv1147c quoted:
    • "Every other refusal keeps mount_read's line: a mount, ..." (body, "Merge with main" section), now with the "mount it had read" qualifier, holds for every case in 3;
    • the S2 bullet "a leg refused for its own reason (its mount, its .git) keeps that line" holds for every case in 3;
    • the comment in audit_leg now carries the qualification, and populated is True exactly on the .git-stage refusals (read in mount_read).
  • The a2fc1da commit message still says "what mends the root's mends it" with no qualifier. That is history and cannot change without a rewrite. The squash message should carry the qualified wording, and the body is where it lives.

NITs

Files are in /workspace/projects/.lane-worktrees/openRepoShape-5/work/rv1147d/: STATE.md, logs/, mut/ (scripts and mutants.py), probes/, and body-live.md.

Lane: openreposhape-5 (openRepoShape-5)

@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review November 8, 2026 18:26
@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 19 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 51a9c5f into main Nov 8, 2026
5 checks passed
@brettheap
brettheap deleted the fix/1146-root-git-unreadable-not-drift branch November 8, 2026 18:27
brettheap pushed a commit that referenced this pull request Nov 8, 2026
#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>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
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 pushed a commit that referenced this pull request Nov 8, 2026
…carries #1147's (51a9c5f) change to shape-doctor.py, validate-pins.py and tests/test_root_git_unreadable.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Nov 8, 2026
…e its `stat` passes, as the gate does, so a root `HEAD` at mode 000 is `.git/HEAD: cannot be read (<reason>)` and `INVALID (pins)`, never `no gitlink recorded` and DRIFTED (rv1248's S1, N3, N4) (#1222)

S1 (rv1248's SHOULD, a MUST on a main that holds #1147). #1147's
`root_git_read` asked the root's `HEAD` by `stat` alone. A `HEAD` at mode
000 stats, so git could not read the root, the `legs` row said `no gitlink
recorded` of each leg and the verdict was `DRIFTED (1 leg(s) not at the
pin)`, while `validate-pins.py` (`_refuse_an_unreadable_root`, through
`_refuse_an_unreadable_git_dir` with #1239's open) refused
`git-repo-unreadable: .git/HEAD: cannot be read (Permission denied)`: the
#1146 defect again. `root_git_read` now names `HEAD` once its `stat` finds
it and opens it through `regular_file_opens`, only where it is a regular
file. A refused open is the root's line, so no gitlink is read and no leg is
compared with its pin, and the verdict is `INVALID (pins)`. A refused
`stat` still names the git directory; an absent `HEAD`, one gone at the
open, a directory and a FIFO are left to git as before. `named` starts from
the root's `.git` path, so `.git/HEAD` is spelled relative to the root, in
POSIX, on every platform, as the gate's `_named` spells it.

N3. `regular_file_opens` keeps its False/True answer, and its docstring now
says why: its two callers (`mount_read`, `root_git_read`) ask it only for
its raise, but it is the gate's `_gitfile_opens`, whose answer the gate
reads of a leg's gitfile (`initialized`, #919), so one question keeps one
answer in both files for any later caller here. A new parity row holds the
two to the same answer for each shape, so rv1248's m11 is no longer an
equivalent mutant.

N4. The leg parity row's `HEAD_SHAPES` gains a `HEAD` that links to a file
at mode 000, and a new row asks #1239's NUL `gitdir: ` line of both parity
pairs (a leg's gitfile, the root's gitfile): neither tool refuses it.

Rows: in `tests/test_root_git_unreadable.py`, the root `HEAD` at 000 (a mode
row, doctor and gate, COMPLIANT once put back), its open refused in process
for a permission and an I/O error (doctor and gate, each with its advice),
three open rows in the doctor-and-gate sentence row (the git directory, and
a gitfile naming one inside and outside the root), a `HEAD` gone at the
open, and a directory `HEAD` among the absent controls. In
`tests/test_doctor_unreadable_paths.py`, the parity row of `root_git_read`
against `_refuse_an_unreadable_root` over three root layouts and nine `HEAD`
shapes, the NUL row, and the `regular_file_opens` parity row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant