Repository navigation
shape-doctor.py: a gitlink beside a .gitmodules the leg's index tracks and this disk lacks is named from the index's copy, which git reads, with its own way out that restores the file first (git restore .gitmodules), as #1080 names one beside other sections (#1093) - #1109
Conversation
…cks and this disk lacks is named from the index's copy, which git reads, with its own way out that restores the file first (`git restore .gitmodules`), as #1080 names one beside other sections (#1093) #1092 read the `.gitmodules` on this disk, so where the leg's index tracks one and the disk lacks it (deleted, not staged) the doctor named nothing, though git reads the index's copy and `git submodule status` exits 128 beside one with no section for the gitlink. - `unregistered_gitlinks` reads the index's copy there (`registered_in_the_index`: `:.gitmodules`, up to its first bad line, as `gitmodules_entry` reads it) and names each gitlink it does not register, whether it holds no section or sections for other paths, under its own `unregistered_beside: index_copy`. - `index_copy_clause` says it, a note as #914's and #1080's are: restore the file first, then a section in it, or the staged `git rm --cached`. Each way out typed as printed exits 0, and the index's copy keeps every other section. - An unmerged `.gitmodules` still names nothing (git reads no copy then); the limits sentence now says when. - HEAD's and the index's copy are read by one helper, `registered_in_blob`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideUpdates shape-doctor.py to identify unregistered gitlinks using the index’s Sequence diagram for detecting unregistered gitlinks from the index copysequenceDiagram
participant Doctor as shape-doctor.py
participant Index as Git index
participant Git as Git
participant Status as git submodule status
Doctor->>Doctor: unregistered_gitlinks()
Doctor->>Doctor: Check .gitmodules is tracked and missing on disk
Doctor->>Git: registered_in_the_index()
Git->>Index: rev-parse --verify :.gitmodules
Index-->>Git: Index blob or no entry
alt Index copy exists
Doctor->>Git: registered_in_blob(:.gitmodules)
Git-->>Doctor: Sections up to first bad line
Doctor->>Doctor: Name gitlinks not registered by index copy
Git->>Status: Read index .gitmodules
Status-->>Git: Exit 128 for unregistered gitlink
else .gitmodules is unmerged
Git-->>Doctor: None
Doctor->>Doctor: Name nothing
end
Flow diagram for index-copy remediationflowchart TD
A[.gitmodules tracked in index but missing on disk] --> B[Read index copy with registered_in_the_index]
B --> C{Does index copy register gitlink?}
C -->|Yes| D[Do not name gitlink]
C -->|No| E[Emit index_copy note]
E --> F[git restore .gitmodules]
F --> G{Choose remediation}
G -->|Keep gitlink| H[Add path and url section]
G -->|Remove gitlink| I[git add .gitmodules]
I --> J[git rm --cached <its path in the leg>]
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
(lane openRepoShape-3, 2026-10-21T13:39:08Z) Independent review RV1109 of #1109 at 8ec615f: READY (no MUST). Three SHOULDs before the landing word. SHOULD-1 (tests): no test checks that the index's copy is read before HEAD's. Every SHOULD-2 (land order with #1111): the textual merge is clean, but on main + #1111 + #1109, tests/test_path_guard_hygiene.py fails on the new bare guard at shape-doctor.py:8865, SHOULD-3 (skip-worktree): with .gitmodules marked skip-worktree and missing from disk, git reads the index's copy (status 128) and the doctor names the gitlink, but the first printed step, NITs: (1) Sourcery appended "## Summary by Sourcery" after the robot line (it contains U+2019); restore the body from W1093's pr-body.md before landing. (2) shape-doctor.py:8850 "exits 0 ... no submodule mapping found" is not true where the copy has a bad line before the section: the clone exits 128 with "bad config line". (3) family.py prints Checked: every probe disagreement with Lane 3's plan: READY, with a fix round before the landing word. In the next usage window W1093, the writer, takes SHOULD-1 (the deleted-over-head rows), SHOULD-3 (the skip-worktree state named as a limit), NIT-2 and NIT-5 (the docstring words), and NIT-1 (the body restored). RV1109 then checks the new head. SHOULD-2: #1111 (lane 1, for #430, in v1.2.0) lands before this follow-up, so #1109 adds the BENIGN entry when it is brought up to a main that holds #1111. NIT-3 (one restore spelling across the tools) is a possible follow-up ruling, and NIT-4 needs nothing. #1109 stays a draft with no milestone and lands after the v1.2.0 tag, on a later word. Lane: openreposhape-3 (openRepoShape-3) |
…s` gives the other answer hold that the index's copy is read first, and the docstrings name the skip-worktree limit and why the staging step stays (#1093) Review round 1 (RV1109: READY, no MUST). - SHOULD-1: `gitmodules_beside_gitlink` takes `deleted-over-head`, which commits the opposite copy with the gitlink, makes it the pin, then stages the case's copy and deletes it from disk. The rows `deleted-over-head-registering` and `deleted-over-head-with-other-sections` kill RV1109's M3b (HEAD's copy read where HEAD has one). - SHOULD-3: `.gitmodules` marked skip-worktree is named, but `git restore .gitmodules` exits 1 there; `index_copy_clause` and the module docstring name it as a limit (family.py's `gitmodules-sparse`). - NIT-2: a recursive clone exits 128 with `bad config line` where the copy holds a line git refuses. - NIT-5: `git add .gitmodules` stays, since `git rm --cached` exits 128 once a section was written for another submodule. Docstrings and tests only: no non-docstring string of shape-doctor.py changed (ast). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-3, 2026-10-21T14:18:29Z) RV1109 round 2 at ab14050: READY.
NITs: shape-doctor.py:8868 is 88 characters after the rewrap. The SHOULD-2 note covers only #1111 landing first; if #1109 lands first, #1111 carries the BENIGN entry. Lane 3's plan: #1109 is READY at ab14050. It is a follow-up that lands after the v1.2.0 tag, on a later word, and #1111 (lane 1, for #430, in v1.2.0) is due before it. So #1109 carries the BENIGN entry (SHOULD-2) when it is brought up to a main that holds #1111, and NIT-6's rewrap goes in that same commit, which RV1109 then checks. If the order turns out the other way, lane 3 tells lane 1 on #1111 that #1111 carries the entry (NIT-7). Lane: openreposhape-3 (openRepoShape-3) |
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…w `os.path.lexists(mount / adopt.GITMODULES)` in `unregistered_gitlinks` is on `BENIGN` as asked after git listed the leg's index, so the path-guard test passes with #1111 merged, and the docstring paragraph on a recursive clone is wrapped to 79 (#1093) Review round 2 (RV1109: SHOULD-2 and NIT-6), on top of the merge of origin/main at 038dafb (#1111). - SHOULD-2: with #1111 merged, `test_no_bare_guard_outside_a_stat_helper_or_the_allowlist[shape-doctor.py]` failed on the bare guard at `unregistered_gitlinks`. The entry sits in `BENIGN` with the table's alphabetical order, tagged ASKED: the guard is asked only after `.gitmodules` is among the paths git listed in the leg's index (`leg_tracked_files`), by `lstat`, in a mount `mount_read` read. - NIT-6: the `unregistered_gitlinks` docstring paragraph that began at 88 characters is rewrapped to 75. With docstrings stripped, `ast` shows shape-doctor.py identical to the merge. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
(lane openRepoShape-3, 2026-10-21T23:04:07Z) RV1109c (round 3, fresh reviewer) at e446ff5: READY. No MUST, no SHOULD.
NITs: (8) the body's round-2 Merges line says no other open PR touches these files; #1144 does now. Add the line above at the next body edit. (9) 1e73379 and e446ff5 carry a Co-Authored-By naming Claude Sonnet 5.5, where writer-common asks for Opus 5.5. The squash replaces both commits, so choose the trailer in the squash message. Lane 3's plan: #1109 is READY at e446ff5, and its SHOULD-2 owed to #1111 is done. NIT-8's line goes into the body at the next body edit, which is the landing's update-branch after the v1.2.0 tag. Whichever of #1109 and #1144 lands second re-checks their merge. The trailer is set in the explicit squash message. #1109 stays a draft with no milestone until then, and lands on a later word. Lane: openreposhape-3 (openRepoShape-3) |
…git dies on read the `.gitmodules` git reads, the index's where the work tree's is gone, and every section's lines end `git add -- .gitmodules`, so a plain `git commit` after them takes the section out of HEAD (#1077) (#1124) ## What this does RV1069's review of #1069 (the PR for #1036) left four follow-ups, recorded in #1077's body (NIT 1, NIT 2) and in issuecomment-6303768948 (NIT 3, NIT 4). Each was measured on main before this change. The only script changed is `templates/family-root/scripts/validate-family.py`, which has no pinned copy. - **NIT 1, `gitmodules-path-twice` read the work tree's `.gitmodules` alone.** A second section for `members/IRRS`, staged in the index's file with the work tree's deleted, was `family ok` in every mode, and a clean clone of the commit after it failed its update and its gate. - It reads the sections as git reads them now (`_sections_as_git_reads`), as `gitmodules-path-unnamed` does since #1036. - Where the work tree's file is gone, it names the line that puts back the file git reads first. That put-back is recorded, so no later finding puts the file back over the hand edit. - It ends "then stage and commit .gitmodules". - **NIT 2, the value check read the work tree's file and HEAD's, never the index's.** An `update = !true` staged in the index's file alone, in a named member's section, was `family ok` while git's own `submodule status` exited 128. - It reads the file git reads (`_listed_as_git_reads`), and says "the index's file, which git reads here" where that is the one read. - Its lines put that file back, not HEAD's. They end "then stage", or "then stage and commit" where HEAD's gives the value too. - Where HEAD's file alone gives the value and the work tree's is gone, it says "Commit the index's .gitmodules, which git reads here and does not give it", with no line, so a fix staged there is kept. - `member-gitmodules-absent` then puts the file back itself: `_check_gitlinks_registered` asks `Report.put_back` alone. - Git is asked in the order it was before (the work tree's file, then HEAD's, then the one read in its place), so with no git to run, `git-not-runnable` names what it named. - **NIT 3, a plain `git commit` after a section's lines.** Where HEAD's file held the section, `gitmodules-path-unnamed`'s lines ended "then commit .gitmodules" and staged nothing. A plain `git commit` after them left the section in HEAD, and a clone's gate failed. Every branch (in `members/`, outside it, and in another case) now prints `git add -- .gitmodules` after the keys, and says "then stage and commit" where HEAD holds the section. - **NIT 4, a test gap.** No row covered the case-variant branch's stage line where HEAD holds no section of the name. RV1069's probe `test_r2_stray_not_in_head` is that row now. ## New rows: `tests/test_validate_family_index_gitmodules.py` Every row types only the printed lines and does by hand only what the text says in words, then makes a plain `git commit`. The gate and a plain clone then pass. - N1: a second section in the index's file alone. - N2: a value in a named member's section, in the index's file alone. - N2b: HEAD's file gives the value, and the index's is mended. - N2c: as N2b, with OTHER's section out of both files, so `member-gitmodules-absent` puts the index's file back first. - N3: a section HEAD holds (a stale one, or `tools/x` with its gitlink), with the file on disk, deleted, or its deletion staged. - N4: the stray `ghost` (RV1069's probe), on disk and deleted. - Fix round 1: path-twice's put-back is recorded, so `gitmodules-path-unnamed` after it prints none (RV1124's P7, on `deleted` and `deletion-staged`); and the value's url is written back from the file git reads, not HEAD's (RV1124's P10). See the fix-round section below. **Before and after.** On main's script the file gives 10 failed and 2 passed. The 2 are N4's rows, which pass there by design (NIT 4 is a test gap) and kill the case-variant mutant M8 below. On this head it gives 12 passed. After fix round 1 the file has 15 rows: on main's script it gives 13 failed and 2 passed (the 2 are N4's), and on this head 15 passed. ## Older rows changed, and why Each one pinned the behaviour #1077 names as the defect. - **"then commit" with no stage line (NIT 3).** - `SECTION_REMEDY` and a new `STAGE` constant (`test_validate_family_unnamed_mounts.py`), and its rows (b) and (c), plus the two-sections row. - `THEN_COMMIT` and `section_first`, which always ends with the stage line now (`test_family_unnamed_gitlink.py`), and its two-sections and control rows. - Two `removal(...)` assertions in `test_validate_family_remedies_typed.py`. - `ghost_out`, R1's replace anchor and R6 in `test_family_hand_mount_git_rm_lines.py`. - Each section in these rows is committed, so HEAD holds it: a plain commit after the old lines left it there. - **Path-twice's ending (NIT 1 and NIT 3).** `test_family_add_refusals.py`'s path-twice row ends "then stage and commit": the commit records the index's file, not the work tree's hand edit. - **HEAD's file put back where git reads the index's (NIT 2).** - `taken_out(put_back=...)` and the two gone-file value rows in `test_family_gitmodules_values.py`. These now read `HERE_AND_HEAD`; one is renamed `..._put_it_back_once`, since its old name said HEAD's file is put back. - The first line of `test_round1_nit_6` in `test_family_holder_remedies_compose.py`. - The value row in `test_family_holder_bootstrap_texts.py`, renamed `test_a_value_finding_that_put_the_file_back_is_not_put_back_over`. - These are now `git checkout -- .gitmodules`, the index's file. - **#1036's R5 control (`test_1036_control_a_value_git_dies_on_keeps_what_it_read`).** - Round 1 put back HEAD's file, which dropped IRQS's section that only the index's file gave. - The value's lines put back the index's file now, so the commit after round 1 records that section, as it does with the file on disk. The next run names it, in every mode. - The row now types round 2 as well, then a plain `git commit`; nothing is left, and the gate and a plain clone pass. - What the control pins is unchanged: where git reads a file that gives a value git dies on, `gitmodules-path-unnamed` reads nothing from it. ## Gates Every run used `/usr/bin/python3 -m pytest -p no:cacheprovider` with: - `EMAIL` set; - `GIT_ALLOW_PROTOCOL`, `GIT_SSH_COMMAND` and the `LANES_*` variables unset; - an empty `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_NOSYSTEM=1`; - test author and committer identities; - a stub `gh` first on PATH, `GH_TOKEN=bogus` and an empty `GH_CONFIG_DIR`. - **Targeted set: 78 files.** - The files that name `gitmodules-path-twice`, `gitmodules-value-refused`, `gitmodules-path-unnamed` or `validate-family` in the head's tree, plus every test file that imports from a test module this changes. - This includes `test_pinned_copy_text`, `test_repo_hygiene`, `test_cli_reference` and `test_agents_md_codes`. - Result: 0 failed in all 78 files, 4390 passed and 34 skipped. - Shards 0 to 4 and 6 to 9 (70 files) were run at `0cd1c3f5`, the three WIP commits this head squashes (the same tree, and the same sha256 of the script): 3379 passed, 25 skipped. - Shard 5 (8 files) was run last, at this head (`672845cc`): 1011 passed, 9 skipped, 0 failed. Two earlier runs of it were cut by checkpoints, at 49% and at 28%, with 0 failures both times. Its files are `test_doctor_legs_members_next.py`, `test_family_add_spelling_leftovers.py`, `test_family_docs_one_spelling.py`, `test_family_makefile_siblings.py`, `test_family_remove_gitmodules.py`, `test_shape_doctor.py`, `test_update_shape_kept_helper.py` and `test_validate_family_unreadable.py`. - The PR's CI `tests` job runs the full suite. - **Older rows.** The 33 rows the change moved, which failed on the new script before their update, now pass with the new rows file (45 passed). ## Mutants Each mutant was run against the new rows file and the older rows that pin the changed paths. The file was restored after each one, and its sha256 checked against the head's. | Mutant | Rows that failed | |---|---| | M1: the value check reads the work tree's file alone | 8 (N2, N2b, N2c, two gitmodules_values rows, round1 NIT 6, the bootstrap_texts row, R5) | | M2: path-twice reads the work tree's file alone | 1 (N1) | | M3: no "commit the index's" branch where HEAD's alone gives the value | 2 (N2b, N2c) | | M4: the value's put-back is HEAD's, as before | 6 | | M5: the value's text does not say to stage the file | 1 (N2) | | M6: no stage line in `members/` | 7 | | M7: no stage line outside `members/` | 3 | | M8: no stage line in the case-variant branch (NIT 4) | 2 (N4 on disk, deleted) | | M9: "then commit" where HEAD holds the section | 10 | | M10: path-twice names no put-back | 1 (N1) | | M11: path-twice ends "then commit" | 3 | | M12: `member-gitmodules-absent` puts nothing back after any value finding | 1 (N2c) | | M13: the file git reads is asked before HEAD's | 3 (the without_git rows) | ## Merge-trees `git merge-tree --write-tree` against `origin/main` 823624d is clean, and was clean against 6da757c, the base the gates ran on. Main has since taken #1101 and #1102; neither touches a file of this PR. No open PR touches these files. #1109, the closest in subject, merges clean too. ## Follow-up, not in this PR RV1124 measured the suspected counterpart of NIT 3 (its N1) and two more older defects (N2 and N6). All three are older than this PR and self-correcting or known limits; the lane files them as the follow-up #1150, and none is fixed here. - N1: the work tree's `.gitmodules` is on disk, and both it and the index's give a value HEAD's does not. The value's lines say nothing of staging, so a plain `git commit` after them records the index's value; the next run names it in HEAD. Main prints the same text and gives the same result. - N2: an index blob with a line git cannot parse after a value is `family ok` (`_blob_listed` returns nothing on git's exit 128). - N6: an unmerged `.gitmodules` (stages 1 to 3), which v1.1.1's release notes list as a known limit. ## Fix round 1: RV1124's review of `672845cc` (S1, N3, N4, N5) RV1124's review of this PR at `672845cc` found one SHOULD and six NITs. This round fixes S1, N3, N4 and N5, in one commit on top of it (`8dfa32bd`), with no amend and no force. N1, N2 and N6 are left to the follow-up #1150. - **S1 (SHOULD): #1111's `test_path_guard_hygiene.py` failed the new guard.** `_listed_as_git_reads` opened with a bare `os.path.lexists(root / GITMODULES)`. Its one caller, `_check_gitmodules_values`, asks it only where the work tree's file is gone (`if present else _listed_as_git_reads(root)`), and `_gitmodules_put_back`, which the function calls next, asks the same `lexists` and is on the test's allowlist. The guard is dropped; the function is `spec = _gitmodules_put_back(root)[0]` and the blob read. On the scratch merge of main `038dafba9` and this head, `test_path_guard_hygiene.py` gives 49 passed, with no failure for this file and no stale `BENIGN` entry. - **N3: two behaviours had no row.** Mutants A and B survived all 439 rows of the PR's nine files. Two rows, written from RV1124's probes P10 and P7: - `test_1077_a_url_git_dies_on_is_written_back_as_the_file_git_reads_gave_it` (P10): the index's file gives a url that is not HEAD's, then a `url` with no value; the line that writes the url back takes it from the index's file. - `test_1077_path_twice_records_its_put_back_for_the_next_finding` (P7, on `deleted` and `deletion-staged`): HEAD's file holds `second` and a stale `members/Old`; path-twice's put-back is recorded, so `gitmodules-path-unnamed` after it prints none, and typed in order then a plain `git commit`, HEAD loses both. - **N4: path-twice's wording.** "back from HEAD's, as the index holds none first" put "first" on the wrong phrase. It says "back first from {whose}, `{put_back}`" now. Only `TWICE_PUT_BACK` in the new rows file pinned the old phrase. - **N5: a stale docstring.** `_gitlink_out_first` said its text ends "then commit .gitmodules"; it now says it ends as `_section_then` does, "then stage and commit" or "then stage". **Rows.** The new rows file has 15 rows. On main's script (`6da757cb`) it gives 13 failed and 2 passed (the 2 are N4's, by design). On `672845cc` it gives 14 passed and 1 failed: the first row (`test_1077_a_second_section_the_index_alone_gives_is_path_twice`) fails on N4's old wording. The P7 and P10 rows pass on `672845cc`, whose code was right; they kill the mutants below. On this head, 15 passed. **Mutants.** Each ran in its own worktree over the new rows file, and the script was restored after each and its sha256 checked. | Mutant | Rows that failed | |---|---| | A: the value's write-back from HEAD's where the work tree's file is gone | 1 (the P10 row: the last line wrote the HEAD url, not the staged one) | | B: path-twice names its put-back without recording it (`_file_put_back(root, None)`) | 2 (the P7 rows: `gitmodules-path-unnamed` printed the put-back again) | **Gate.** On a scratch merge of main `038dafba9` and `2d4cb64b`, whose tree is this head's: the usual environment (`EMAIL`, an empty `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_NOSYSTEM=1`, test identities, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`). 0 failed, 3458 passed and 27 skipped in all. - `test_path_guard_hygiene.py`: 49 passed. - The 64 files of every `tests/test_family*.py` and `tests/test_validate_family*.py` (the PR's nine among them), plus `test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`, `test_pinned_copy_text.py` and `test_doctor_validator_rows.py`: 2964 passed and 24 skipped, in four shards (685; 997 and 20 skipped; 557 and 4 skipped; 725). Those four shards were cut by a checkpoint and re-run on the same merge with the same lists. - `test_shape_doctor.py`, which runs `validate-family.py`, in 8 shards by test id, each list non-empty: 445 passed and 3 skipped. **Merge-trees.** `git merge-tree --write-tree origin/main HEAD` at `8dfa32bd` against main `038dafba9` is clean (tree `23c7194f0b6a08f56b93bdc80a05924e90e703a4`). Closes #1077 Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
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 10 hours and 10 minutes by commenting |
Main moved past #1180's squash 4829418 by e9a1650 (#1109) and bd3e9c1 (#1235), which touch shape-doctor.py, tests/test_doctor_plan_overrides.py, tests/test_family_bump_fetch_refspecs.py and tests/test_path_guard_hygiene.py. None of them is a file #1190 changes. This is the third of the three merges that restack #1190 after #1180 landed. Lane: openreposhape-1 (openRepoShape-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s message spells a path byte that is not UTF-8 as `_shown` does, an escape, and no longer says it names it with a `?` in its place, which #900 replaced (#1018) (#1104) `env_commit`'s docstring in `scripts/shape_materialize.py` says what the split commit's message does with a `root` or `drop` path that holds a byte that is not UTF-8, as the code does since #900. It said the message names such a path "with a `?` in place of that byte, a path that does not exist". That was true before #900, and it is not true now. A reader of the docstring is told about a `caf?.md` the tool no longer writes. Found by lane 1's writer on #900 and filed as #1018. Lane 5 took it outside v1.2.0, under Brett Heap's ruling of 2026-10-21T10:32:04Z. Measured on main `2a119f281` and again on `16f9a0e81` (main after #1087), with a small script that calls: `adopt-project.py`'s own `_split_message` and `_shown`, then `env_commit` and a real `git commit`, read back with `cat -A`. - The issue's claim held: the docstring (about line 2103 when filed, line 2268 on `2a119f281`, line 2349 on this branch) still said the message names the byte with a `?`. - The code does not. `_shown("caf\udce9.md")` is `` `caf\udce9.md` ``, and the split message for a `root` and a `drop` entry named that holds no lone surrogate. Through `env_commit` and `git cat-file commit HEAD`, `cat -A`: ``` DROPPED (in no leg and no longer here):$ `caf\udce9.md`$ STAYS IN THE ASSEMBLY ROOT:$ README.md$ `caf\udce9.md`$ ``` - What stays true of `env_commit` itself: a message that holds the lone surrogate, handed to it directly, is still written as `?`. `cat -A` reads `Name: caf?.md$`. So the `errors="replace"` paragraph is kept, and what changes is who it describes. - #1087 (main `16f9a0e81`) changed `scripts/shape_materialize.py` by 128 lines, none of them in `env_commit`, `COMMIT_COMMAND` or the message, and changed nothing in `adopt-project.py` (0 diff lines). The rebase onto it was clean and the docstring needed no other change. One file, `env_commit`'s docstring, in two paragraphs: the `errors="replace"` paragraph (round 1, 11 lines added and 7 removed) and the sentence above it (round 2, below; 3 added and 2 removed). Together 14 added and 9 removed against main `6da757cb`. The `errors="replace"` paragraph now says: - encoding, a character UTF-8 cannot carry is written as `?`, and nothing raises after both legs are pushed, as before; - `adopt-project.py` refuses a lone surrogate in a path it extracts (`entry_path_problem`), and a `root` or `drop` entry that holds one is listed in the split message but never raw: the message names each path as `plan`'s report does (`_shown`, #900), so the byte 0xE9 reads as an escape with no surrogate in it, `` `caf\\udce9.md` ``, the path the source holds; - written raw it would be a `?`, `caf?.md`, a path that does not exist, which is what `env_commit` still writes for a lone surrogate it is handed. The backslash is doubled in the source, as the neighbouring docstrings write `\\udcc3`. A single `\u` in a non-raw docstring would be an escape that makes a lone surrogate. Measured: the docstring holds no character from U+D800 to U+DFFF and holds the six characters `\udce9`. The longest docstring line is 78 characters. **The code is unchanged.** A script compares the AST of `scripts/shape_materialize.py` with every docstring blanked: the branch's file is identical to `origin/main`'s after the rebase onto `16f9a0e81` (round 1), and the merge of this head into `origin/main` `6da757cb` is identical to `6da757cb`'s file (round 2). **No pin moves.** `scripts/shape_materialize.py` is not a pinned copy. `COPIED_VERBATIM`, `COPIED_FROM_SHAPE`, `FAMILY_COPIED_VERBATIM` and `FAMILY_COPIED_FROM_SHAPE` do not list it, and `templates/assembly-root/scripts/validate-pins.py` says of it "which no project carries". So no `upstream-changed` row, no digest, no assembly copy and no `tests/test_pinned_copy_text.py` entry changes. **No other text pins these words.** Searched in `tests/`, `README.md`, `docs/`, `docs/handbook.html`, `AGENTS.md` and `templates/`: no test reads `env_commit`'s docstring (`__doc__`, `getdoc`), and none of them holds "in place of that byte" or "a path that does not exist" for this. The other places that name the `?` are about this function's behaviour or about history, and are still true: `_split_message`'s docstring says `caf\\udce9.md` "was `caf?.md`" (before #900), `_refused_by_policy` already says the message "lists it as `_shown` does (#900)", and `tests/test_adopt_spelled_names.py` quotes the message as it was on `c7a3ff16`. The `?` in `argv_text`, `trailer_line` and the commit-trailer text is about a `--trailer`, not the split message. No rows and no mutants: the change is a docstring, the code is the same AST, and no test pins the docstring. So there is no row to fail on main and nothing to mutate. All with `$Q/v110/run9.sh`, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`, the stub `gh`, a fake token, git 2.43.0 (`/usr/bin/git`). Two shards, run before the rebase, at the `2a119f281` base with this one-file change in the tree. Together they are every `tests/*.py` that names `shape_materialize` or `env_commit` (86 files, including `tests/test_adopt_spelled_names.py` and `tests/test_adopt_entry_paths.py`, which run `env_commit` and the #900 message) and `tests/test_agents_md_codes.py`: | run | files | result | |---|---|---| | shard A | 43 | 2873 passed, 4 skipped in 1707.51s, rc=0 | | shard B | 44 | 4611 passed, 31 skipped in 2462.01s, rc=0 | After the rebase onto `16f9a0e81`, on the committed head `e32b2703d733f6c60c1b2cba2d62d164d93d7b9a` (`dirty=0`): the trio, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py` and `tests/test_cli_reference.py`, **170 passed in 55.67s**, rc=0. The docstring-only change needed no new shards: the shards' counts are runs at the `2a119f281` base, and #1087 did not touch `env_commit`. Round 2, on the committed head `de89aa81b1533e32f1007f991aeae05cd915b36d` (`dirty=0`): the same trio, **170 passed in 61.77s**, rc=0. The round 2 commit is one docstring sentence, so it needed no new shards either. git 2.47.1: no run. The change adds no test file of its own, so there is none to run on it. `git merge-tree --write-tree HEAD <other>` with HEAD at `de89aa81b1533e32f1007f991aeae05cd915b36d`, after a re-fetch of `origin main` with `-c gc.auto=0 -c maintenance.auto=false` (round 2, about 14:03Z on 2026-10-21): | against | result | |---|---| | `origin/main` `6da757cbe9a4eb66d60c34f38dcc2b332d175e8b` | clean, rc 0, tree `15c11b6c959fc4c7540105d5d62e3f961c8e27fd` | | #1105 `fix/1048-makefile-follow-up-gnu-read-order` `5a4de7f763b5a3c32adce9614a3a539508416eb4`, an open PR that touches `scripts/shape_materialize.py` | clean, rc 0, tree `db05aead67052643942e35a9689d9dd4a0873fda` | | #1118 `fix/1089-named-link-skip-tests` `c60066cbeec18b8b6c926e254ed4bee6b9a486c2`, the other open PR that touches `scripts/shape_materialize.py` | clean, rc 0, tree `a40df3b63c32c2a3d8f4c40587b094940c8d6567` | Read with `gh pr list --state open --json number,headRefName,headRefOid,files`: 16 open PRs, three of them (this one, #1105 and #1118) touch `scripts/shape_materialize.py`, the only file this change touches. The other 13 (#1120, #1119, #1117, #1111, #1109, #1107, #1106, #1102, #1101, #1098, #1097, #1091, #1088) touch no file this change touches. The same command with HEAD at the reviewed head `e32b2703` gives rv1104's trees: `de31d857` against `6da757cb` and `195ffe19` against #1105. The round 1 merge against #1090 is moot: #1090 has landed in `6da757cb`. Held as a draft until the v1.2.0 tag; it is now on the v1.2.1 milestone. - **NIT 1, `scripts/shape_materialize.py` lines 2340-2343, a line round 1 did not change.** The paragraph above the reworded one said the adoption's split message "names each entry path as the source spells it". Since #900 that is true of a printable path alone: `_split_message` lists any other through `_shown`, as the escape the next paragraph describes. It now says "as the source spells it (a printable one; see below)". One new commit on top of `e32b2703`, `de89aa81b1533e32f1007f991aeae05cd915b36d`, 3 lines added and 2 removed, docstring only, pushed as a fast-forward (no rebase, the PR is open). - Measured: the AST with docstrings blanked is identical to `e32b2703`'s and, merged into `6da757cb`, to `6da757cb`'s. The docstring holds no lone surrogate, still holds the six characters `\udce9`, and its longest line is 78 characters. - Searched `tests/`, `README.md`, `docs/`, the handbook mirror, `AGENTS.md`, `templates/` and the scripts for "names each entry path", "as the source spells" and the new words: only `tests/test_adopt_entry_paths.py` line 233 matches, a test's docstring about `execute` handing each path on as the source spells it, which is another sentence and pins nothing here. - **NIT 2, this body.** It named main as `16f9a0e81` and #1090 as the one open PR that touches the file. #1090 has landed. Main is `6da757cb`, and the merge-trees above are redone against it, against #1105 and against #1118, a draft that opened since and also touches the file. Review: rv1104-1107, lane 5's independent opus reviewer, which wrote none of #1104: READY at `de89aa81`, H0 of this landing (issuecomment-6318111413), with no finding; NIT 1 and NIT 2 of the round before are fixed. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 de89aa8 passed tests, tests-git-bash and SonarCloud; the head 92285d4 is a clean update-branch merge of main b66d8f0, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 6bd2128 over main b66d8f0: the repo_hygiene, agents_md_codes, cli_reference sets, 171 passed. 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 #1018 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>



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.
The state #1093 asks for. A leg's index tracks
.gitmodules, but this disk lacks it (deleted, the deletion not staged). git'sconfig_from_gitmodulesthen reads the index's copy, andgit submodule statusexits 128 for a gitlink that copy does not register. At main the doctor named nothing there, because it read only the file on this disk.The index's copy is read (
shape-doctor.py):unregistered_gitlinkshas a third branch. Where the index tracks.gitmodulesandlstatfinds none on this disk,registered_in_the_indexreads:.gitmodulesasgitmodules_entrydoes:gitmodules_listing(mount, ["--blob", ":.gitmodules"], partial=True), thensubmodule_keys(..., working_tree=False), so up to its first bad line..gitmodulessection registers, beside a.gitmoduleswhose sections name other paths or beside none, is named by no row, though a recursive clone of the leg leaves it empty #1080) and the index's copy are read by one helper,registered_in_blob.registered_unindexedcalls it and behaves as before: RV1092d'sprobe_d.pyrows are identical at main and at this head..gitmodulesof whitespace is asked on every run, a dropped file whose blob the root also holds counts as root,_answered_legsis quadratic, and two validator code families have no placeholder row #914's state) or sections for other paths (shape-doctor.py: a leg's gitlink that no.gitmodulessection registers, beside a.gitmoduleswhose sections name other paths or beside none, is named by no row, though a recursive clone of the leg leaves it empty #1080's state). A copy that registers it names nothing.Its own value and clause.
unregistered_beside: index_copy(BESIDE_INDEX_COPY), documented beside the other leg keys and in the module docstring's leg-key prose.index_copy_clauseprints the way out: "first restore that.gitmodulesin its leg (git restore .gitmodules), then give each a section with itspathandurlin it, or, once it is staged (git add .gitmodules), remove its gitlink withgit rm --cached <its path in the leg>in its leg".git add .gitmodulesstages its deletion (measured: exit 0, and the index then tracks none), so every other submodule's section leaves the index with it. And no section can be written in a file that is not there.git restore .gitmodules,git add .gitmodules,git rm --cached vendor/lib: each exits 0, the gitlink leaves the index, and the index's copy is unchanged;git restore .gitmodules, a section appended,git add .gitmodules: each exits 0, thengit submodule statusexits 0.vendor/libempty, andgit submodule statusin the clone exits 128, as the clause says.Note or finding: a note. Like #914's and #1080's clauses, it is said in the row and never a finding. The row stays the
noteit was, since the policy still asks about the tracked.gitmodules, orokwheresubmodules:records the gitlink (casedeleted-and-recorded).An unmerged
.gitmodulesstill names nothing. git reads no copy then. With the file gone from this disk, the index holds no:.gitmodules(rev-parse --verifyfails), soregistered_in_the_indexreturns None. The limits sentence (module docstring, andunregistered_gitlinks) now says what the doctor really does, as RV1092e's NIT suggested: it names nothing there only while the copy on this disk is the conflict, registers the submodule, or is not there.The module docstring's limits sentence no longer lists #1093's state.
Kept byte for byte. I compared every non-docstring string literal of
shape-doctor.pywithast(RV1092d'sstrings.py), at main and at this head.index_copy,rev-parse,-q,--verify,:(the f-string piece of:.gitmodules) and""(the clause's empty return)..gitmodulesof whitespace is asked on every run, a dropped file whose blob the root also holds counts as root,_answered_legsis quadratic, and two validator code families have no placeholder row #914's clause and entry, shape-doctor.py: a leg's gitlink that no.gitmodulessection registers, beside a.gitmoduleswhose sections name other paths or beside none, is named by no row, though a recursive clone of the leg leaves it empty #1080's two clauses and entries, and every refusal code are unchanged, and their existing cases pass unchanged.Decisions the direction left open
index_copy. It names what git reads in this state.missing_gitmoduleswould read too close tono_gitmodules.git restore .gitmodules,git rm --cachedalone also exits 0 (measured). The clause still says "once it is staged (git add .gitmodules)", as the direction asks and as adopt-project.py and shape-doctor.py: a.gitmodulesof whitespace is asked on every run, a dropped file whose blob the root also holds counts as root,_answered_legsis quadratic, and two validator code families have no placeholder row #914's and shape-doctor.py: a leg's gitlink that no.gitmodulessection registers, beside a.gitmoduleswhose sections name other paths or beside none, is named by no row, though a recursive clone of the leg leaves it empty #1080's clauses word it. Right after the restore it stages nothing, and it keeps the removal true if the restored file is then edited.bad config line, 128), and the doctor names nothing, as beside any file git refuses. The removal still works as printed (casedeleted-read-to-its-first-bad-line). A section needs that line mended too.index_copy_clause's docstring says so.update = bogus) names nothing, as a refused file on this disk does. It is probed and tested (deleted-and-refused-whole).Out of scope
.gitmodulesthat a leg's index tracks but this disk lacks, a gitlink with no section in the index's copy is not named, though git reads that copy andgit submodule statusexits 128 #1093 has no "Needs a ruling" or "Documented limit" section..gitmodules(still names nothing, as directed);.gitmodulesgit refuses whole;.gitmodules;tests/test_shape_doctor.pyis unchanged: none of its tests forced a change.Tests
At
8ec615f4, one file at a time, under the stub gh,GH_TOKEN=bogus, an emptyGH_CONFIG_DIR,GIT_CONFIG_GLOBAL=/dev/nullandEMAILexported:tests/test_doctor_plan_overrides.pytests/test_doctor_recorded_answers.pytests/test_doctor_next_is_a_command.pytests/test_shape_doctor.py(once)tests/test_scaffold_leg_path_collisions.pytests/test_repo_hygiene.pytests/test_no_auto_maintenance.pytests/test_agents_md_codes.pytests/test_cli_reference.pytests/test_update_add_codes_documented.pyNew cases in
tests/test_doctor_plan_overrides.py:test_a_gitlink_no_section_registers_is_said:deleted-and-registering,deleted-with-no-section,deleted-with-other-sections,deleted-and-recorded,deleted-read-to-its-first-bad-line,deleted-read-to-a-bare-path,deleted-and-refused-whole;test_a_section_written_for_the_gitlink_registers_it[deleted-with-other-sections];test_an_unmerged_gitmodules_this_disk_lacks_names_nothing.pyflakes:
shape-doctor.py: no warning at main or at this head.tests/test_doctor_plan_overrides.py: no new kind of warning. The new test'sstandardandprojectparameters add two more of the file's existing fixture-shadowing messages ("redefinition of unused 'standard'/'project'"), as every fixture-taking test there does.Mutants
Each mutant was run with
-k "no_section_registers or section_written or unmerged_gitmodules_this_disk"(28 cases). After each run, the file was restored andcmpsaid identical. Code with docstrings stripped (ast) is identical between the tree the mutants ran on and8ec615f4.deleted-with-no-section,deleted-with-other-sections,deleted-and-recorded,deleted-read-to-its-first-bad-line,deleted-read-to-a-bare-pathrev-parse --verify :.gitmodulesfirsttest_an_unmerged_gitmodules_this_disk_lacks_names_nothingpartial=Falseinregistered_in_blobin-head-read-to-its-first-bad-line,deleted-read-to-its-first-bad-lineworking_tree=Trueinregistered_in_blobdeleted-read-to-a-bare-pathdeleted-and-refused-wholeindex_copy_clauseleft out ofheld_clauses.gitmodulesgone) andtest_a_section_written_for_the_gitlink_registers_it[deleted-with-other-sections]Probes
Each probe is a fresh leg, compared against
git submodule status(git 2.43). In each row the index tracks.gitmodules, and this disk lacks it unless the case says otherwise..gitmodulesthat a leg's index tracks but this disk lacks, a gitlink with no section in the index's copy is not named, though git reads that copy andgit submodule statusexits 128 #1093's state (the first 21 below) disagreed with git. At this head, every row agrees except the 3 copies git refuses whole, which are the refused-file limit.probe_d.py(24 rows) gives identical rows at main and at this head.probe.py(32 rows) changes in one row only:tracked-missing-on-disk-other, which at main was[] None NOand at this head is['vendor/lib'] index_copy yes.Merges
At the first push (current ones are under "Review round 2").
git merge-tree --write-tree, after a fresh fetch:16f9a0e8(shape_materialize.py: the fetch for a branch's answer leaves out every refspec of the repository's own onto the branches' names, whatever its source, and every one that puts a branch under a name not its own, sofamily.py bumpandbump-leg.pyno longer fail where the remote hasxandrelease/xand the repository's owngit fetch originworks (#1066) #1087 landed): clean.shape-doctor.pyor these tests.visibility:says it is the visibility the run asked for, and that a repository the run reuses keeps its own, where it said "this is all three repositories' visibility" (#913) #1102, scaffold-project.py: the--reuse-empty-repohelp, its docs and each remedy that sends a person into that re-run say ssh, a credential helper or askpass can still make it wait, and how to keep it from waiting (#915) #1101, family.py: a failed remove's by-hand lines spell a leg mounted at a path that is not UTF-8 as a printf word of octal escapes ending in a slash, sogit -C <member>/<leg>andsubmodule update -- <leg>typed in any POSIX shell name that leg (#1086) #1098, tests.yml: the full suite keeps its temporary files underrunner.temp, not/tmp, so it runs on Ubuntu 26.04 and thetestsjob goes back toubuntu-latest(#1096) #1097, shape_materialize.py, adopt-project.py and bump-leg.py: a tag a clone holds without its commit is one it cannot answer for,checksays a commit it cannot ask was not asked, and a pinned product YAML 1.1 or 1.2 reads as no text is quoted (#912) #1090, the family root's bootstrap.py:credential_adviceandended_onsay a member's failed clone ends git's update with 1 before any checkout but a leg's lets it go on, andkilled_saidsays git's update was itself killed, not that it went on (#1085) #1088 and the family root's siblings.py: with a token in use, the clone and the fetch that carry it run in bootstrap.py'suntraced()environment, somake siblingsandfamily.py siblingswrite the token to no trace the caller asks git for (#1055) #1075 merge clean.checknames what the clone's.githolds as git keeps refs, and asks a pin's file through the links the root keeps, so a link into.gitor onto a pin's file is refused before any leg or adopted with the copy beside it (#904) #1091 conflicts inadopt-project.py. It conflicts the same way with main alone, so the conflict is adopt-project.py:checknames what the clone's.githolds as git keeps refs, and asks a pin's file through the links the root keeps, so a link into.gitor onto a pin's file is refused before any leg or adopted with the copy beside it (#904) #1091's own.Review round 1 (RV1109: READY)
RV1109 found no MUST. This round is commit
ab140500, on top of8ec615f4. It changes docstrings and tests only: theastcheck ofshape-doctor.pyshows no non-docstring string changed, and with docstrings stripped the code is identical to8ec615f4.gitmodules_beside_gitlinktakeskept="deleted-over-head". It commits the opposite copy with the gitlink first, makes that commit the pin, then stages the case's copy and deletes the file from this disk. Two new rows use it:deleted-over-head-registering:SECTIONEDin the index,OTHER_SECTIONin HEAD, nothing named;deleted-over-head-with-other-sections: the reverse, named underindex_copy.mut_rv.py, both new rows fail (2 failed, 28 passed). M3 re-run: KILLED, 7 failed. Each time the file was restored andcmpsays identical..gitmodulesis marked skip-worktree and missing from this disk, the gitlink is named (git reads the index's copy, status 128). Butgit restore .gitmodulesexits 1 there, asgit checkout -- .gitmodulesdoes (measured).index_copy_clause's docstring and the module docstring now name this as a limit: family.py'sgitmodules-sparsestate.unregistered_gitlinksdocstring now says a recursive clone andgit submodule statusboth exit 128 withbad config linewhere the copy holds a line git refuses.index_copy_clause's docstring says whygit add .gitmodulesstays: once a section is written for one submodule,git rm --cachedof another exits 128 until the file is staged.e446ff58b, below): theBENIGNentry is intests/test_path_guard_hygiene.pynow that lane 1's tests/test_path_guard_hygiene.py and shape-doctor.py: a bare is_file/is_dir/exists guard in a shipped tool fails by file and line unless its_is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111 has landed on main.Tests at
ab140500(one at a time, under the stub gh, withEMAILexported):tests/test_doctor_plan_overrides.pytests/test_doctor_recorded_answers.pytests/test_doctor_next_is_a_command.pytests/test_agents_md_codes.pypyflakes:
shape-doctor.pyhas no warnings.tests/test_doctor_plan_overrides.pyhas the same 30 messages as at8ec615f4.Review round 2 (RV1109 round 2: READY; SHOULD-2 and NIT-6 done)
Lane 1's #1111 (
tests/test_path_guard_hygiene.py, #430 slice Z) merged to main at 2026-10-21T18:42:21Z as038dafba9dbedd9405b316cc749a04dffe02bb36. This round is the merge of that main into this branch (1e733793f, no conflict) and one commit on top of it,e446ff58b. It changes a test table and a docstring: with docstrings stripped,astshowsshape-doctor.pyidentical to the merge.e446ff58b. The semantic conflict RV1109 predicted is real. At the merge,tests/test_path_guard_hygiene.pygave1 failed, 48 passed in 7.68s:test_no_bare_guard_outside_a_stat_helper_or_the_allowlist[shape-doctor.py]: "shape-doctor.py:8899, inunregistered_gitlinks:os.path.lexists(mount / adopt.GITMODULES)is a bare guard."BENIGN, tagged ASKED:(SD, "unregistered_gitlinks", "os.path.lexists(mount / adopt.GITMODULES)"), reason "a leg git listed the index of first, bylstat(shape-doctor.py: beside a.gitmodulesthat a leg's index tracks but this disk lacks, a gitlink with no section in the index's copy is not named, though git reads that copy andgit submodule statusexits 128 #1093)".adopt.GITMODULES in paths, andpathsis the leg's index asleg_tracked_fileslists it (gitls-files), in a mountmount_readread first. It sits where the table's alphabetical order puts it, afterunloaded_manifest_row, not next toregistered_unindexed, whose entry has the same reason.unregistered_gitlinksdocstring paragraph that began at 88 characters is rewrapped to 75, the same words._is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111 landed first, so this PR carries the entry and tests/test_path_guard_hygiene.py and shape-doctor.py: a bare is_file/is_dir/exists guard in a shipped tool fails by file and line unless its_is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111 owes nothing.Tests at
e446ff58b(one at a time, under the stub gh, withEMAILexported,timeout 2400):tests/test_path_guard_hygiene.pytests/test_shape_doctor.pytests/test_scaffold_leg_path_collisions.pytests/test_doctor_plan_overrides.pytests/test_repo_hygiene.pytests/test_doctor_recorded_answers.py,tests/test_doctor_next_is_a_command.pyandtests/test_agents_md_codes.pywere not run again this round: they were run atab140500, and since then only the merged #1111 code and the docstring rewrap changed inshape-doctor.py.Mutant. The new
BENIGNentry dropped:tests/test_path_guard_hygiene.pygives1 failed, 48 passed in 9.34s, the same test as at the merge. The file was restored andcmpsays identical.Merges (
git merge-tree --write-tree, after a fresh fetch):038dafba9(tests/test_path_guard_hygiene.py and shape-doctor.py: a bare is_file/is_dir/exists guard in a shipped tool fails by file and line unless its_is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111 itself): clean, treed7815c9a707c2e6750c7aa8f094ccb4df82b9294, the head's own tree, since the head holds main..gitmodulesthe leg's index tracks and this disk lacks is named from the index's copy, which git reads, with its own way out that restores the file first (git restore .gitmodules), as #1080 names one beside other sections (#1093) #1109 touchesshape-doctor.pyortests/test_path_guard_hygiene.py, so there is nothing else to merge against.Closes #1093
Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code
Summary by Sourcery
Teach shape-doctor to diagnose unregistered gitlinks using the index's
.gitmodulescopy when the working-tree file is missing, and explain how to resolve them.New Features:
.gitmodulesis still tracked in the index but does not register them, and report the index copy as the source of truth..gitmodulesbefore either adding its submodule section or removing the gitlink.Bug Fixes:
.gitmodulesfiles, including precedence over HEAD and partial parsing behavior.Enhancements:
.gitmodulesblob registration logic across HEAD and index copies and document the remaining unmerged, refused-file, and sparse-checkout limits.Documentation:
.gitmodulescases.Tests:
.gitmodulesbehavior.