Skip to content

shape-doctor.py: a leg or member whose git directory this user cannot read is named in the gate's words, spec: .git/modules/spec: cannot be read (<reason>), so the legs and members rows never count it as drift, placement never calls it "not a git repository", and no HEAD is read for it (#1174) - #1200

Merged
brettheap merged 4 commits into
mainfrom
l3-1174-unreadable-leg-git-named
Oct 23, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What changed

A follow-up outside v1.2.0 (Brett Heap, 2026-10-21T10:32:04Z). It lands only after the v1.2.0 tag, on a later merge word.

Stacked on #1193 (#1175, branch l3-1175-unreadable-mount-rows-tested, head f3247e54), and based on that branch. Lane 3 asked for the stack after RV1193's review of #1193: this PR changes code that #1193 adds, and it takes RV1193's NIT 1 on #1193. Its own diff is one commit on top of f3247e54.

shape-doctor.py now asks a leg's or a member's git directory the way validate-pins.py asks it. Where this user cannot read that directory, the legs, members and placement rows say the gate's line. The legs and members rows no longer count that mount as drift, placement no longer calls it "not a git repository", and the verdict is the gate's INVALID instead of DRIFTED.

The change: mount_read asks the git directory

mount_read is the one question that the legs, leg shape files, placement and members rows share. Where the mount has a .git, it now also asks two things, as the gate's _refuse_an_unreadable_git_dir does (#968):

  • git_dir_of(dotgit), a copy of the gate's _leg_git_dir. It gives the .git itself where that is not a regular file. For a gitfile it gives the directory the gitdir: line names, joined to the gitfile's own directory as git joins it, or None where the gitfile names no directory;
  • the stat of that directory's HEAD, through is_a.

If either read is refused, that refusal becomes the mount's mount_cannot_be_read line. own_git is then False, so head_of never runs in the mount. The line names the path the way the gate names it (git_dir_named): normalized and relative to the root, spec/.git or .git/modules/spec. Where the directory is outside the mount, the line starts with the mount's path: spec: .git/modules/spec: cannot be read (Permission denied).

From there the legs, members and placement rows do what #975 already made them do for a mount this user cannot reach. The NEXT is this doctor again, the WHY is the gate's advice for the cause, and drifted_reasons skips the leg or member. The verdict falls to the pins row's INVALID, or the family row's for a holder.

A gitfile that names no directory, or a git directory that is not there, is not a refused read. Both are left to git, as before (populated, but git cannot read a HEAD in it and the lockstep gate), the same way the gate leaves them to git.

Decided where the Direction left it open

  • The leg shape files row still compares the files of such a leg. RV1193 asked for this to be ruled on purpose. That row compares a leg's files on disk with the templates and runs no git, and a git directory nobody can search stops none of those files from being read. So leg_shape_comparison asks mount_read(..., git_dir=False): the mount's stat, its listing and its .git's stat, exactly as before shape-doctor.py: a leg whose git directory this user cannot read (.git/modules/spec at mode 000) is counted as off its pin, so the verdict is DRIFTED for a leg at its pin, and placement calls it not a git repository #1174, and not the git directory. Where the files match, the row stays ok with no WHY, as it was at main. The legs row names the directory and gives the advice. Pinned by assert_compared in every mode cell and stand-in case, and by mutants M16 and M17.
  • The members row does not ask such a member what it carries. shape-doctor.py: five branches of the unreadable-mount rows (#975) have no test, so mutants MI, MJ, MK1, MK2 and ML survive, and the leg shape files note for a file this user cannot read gives no cure #1175's guard on the holder's bootstrap note (if MOUNT_UNREAD not in entry, which RV1193 calls MJ) now also skips the .gitmodules of a member whose git directory cannot be read, even though that file is readable. This is right: git can move nothing in a member whose repository it cannot read, so no keep line is owed for that member's legs. Only the carried note is affected. test_a_member_whose_git_directory_is_refused_is_not_asked_what_it_carries pins it: the why is the same with that file and without it, and it carries no git -C LEG branch NAME line. Dropping the guard (MJ) fails that test.
  • The mount's path goes first for a directory outside the mount. The placement and members rows print the line as that mount's half of the reason, and their code assumes it starts with the mount's path (leg_audit_summary, member_problem). Without the prefix, the leg's half would read .git/modules/spec: .... The gate's own line is the suffix, word for word: the mode cells check it against the pins row, and a parity case checks it against the gate's _named.
  • A directory outside the root is named whole with str(), as the gate's _named names it. On POSIX that is the same text as as_posix().

RV1193's NIT 1 on #1193 is folded in: test_beside_a_checkout_line_the_unread_mount_gets_the_advice now asserts that the leg shape files note for a mount has no WHY, and RV1193's mutant N10 is KILLED (see Mutants).

Measured

Before is main 961640c6; after is this PR's head. Both come from one probe, which was copied into tests/ for one run and then moved back out, with every mode restored in a finally. Python 3.12. 9e9303b6b1f8 in the before block is the ROOT's HEAD (git rev-parse HEAD in the root).

Before, main 961640c6:

.git/modules/spec at 000
  verdict:   DRIFTED (1 leg(s) not at the pin)
  leg shape files: ok, why None, spec compared
  legs:      spec: populated, but git cannot read a HEAD in it
  next:      python3 validate-pins.py --root Atlas
  placement: spec: it is not a git repository, so it has no tracked paths of its own to read
.git/modules/spec at 400
  verdict:   DRIFTED (1 leg(s) not at the pin)
  leg shape files: ok, why None, spec compared
  legs:      spec: populated, but git cannot read a HEAD in it
  next:      python3 validate-pins.py --root Atlas
  placement: spec: it is not a git repository, so it has no tracked paths of its own to read
spec/.git gitfile at 000
  verdict:   DRIFTED (1 leg(s) not at the pin)
  leg shape files: ok, why None, spec compared
  legs:      spec: populated, but git cannot read a HEAD in it
  next:      python3 validate-pins.py --root Atlas
  placement: spec: it is not a git repository, so it has no tracked paths of its own to read
spec/.git a directory at 000
  verdict:   DRIFTED (1 leg(s) not at the pin)
  leg shape files: ok, why None, spec compared
  legs:      spec: checked out at 9e9303b6b1f8, pinned at e6f8d9b11417
  next:      git -C Atlas submodule update --init --recursive --checkout -- spec
  placement: spec: it is not a repository of its own -- `git` answers from <tmp>/test_probe_git_directory0/Atlas -- so `ls-files` there would report that repository's paths, not this leg's
spec/.git a directory at 400
  verdict:   DRIFTED (1 leg(s) not at the pin)
  leg shape files: ok, why None, spec compared
  legs:      spec: checked out at 9e9303b6b1f8, pinned at e6f8d9b11417
  next:      git -C Atlas submodule update --init --recursive --checkout -- spec
  placement: spec: it is not a repository of its own -- `git` answers from <tmp>/test_probe_git_directory0/Atlas -- so `ls-files` there would report that repository's paths, not this leg's
.git/modules/members/IRRS at 000
  verdict:   DRIFTED (a member is not at its pin)
  members:   members/IRRS: checked out at ?, pinned at 5e5607277182
  next:      python3 validate-family.py --root InkRouter
.git/modules/members/IRRS at 400
  verdict:   DRIFTED (a member is not at its pin)
  members:   members/IRRS: checked out at ?, pinned at 5e5607277182
  next:      python3 validate-family.py --root InkRouter

After, this PR (shape-doctor.py as at 5fb38294):

.git/modules/spec at 000
  verdict:   INVALID (pins)
  leg shape files: ok, why None, spec compared
  legs:      spec: .git/modules/spec: cannot be read (Permission denied), so no HEAD in it is compared with the pin
  next:      python3 shape-doctor.py --root Atlas
  placement: spec: .git/modules/spec: cannot be read (Permission denied)
.git/modules/spec at 400
  verdict:   INVALID (pins)
  leg shape files: ok, why None, spec compared
  legs:      spec: .git/modules/spec: cannot be read (Permission denied), so no HEAD in it is compared with the pin
  next:      python3 shape-doctor.py --root Atlas
  placement: spec: .git/modules/spec: cannot be read (Permission denied)
spec/.git gitfile at 000
  verdict:   INVALID (pins)
  leg shape files: ok, why None, spec compared
  legs:      spec/.git: cannot be read (Permission denied), so no HEAD in it is compared with the pin
  next:      python3 shape-doctor.py --root Atlas
  placement: spec/.git: cannot be read (Permission denied)
spec/.git a directory at 000
  verdict:   INVALID (pins)
  leg shape files: ok, why None, spec compared
  legs:      spec/.git: cannot be read (Permission denied), so no HEAD in it is compared with the pin
  next:      python3 shape-doctor.py --root Atlas
  placement: spec/.git: cannot be read (Permission denied)
spec/.git a directory at 400
  verdict:   INVALID (pins)
  leg shape files: ok, why None, spec compared
  legs:      spec/.git: cannot be read (Permission denied), so no HEAD in it is compared with the pin
  next:      python3 shape-doctor.py --root Atlas
  placement: spec/.git: cannot be read (Permission denied)
.git/modules/members/IRRS at 000
  verdict:   INVALID (family)
  members:   members/IRRS: .git/modules/members/IRRS: cannot be read (Permission denied), so no HEAD in it is compared with the pin
  next:      python3 shape-doctor.py --root InkRouter
.git/modules/members/IRRS at 400
  verdict:   INVALID (family)
  members:   members/IRRS: .git/modules/members/IRRS: cannot be read (Permission denied), so no HEAD in it is compared with the pin
  next:      python3 shape-doctor.py --root InkRouter

The pins row is unchanged in every cell (REFUSED pin-submodule-unreadable: <the same path>: cannot be read (Permission denied)), and so is every other row but machine, which is n/a under the stub gh in both. The leg shape files row is ok and compares the files before and after.

Out of scope

Nothing in the issue is headed "Needs a ruling" or "Documented limit".

Tests

Each test ran under a stub gh first on PATH (exit 97), with GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null and EMAIL set, as /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch> -o tmp_path_retention_policy=failed <files> -rs, one at a time, on Python 3.12.

At the head, 5fb38294:

Files Exit Result
tests/test_doctor_unreadable_paths.py alone 0 68 passed in 17.44s
tests/test_repo_hygiene.py 0 101 passed in 1.13s
tests/test_path_guard_hygiene.py 0 49 passed in 5.49s
tests/test_no_auto_maintenance.py 0 58 passed, 2 skipped in 4.15s
tests/test_agents_md_codes.py 0 47 passed in 0.84s
tests/test_cli_reference.py 0 23 passed in 19.03s
tests/test_update_add_codes_documented.py 0 6 passed in 0.37s

At 56557756, whose shape-doctor.py is the head's byte for byte. The head changes only the test file's HEAD stand-in (refused_head, see Merges), and that file passes alone at the head, above:

Files Exit Result
tests/test_doctor_unreadable_paths.py and tests/test_shape_doctor.py in ONE pytest (the file imports that one's session fixtures) 0 513 passed, 3 skipped in 407.21s (0:06:47)
tests/test_doctor_unreadable_pin.py 0 52 passed in 13.12s
tests/test_doctor_next_is_a_command.py 0 61 passed in 48.53s
tests/test_legs_readers_not_a_list.py 0 74 passed in 26.15s
tests/test_doctor_legs_members_next.py 0 186 passed, 6 skipped in 162.67s (0:02:42)
tests/test_doctor_paste_through_git.py 0 21 passed in 6.18s

The skips are the host's: 3 for a Windows-only PowerShell line, 6 for this host's git 2.43 (CI runs 2.55), and 2 because the session set GIT_CONFIG_GLOBAL. The mode cases also skip as root and on Windows; every mutant below is also killed by an in-process case, so a run as root or on Windows still kills it.

Mutants

Each mutant changes one text in shape-doctor.py (found exactly once), runs this test file, then #1193's copy of it, and puts the file back by copy, checked with cmp (cmp ok after every one).

Mutant What it breaks This file #1193's file
M1-git-dir-not-asked no git directory asked at all (main's tail) KILLED: 13 failed, 55 passed exit 0: 43 passed
M2-head-not-asked the HEAD is not stated KILLED: 11 failed, 57 passed exit 0: 43 passed
M3-gitfile-not-read every .git taken for a directory KILLED: 16 failed, 52 passed exit 0: 43 passed
M4-names-none-refused a gitfile naming no directory is refused KILLED: 2 failed, 66 passed exit 0: 43 passed
M5b-no-mount-prefix no <rel>: before a directory outside the mount KILLED: 12 failed, 56 passed exit 0: 43 passed
M6-always-prefix <rel>: before every line KILLED: 4 failed, 64 passed exit 0: 43 passed
M7-not-normalized the directory is not normalized KILLED: 12 failed, 56 passed exit 0: 43 passed
M8-outside-root-raises a directory outside the root raises KILLED: 1 failed, 67 passed exit 0: 43 passed
M10-line-not-renamed the HEAD's refusal keeps <rel>/.git KILLED: 9 failed, 59 passed exit 0: 43 passed
M11-own-git-kept own_git stays True on a refusal (head_of runs) KILLED: 7 failed, 61 passed exit 0: 43 passed
M12-permission-only only a PermissionError is caught KILLED: 1 failed, 67 passed exit 0: 43 passed
M13-absent-head-refused a git directory with no HEAD is refused KILLED: 1 failed, 67 passed exit 0: 43 passed
M14-cr-kept a gitfile's CR is kept KILLED: 1 failed, 67 passed exit 0: 43 passed
M15-joined-to-the-gitfile the line joined to the gitfile, not its folder KILLED: 13 failed, 55 passed exit 0: 43 passed
M16-shape-files-asks-the-git-dir leg shape files asks the git directory KILLED: 8 failed, 60 passed exit 0: 43 passed
M17-git-dir-flag-ignored git_dir=False ignored KILLED: 8 failed, 60 passed exit 0: 43 passed
N10-mount-handed-back-as-refused RV1193's N10: the mount early return hands back [unread[1]] KILLED: 1 failed, 67 passed exit 0: 43 passed
MJ-carried-guard-dropped #1175's MJ: the carried guard dropped KILLED: 3 failed, 65 passed exit 1: 2 failed, 41 passed
M0-1174-reverted shape-doctor.py at #1193's head (all of #1174 out) KILLED: 23 failed, 45 passed exit 0: 43 passed

Every mutant is KILLED by this file. All but MJ pass #1193's copy of it, so the new cases are what catch them. MJ is #1175's own mutant: #1193's cases kill it, and so does test_a_member_whose_git_directory_is_refused_is_not_asked_what_it_carries. N10 is killed by the assert added for RV1193's NIT 1 (test_beside_a_checkout_line_the_unread_mount_gets_the_advice).

Merges

git merge-tree --write-tree of the head 5fb38294 with origin/main and with every open PR that touches shape-doctor.py or tests/test_doctor_unreadable_paths.py (re-listed live):

  • origin/main (961640c): clean, tree c48efa65647a4903717cd361c4384fa4957d01c5
  • pr/1193 (f3247e5): clean, tree c48efa65647a4903717cd361c4384fa4957d01c5
  • pr/1172 (5aa0f11): clean, tree d985c7b0c3cb52c6b10ce9948e09f3061cad3a7c
  • pr/1159 (66e68f9): clean, tree c2e9f4a382af099126b0d71902416f25b6dbe9e2
  • pr/1147 (b75babe): clean, tree b3e4b471ed6701208f0d77324e899012d96686c1
  • pr/1144 (89486f6): clean, tree 5e5cb15841b3d7f9cb41b86fbe631b404ad54108
  • pr/1109 (e446ff5): clean, tree a16616b8b923f3af82e84a2f5822d969abeae511

All are clean as text. One is not clean in meaning:

Closes #1174

Lane: openreposhape-3 (openRepoShape-3)

🤖 Generated with Claude Code

Summary by Sourcery

Handle unreadable leg and member Git directories as gate-level invalid findings without treating them as drift or attempting to read their HEADs.

New Features:

  • Report unreadable leg and member Git directories using the gate’s path and refusal reason across pin, placement, and family diagnostics.

Bug Fixes:

  • Prevent unreadable Git directories from being misclassified as drift or non-repositories, and avoid reading their HEAD files.
  • Keep shape-file comparisons and carried-member analysis independent of Git-directory readability.

Enhancements:

  • Align Git-directory discovery and diagnostic naming with the validation gate, including Gitfiles, nested module directories, and paths outside the mount.

Tests:

  • Add coverage for unreadable Gitfiles, HEADs, nested module directories, member carry analysis, path naming, and non-regression behavior.

Brett Heap and others added 2 commits October 22, 2026 21:56
…cannot read gives the gate's advice as its WHY, and each branch #975 added for a mount this user cannot reach is built by a test (#1175)

`shape_file_state` keeps the `OSError` of a refused `stat` or read beside
its words, `leg_shape_comparison` hands each one back, and
`check_leg_shape_files` gives the note `cannot_read_why` of them: the
permission advice or the other one, ending "Then run this doctor again".
Beside a missing file's line the advice follows that line's why. The NEXT
stays None and the row a note, so the verdict does not change. A whole
mount gets no why here: the `legs` row names it and gives its advice.

tests/test_doctor_unreadable_paths.py builds the five branches rv975's
mutants MI, MJ, MK1, MK2 and ML found unbuilt: a leg with no gitlink over
a mount it cannot list, and a holder with two members, one unreachable
beside one a checkout cures, by the holder's own bootstrap and by the git
line, in process and with a real mode 000. ML's term is not dead: the
`rerun_sentence` it adds is not the advice's own "run this doctor again",
and the cases tell the two apart. The note's why is asserted word for
word, for a permission and for an I/O error, for a refused `stat` and a
refused read, beside a missing file, and under a real mode 000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… read is named in the gate's words, `spec: .git/modules/spec: cannot be read (<reason>)`, so the `legs` and `members` rows never count it as drift, `placement` never calls it "not a git repository", and no HEAD is read for it (#1174)

`mount_read`, which the `legs`, `leg shape files`, `placement` and
`members` rows share, now asks the mount's git directory as
`validate-pins.py` asks it (`_leg_git_dir` and
`_refuse_an_unreadable_git_dir`, #968): the `.git` itself where it is no
regular file, else the directory its gitfile's `gitdir: ` line names
(`git_dir_of`), and then the `stat` of that directory's `HEAD`. A
refused read of the gitfile, or a refused `stat` of the `HEAD`, is the
mount's `mount_cannot_be_read` line, named as the gate names it
(`git_dir_named`): `spec/.git`, or, where the directory is not under the
mount, `spec: .git/modules/spec`, so every row's line starts with the
mount. The `legs`, `placement` and `members` rows then say what they
say of a mount this user cannot reach: this doctor again as the NEXT,
the gate's advice as the WHY, no drift, and the verdict is the `pins`
(or `family`) row's INVALID.

The `leg shape files` row runs no git, so it asks with `git_dir=False`
and still compares the files of such a leg, as it did before. The
`members` row's note on what the holder's bootstrap checks out
recursively still skips a member whose mount line is set, now one whose
git directory cannot be read too: git can move nothing in it.

Before, a leg at its pin was `populated, but git cannot read a HEAD in
it`, sent to the lockstep gate and counted as drift; a member was
`checked out at ?`; `placement` said "it is not a git repository"; and a
`.git` directory this user cannot search was passed over by git, which
read the ROOT's HEAD as the leg's and named a checkout over it. Each
verdict was DRIFTED. A gitfile that names no directory, or a git
directory that is not there, is left to git as before.

Also pins that the `leg shape files` note for a mount this user cannot
reach has no WHY (RV1193's NIT 1 on #1193).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 23, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates shape-doctor.py to identify unreadable leg or member git directories using gate-compatible resolution and HEAD checks, report the exact refusal across relevant rows, avoid false drift and HEAD reads, and retain independent file-shape comparisons; extensive tests cover permissions, naming, parsing, controls, and regressions.

Sequence diagram for unreadable mount git-directory detection

sequenceDiagram
    participant Doctor as shape-doctor.py
    participant Mount as Mount
    participant GitDir as Git directory
    participant Head as HEAD
    Doctor->>Mount: mount_read(root, rel, git_dir)
    Mount->>Mount: is_a(.git)
    alt git_dir is enabled
        Mount->>GitDir: git_dir_of(.git)
        Mount->>Head: is_a(git_dir/HEAD)
        alt read refused
            GitDir-->>Mount: OSError
            Mount-->>Doctor: mount_cannot_be_read
            Doctor->>Doctor: skip head_of and drift counting
        else readable
            Head-->>Mount: readable
            Mount-->>Doctor: own_git=True
            Doctor->>Head: head_of
        end
    else leg shape files
        Mount-->>Doctor: mount result without git-directory read
        Doctor->>Mount: compare files with templates
    end
Loading

Flow diagram for unreadable mount verdict and reporting

flowchart TD
    A[mount_read] --> B{Git directory readable?}
    B -->|No| C[mount_cannot_be_read: gate-compatible path and reason]
    C --> D[legs and members skip drift counting]
    C --> E[placement reports refusal]
    D --> F[No HEAD read]
    E --> G[Verdict INVALID from pins or family]
    B -->|Yes| H[Normal HEAD comparison]
    I[leg_shape_comparison] --> J[Calls mount_read with git_dir=false]
    J --> K[Compares files independently]
    K --> L[Shape-files row remains OK when files match]
Loading

File-Level Changes

Change Details Files
Detect unreadable mount git directories before reading HEAD and propagate the gate-compatible refusal through doctor rows.
  • Resolve repository git directories from regular .git files or directories, including normalized naming for paths outside the mount.
  • Stat the git directory's HEAD and convert any refused read into mount_cannot_be_read, preventing HEAD evaluation and drift counting.
  • Update legs, members, placement, and verdict/next/why handling to report INVALID with gate advice instead of DRIFTED or “not a git repository.”
shape-doctor.py
Preserve file-only shape comparison and suppress member carry analysis when repository metadata is unreadable.
  • Make leg shape-file comparison skip git-directory probing while continuing to compare on-disk files.
  • Avoid asking an unreadable member what it carries, including suppressing its carried keep lines.
shape-doctor.py
Add comprehensive coverage for unreadable gitfiles, git directories, HEADs, path naming, controls, and regression behavior.
  • Test permission and I/O failures across legs and members, including in-process and mode-based cases.
  • Verify gitfile parsing parity with the gate, mount-prefix naming, no-HEAD/no-directory controls, unchanged shape comparison, and carry suppression.
  • Add assertions and mutant coverage for all key refusal, naming, and control-flow paths.
tests/test_doctor_unreadable_paths.py

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-23T10:07:25Z) Independent review (RV1200, opus, ruling 8) of #1200 at 5fb38294a4d18a949a2267a76b79c8175bca0626, base f3247e54 (#1193's branch): READY as a draft. No MUST, one SHOULD (a note for lane 1 on #1147, no code here), three NITs with nothing to change. It lands on a later word, after #1193 and after the v1.2.0 tag.

Measured with an own probe, real modes restored in a finally, at main 961640c6 and at this head (Python 3.12, uid 1000, so every mode row ran):

  • .git/modules/spec at 000 and 444, the spec/.git gitfile at 000, a spec/.git directory at 000 and 444, .git/modules at 000, and a member's .git/modules/members/IRRS (and its gitfile) at 000 were DRIFTED at main. Here they are INVALID (pins) or INVALID (family), each named as the gate names it. At main, the spec/.git directory cell quoted the ROOT's HEAD as the leg's.
  • The readable controls (a gitfile leg, a .git directory leg, a member) are identical to main in every row and verdict.
  • No row runs a git command it did not run before; refused mounts now run fewer. The only new I/O is one gitfile read and one stat of <gitdir>/HEAD per populated mount, in legs, placement and members. No HEAD's content is read.

Rulings:

  • git_dir_of is the gate's _leg_git_dir (the parity test runs six gitfile shapes against it), and the stat of HEAD is the gate's _refuse_an_unreadable_git_dir (validate-pins.py:443).
  • git_dir=False for leg shape files (shape-doctor.py:7830) is right: that row reads files on disk, runs no git, and stays ok/compared in every cell. The legs row names the directory and the verdict is INVALID, so the ok row hides nothing.
  • The MJ guard skipping such a member's .gitmodules (:10790) is right: git cannot recurse into a member whose repository it cannot read. Pinned at tests/test_doctor_unreadable_paths.py:851.
  • The spec: prefix is accepted: the gate's line is the exact suffix, and without the prefix a directory outside the root would not name its leg.

Tests at the head, one pytest at a time, under a stub gh:

  • tests/test_doctor_unreadable_paths.py with tests/test_shape_doctor.py in ONE pytest: 513 passed, 3 skipped (the Windows-only PowerShell line).
  • tests/test_path_guard_hygiene.py: 49 passed. tests/test_repo_hygiene.py: 101 passed. tests/test_no_auto_maintenance.py: 58 passed, 2 skipped (GIT_CONFIG_GLOBAL set).

Mutants, own script, each KILLED, restored and checked with cmp, and each also killed by an in-process case, so a run as root or on Windows still kills it:

  • the git directory not asked: 13 failed;
  • git_dir=False dropped (M16): 8 failed; the flag ignored (M17): 8 failed;
  • the MJ guard dropped: 3 failed;
  • N10: 1 failed (the new why is None assert);
  • a stat of the git directory instead of its HEAD: 11 failed;
  • own_git kept True on a refusal: 7 failed;
  • the name not normalized: 12 failed.

Merges: merge-tree --write-tree is clean against origin/main and #1193, #1172, #1159, #1147, #1144 and #1109, with the body's trees.

SonarCloud: gate OK, 0 new issues, 0.0% new duplication. CI: package, tests-git-bash and SonarCloud pass; tests was still pending at 10:05Z and must pass before landing.

SHOULD 1 (process, no code here), shape-doctor.py:9117, audit_leg's early return on mount_read:

NITs, nothing to change:

Body and commit: draft, no milestone, base #1193's branch; "Stacked on #1193" in the second paragraph; the only closing keyword is Closes #1174 (closingIssuesReferences is empty until it is retargeted to main); nothing above U+007F but the robot, no bot block. The commit is by Brett Heap <service@opensoft.dev> and ends with the Co-Authored-By line.

Lane: openreposhape-3 (openRepoShape-3)

Brett Heap and others added 2 commits October 23, 2026 20:16
Ours for the parent files shape-doctor.py and tests/test_doctor_unreadable_paths.py: S carries F, which this branch already holds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@sourcery-ai

sourcery-ai Bot commented Oct 23, 2026

Copy link
Copy Markdown

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

You can request another review in 1 hour and 36 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 50869f3 into main Oct 23, 2026
5 checks passed
@brettheap
brettheap deleted the l3-1174-unreadable-leg-git-named branch October 23, 2026 22:12
brettheap pushed a commit that referenced this pull request Oct 24, 2026
…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 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 added a commit that referenced this pull request Nov 8, 2026
…ose 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)

#1146's case, found by r1144: an assembly root whose own `.git` is a link into a folder at mode 000, so this user cannot search it. On base `038dafba` the doctor read it as `DRIFTED (2 leg(s) not at the pin)`, with `no gitlink recorded` for each leg, and its `manifest` and `pins` rows said each validator `crashed before reaching a verdict (exit 1): PermissionError`. Run directly, `validate-pins.py` and `validate-manifest.py` each ended in a `PermissionError` traceback, exit 1, on Python 3.12, and in `REFUSED not-a-git-repo`, exit 2, under a 3.14-like shim in which `Path.is_file`, `is_dir`, `exists` and `is_symlink` answer False to any `OSError`. Both raised inside `find_repo_root` of `scripts/repo_shape.py`, at `(candidate / ".git").exists()`.

- The validators each call `_refuse_an_unreadable_root(start)` first in `main`. With the file's own `_is_a` (`scripts/repo_shape.py` is a pinned copy too, and a pinned validator must not rely on a newer one, #370 and #610), it asks each `.git` that `find_repo_root` would ask, in the same order, from the real path of the `--root` given, and stops at the first one that is there, a directory or a gitfile. One that answers absent (ENOENT, ENOTDIR, EBADF, ELOOP, and Windows 21, 123 and 1921) is passed over, as `exists()` passes it. A refused `stat` ends in the new code `git-repo-unreadable`, the sibling of `not-a-git-repo`: `REFUSED git-repo-unreadable: .git: cannot be read (Permission denied)`, exit 2, with the cause's advice (the `chmod` cure for a permission). A `.git` above the `--root` given is named whole. `find_repo_root` is not changed and answers as before after it.
- `validate-pins.py` also asks the git directory of that `.git`, as #968 asks of a leg's: a gitfile it cannot open, a git directory whose `HEAD` it cannot reach. On base git said of each that the root records no gitlink, and the gate refused `pin-gitlink-absent`. `validate-manifest.py` runs no git and asks only the `stat`, so over a readable `.git` and an unreadable git directory it passes, a true answer about `project.yaml`.
- The doctor's `root_git_read` asks what `validate-pins.py` asks and names the git directory as the gate does (relative to the root inside it, whole outside it, a gitfile ending in LF or CR LF). The `legs` row says the gate's line once, as a FINDING, with the doctor itself as NEXT and the gate's advice as WHY. Each leg's entry has `gitlink: null`, the line in `gitlink_cannot_be_read`, and `pin`, `populated`, `head`, `own_git` and `unborn` null, so a reader of `--json` gets no `KeyError`. `drifted_reasons` counts no such leg, and still counts a readable leg beside it that is off its pin.
- `placement` audits every leg git can read, and gives the root's line to a leg whose `.git` is there and leads to a repository git names no top level for; its row says the root's line once, `n/a`, only where it is every leg's. The verdict follows the ladder (an unfinished merge, DRIFTED, MISPLACED, INVALID, SHAPE BEHIND): no leg is compared with its pin while the root's `.git` cannot be read, so none is drift, and a path in the wrong leg of a leg git reads is `MISPLACED (1 path)`.
- Over #1200 (`a2fc1da8`): #1200's `mount_read` refused a leg's git directory before the root's line was reached, so `placement` named `<leg>: .git/modules/<leg>: cannot be read` once per leg instead of the root's line once. `audit_leg` now gives the root's line first where the leg's git directory is inside the root's own, under its `.git` or under the directory its gitfile names (where git keeps a submodule's for a root that is a linked worktree or a submodule itself, measured with git 2.43.0). Every other refusal keeps `mount_read`'s line: a mount, a `.git` or a gitfile, and a leg's own `.git` directory. With the root readable, #1200's line stands.
- The S1 round (`4d299dd3`): `audit_leg` asks `populated`, which is True only where `mount_read` got as far as the mount's `.git`, before `read_through_the_root`. A spec mount this user can search and not list (mode 0100 or 0300) had been given the root's line, because `spec/.git` could still be read through the root, against the text above. It now keeps `spec: cannot be listed (Permission denied)`. The verdict stays `INVALID (pins)`, so only the text of the `placement` row changes.
- Both validators are in `COPIED_VERBATIM`, and no file is added to or taken from a pinned list. Against a project one commit past its pin, `update-shape.py check` says `upstream-changed` for the two and `unchanged` for the other 9; a project that holds the old copies keeps `crashed ... PermissionError` in the validator rows until `update-shape.py apply`. No `--help` text changes, so `docs/cli.md` is unchanged, and no refusal code is added to `AGENTS.md`. Not touched: #1144's eight sites, the doctor's gitmodules-on-disk code (#1109), and `scripts/repo_shape.py`.
- Measured in a scaffold's recursive clone, on Python 3.12.3 and under the shim, with base `038dafba` against `b75babee`: the issue's case went from the `DRIFTED` above to `INVALID (manifest, pins)`, the `legs` row `.git: cannot be read (Permission denied), so no gitlink is read and no leg is compared with its pin`, and each validator to `REFUSED git-repo-unreadable`, exit 2, no traceback. A `.git` that is a directory at 000 or 444, or a gitfile at 000, went from `DRIFTED` to `INVALID (pins)` (`validate-manifest.py` passes). A spec leg with a repository of its own and a misplaced path, beside a root `.git` at 000, went from `DRIFTED (1 leg(s) not at the pin)` to `MISPLACED (1 path)`. The controls (a readable `.git`, a link to nothing, a link to itself) give the base's reports byte for byte, with paths and commit SHAs normalized, on both Pythons.
- `tests/test_root_git_unreadable.py` is new and holds 51 rows (47 at `b75babee`, 50 at `a2fc1da8`). Each round was run base against head, with the new file laid over base. At `b75babee`, on 3.12.3 and under the shim, base failed 37 and passed 10 (controls, and the walk rows that stop at a gitfile), and the head passed 47. For the order round, over 54 ids with #1200's 4 rows, base failed 8 and passed 46, and the head passed 54. For the S1 row, `a2fc1da8`'s `shape-doctor.py` failed it and the head passes it. Mutants: 49 of 50 killed at `b75babee` (the survivor, r1147's Da, differs only in a race between the `stat` and the read), 7 of 7 at `a2fc1da8`, and 5 of 7 at `4d299dd3`, where M6 and rv1147c's K4 are equivalent on a real filesystem.
- Known limits:
  - Four other pinned callers of `find_repo_root` keep the defect, a `PermissionError` traceback on 3.12 and `REFUSED not-a-git-repo` under the shim: the assembly root's `bootstrap.py`, and the family root's `validate-family.py`, `bootstrap.py` and `siblings.py`. They need the same local walk in four more pinned copies, filed as #1148.
  - The `chmod` cure says "every directory above it inside the root", but in the issue's case the locked folder is outside the root and the line names `.git`, a link, so `chmod u+rx .git` is refused. These are the gate's words, unchanged, as r1111's N5 left them.
  - A root with no `.git` at all (a link to nothing or to itself, which #430's rule calls absent, or none under a parent whose `.git` cannot be read) still reads `DRIFTED (2 leg(s) not at the pin)`; under such a parent `validate-pins.py` walks up and refuses, and the doctor asks only the root's own `.git`.
  - While the root's `.git` cannot be read, the `legs` row says that alone, so the verdict can read `MISPLACED (1 path)` where the run after it is mended reads `DRIFTED (1 leg(s) not at the pin)`.
  - `placement` also gives the root's line to a leg whose gitfile names a folder that does not exist and to a leg whose `.git` folder has no `HEAD`; the run after the root is mended gives git's answer for both.
  - A root whose `.git` is a link, with legs whose gitfiles name the link's target and not `.git/modules/<leg>`, keeps `mount_read`'s line for each leg in `placement`, since `read_through_the_root` follows no link. The `legs` row still says the root's line once.
  - A family holder whose own `.git` cannot be read, and the `members` row, are not changed (#1109's code is avoided); #1148 asks for them to be measured.
  - Only what #968 asks of a leg is asked: the `stat` of `.git`, the open of a gitfile, the `stat` of the git directory's `HEAD`.

Review: lane 1's independent reviewers reviewed the first two rounds, lane 3 noted the clash with #1200, and lane 5 re-reviewed the order round. r1147 found `e86fb8f4` READY on condition, with no MUST, two SHOULDs and eight NITs (#1147 issuecomment-6332417236): S1, no rows for a relative `--root` or a walk that stops at a gitfile, and S2, `placement` turning down legs it could still read. It killed 13 of its 21 mutants; the survivors were the rows S1 and the NITs asked for, and Da, which is equivalent. The fix round `b75babee` answered both SHOULDs and the NITs, and r1147b found it READY, with no MUST or SHOULD and three NITs (issuecomment-6333323566): each named mutant was killed by its named row, 20 of r1147's 21 are killed (Da is equivalent), N1 and N2 are answered in the text above, and N2's docstring and N3 are optional and left. Lane 3's note (issuecomment-6362488848), from its review of #1200 (issuecomment-6362479557), found that on a merge of #1200 with `b75babee` 5 of the 47 rows failed, all on `placement`'s reason, and named the order that `a2fc1da8` gives. Lane 5's re-review of `a2fc1da8`, by rv1147c (rv1147 and rv1147b hung before they measured anything), found it READY, with no MUST, one SHOULD and four NITs (issuecomment-6723841826): the SHOULD, S1, was a mount this user can search and not list that got the root's line; the NITs were K2, K4 and K7 surviving, rv1248's note, which the text does not contradict, and the draft, to be marked ready. rv1147c killed 8 of its 11 mutants, and found the tree of the merge `569ea056` to be exactly that of `git merge-tree --write-tree b75babe 50869f3`, with no hand edit. Lane 1 took the code for S1, not a narrower text, as `4d299dd3` (issuecomment-6724251407). Lane 5's delta check of `4d299dd3`, by rv1147d, found it READY, with no MUST or SHOULD and three NITs: M6 has no row (an optional stand-in row), the PR was a draft, and CI's `tests` was still running (issuecomment-6724684319).

Tests, under a stub `gh` with a bogus token: at `a2fc1da8`, lane 1's gate, on tree `50a97fd7`, gave 1,018 passed and 5 skipped over 8 calls: `tests/test_root_git_unreadable.py` 50 passed, `tests/test_doctor_unreadable_paths.py` 68 passed, `tests/test_path_guard_hygiene.py` and `tests/test_no_auto_maintenance.py` with `tests/test_scaffold_leg_path_collisions.py` 283 passed and 2 skipped, the doc trio (`test_agents_md_codes`, `test_cli_reference`, `test_repo_hygiene`) 171 passed, and `tests/test_shape_doctor.py` in 4 shards 446 passed and 3 skipped (the PowerShell line that is printed only on a Windows host). Lane 5's rv1147c ran the first two files with `tests/test_shape_doctor.py` in one pytest at `a2fc1da8`, on git 2.43.0 and on 2.47.1: 564 passed and 3 skipped each time. The wider gate at `b75babee`, with the 37 other test files that name `placement` and the validator files, gave 6,740 ids: 6,737 passed and 3 skipped; the validators have not changed since `e86fb8f4`. At `4d299dd3`, w1147s's gate (Python 3.12.3, git 2.43.0) gave `tests/test_root_git_unreadable.py` with `tests/test_doctor_unreadable_paths.py` 119 passed; `tests/test_shape_doctor.py`, 4 shards, 446 passed and 3 skipped; the scans with `tests/test_scaffold_leg_path_collisions.py` 283 passed and 2 skipped; the doc trio 171 passed. That is 1,024 ids: 1,019 passed and 5 skipped.

It lands by fast refresh (Brett Heap, 2026-10-15T22:48:35Z). Its H0 `4d299dd3` is green on Linux and SonarCloud. The branch was never rebased. After H0, the only commit is update-branch's clean merge of main, giving 95ea564, which is green on SonarCloud and Git Bash. Lane 1's gate passed on its tree: 8 pytest calls over `tests/test_root_git_unreadable.py`, `tests/test_doctor_unreadable_paths.py`, `tests/test_path_guard_hygiene.py`, `tests/test_no_auto_maintenance.py`, `tests/test_scaffold_leg_path_collisions.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py` and `tests/test_shape_doctor.py` in 4 shards: 1,019 passed, 5 skipped.

Closes #1146

Lane: openreposhape-1 (openRepoShape-1)
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