Skip to content

shape-doctor.py: the legs and members NEXT names every detached commit it would move, and sends a mount with no .git of its own, an unborn HEAD, a mount git finds no url for, and a leg with its own lockstep fact to a line that cures it or to none (#413, #414) - #559

Merged
brettheap merged 5 commits into
mainfrom
fix/doctor-next-detached-commits
Oct 15, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-4 (openRepoShape-4)
Claim: #413 (comment)
Claim: #414 (comment)

What changed and why

Since #276 (PR #354) the doctor's legs and members rows hand over git -C <root> submodule update --init --recursive --checkout -- <paths> where a checkout cures the row, and the lockstep gate where none does. Since #277 (PR #402) the row's why says what the line does. This PR fixes the two follow-ups this lane's review of #354 filed, in the same functions. Both issues' repro scripts split the NEXT on #. Since #277 that split finds an empty sentence on main, and the sentence is the row's why ("why" in --json).

#413: every detached commit the line would move is named

The keep sentence ("A commit made in a leg on no branch is left only in its reflog by this line. To keep one, run first git -C branch NAME") was printed only for a populated mount detached off its pin. The line moves more HEADs than that, each able to hold the only copy of a commit:

  • (a) An emptied mount whose repository git kept. git submodule deinit -f keeps .git/modules/<name>. The update links the mount to it again and checks the gitlink out over the commit. The row now finds that repository by the submodule's name (.gitmodules, read where git reads it) and git rev-parse --git-path modules/<name>. It reads that repository's HEAD through --git-dir, never in the empty mount, where git walks up to the root. When the HEAD is detached at a commit that is not the gitlink (left_in_reflog, the rule the populated mounts use too), the row names the commit with git --git-dir=<repository> branch NAME <commit>. Run in the empty mount, git -C <mount> branch NAME would branch the ROOT. If the mount directory is gone too, git refuses every command in that repository (cannot chdir, exit 128, measured), so that keep line then carries a --work-tree.
  • (b) The holder's own scripts/bootstrap.py. Its update names no path, so it moves every member, including one the row hands to the family gate. Its why now carries the same keep sentence, built by the same function (checkout_notes, factored out of submodule_update_next), for every member it would move off a detached commit.
  • (c) --recursive. It checks out the submodules a mount carries of its own, at the commits that mount records: each member's legs, and anything a leg mounts. The row does not walk into them. A line that names a mount carrying some (a .gitmodules in it, or a kept repository that keeps their modules/) says so in general words, with a placeholder keep line to run first: git -C LEG branch NAME for a member's leg, git -C LEG/SUB branch NAME for a leg's own submodule. --recursive stays, for parity with the holder's bootstrap (fetch_members).

#414: four states, each sent to a line that cures it or to none

state on main now
(1) files in the mount, no .git of its own the gate, under "no submodule update cures", and the reason quoted the ROOT's HEAD as the leg's No HEAD is read there. If git kept the repository, the NEXT is the git line, whose why says it links the two again; pasted, the row is ok. If not, the NEXT is the gate, whose why says git kept none: the update tries a clone into a directory that is not empty and exits 1 (measured).
(2) an unborn HEAD the gate, which says pins ok while the row stays red NEXT None. The reason names the branch, and the why says nothing here chooses a commit.
(3) git finds no url: no .gitmodules section gives the path, or the section has no url and the root's own config has none either the git line, which exits 128 (No url found) NEXT None. The why says a person restores the entry from the repository's history. The gates were measured: pin-submodule-uninitialized, and member-uninitialized for a member, name no .gitmodules entry and hand back the same git line. No command is printed, so none can discard an uncommitted .gitmodules.
(4) a leg the line checks out, with its own lockstep fact "run the doctor again" only when ANOTHER leg was lockstep Asked per leg too: a pin that is not 40 hex, or not the gitlink.

For (3), the url is asked for every mount the line would name, populated ones too: --init exits 128 on a populated one as well (measured). .gitmodules is read where git reads it, so a copy deleted from the working tree alone is read from the index, and the line stands (pasted, it exits 0). A section that keeps its path but lost its url key is not url-less while the root's own config still holds the url submodule init copied there: --init reads that first, and the line exits 0 and cures the row (measured, populated past its pin and emptied by rm; deinit removes the configured url).

The members row takes the same rules through one function, checkout_reach, and a member reaches all four states. A holder adds one case. Git stops the holder's bootstrap at a member it finds no url for: the bootstrap's update names no path, git exits 128 before any member is fetched, and the bootstrap still exits 0 with the row as red as before. So the NEXT is then the git line over the members a checkout cures, and its why says why it is not the bootstrap.

Where the lockstep gate is the NEXT, an unborn HEAD or a mount with no url beside it is named in the gate's why, in the same sentence it gets when no line is printed. That sentence ends by saying to run the doctor again.

Review round 1 (R559, at 83b3865)

  • M1: the url check asked .gitmodules alone, a regression from main for an initialized mount whose section lost only its url key. It now also asks git config --get submodule.<name>.url in the root (configured_url), so such a mount, populated or emptied by rm, keeps the curing git line, as on main. The holder's bootstrap does the same for a member.
  • S1: a leg's own submodules get the general --recursive sentence, as a member's legs do.
  • N1, N2: tests now pin detached()'s None, and every member the holder's bootstrap moves.
  • N3: the docstrings no longer apply the old # tail rule to the why.
  • N4: the gate's why now carries the states no line cures.
  • N5: pre-existing, and left as it is.

Tests

The new tests go at the end of tests/test_doctor_legs_members_next.py: 13 for #413, 21 for #414, and 15 from review round 1. Each runs for the legs row and the members row where the state applies. One existing test there changes. It asserted no reflog in a branched member's why, which now carries the nested sentence, so it asserts instead that the member gets no keep line of its own.

Run on Linux (py-bench) at this head's bytes (shape-doctor.py sha256 d85e92ac…, the test file 7b765cae…), with git-filter-repo on PATH:

No tests/test_family.py test reads the doctor's members row. Its only doctor test reads the family row, and runs above.

Mutants, each run against its own tests, then reverted (sha256 checked):

mutant tests result
drop the kept-repository read (kept_head answers nothing) -k 413a 4 failed: (a)'s text test, both paste tests that use its keep line, and the members (a) test
drop the holder bootstrap's keep sentence -k 413b 4 failed: (b)'s text tests and keep-line pastes, for the checkout member and for the member the gate gets
compute rerun from other legs only -k 414_4 2 failed: both legs cases of (4)
R559's R1: detached() reads any failure as detached, as main did -k detached 2 failed: the detached test and the member --json test
R559's R3: the bootstrap names off_pin only -k 413b 2 failed: the second-member text test and its keep-line paste
the url asked of .gitmodules alone -k 414_3 6 failed: the url-in-config text and paste tests, for both legs cases and the member
no nested sentence on the legs line -k submodule 2 failed: the leg-submodule text test and its keep-line paste
the gate's why without the states no line cures -k gate_is_the_line 1 failed

macOS and Windows were not run here; this PR's CI is their first run.

Shared files

shape-doctor.py is also changed by other open PRs, on other lines. Each is named by the functions its hunks sit in, and the functions it adds:

This PR touches submodule_update_next, check_legs, detached, member_state, member_problem and members_verdict, and adds its helpers between lockstep_next and submodule_update_next. None of the four adds a function under a name this PR adds.

tests/test_doctor_legs_members_next.py is also changed by lane 3's #554 (l3-544-test-hygiene): its import line and test_a_plain_clones_empty_mounts_are_cured_by_the_pasted_line. This PR changes the module docstring and one other test, test_the_members_reflog_note_is_printed_for_a_detached_member_only, and appends its tests at the end.

git merge-tree --write-tree of this head with origin/main and with each of them exits 0 with a tree, so none conflicts. The heads were main b8b7062, #557 430c13f, #554 4619757, #555 abbce43, #553 e64f47e and #552 9362501. Main's commit since this branch's base, 5ed3e5d (#518), changes none of this PR's files.

The two stale #533 docstrings in shape-doctor.py (submodule_update_next, check_legs), and the test file's own, are corrected here: since #475 and #490 bootstrap does move a branch whose tip is not the pin, where that loses nothing.

v1.1.0: lands only after the v1.0.0 tag.

Closes #413
Closes #414

🤖 Generated with Claude Code

Summary by Sourcery

Make shape-doctor accurately route legs and members repairs while warning about every detached commit and nested checkout that a proposed update may move.

Bug Fixes:

  • Correct NEXT routing for legs and members with missing repositories, unborn HEADs, missing submodule URLs, and nested lockstep problems so commands are only offered when they can cure the reported state.
  • Ensure checkout and bootstrap explanations identify every detached commit that may be displaced, including commits in retained submodule repositories and recursively updated nested submodules.

Enhancements:

  • Unify submodule checkout analysis, URL resolution, retained repository detection, detached/unborn HEAD handling, and explanatory notes across the legs and members checks.
  • Read submodule metadata and configured URLs consistently with Git and distinguish mounts that can be relinked from those requiring the lockstep gate or manual intervention.

Tests:

  • Expand legs and members coverage for detached commit preservation, missing repositories, unborn HEADs, missing URLs, recursive submodules, retained configuration URLs, nested repositories, and per-leg rerun guidance.

Brett Heap and others added 2 commits October 14, 2026 17:50
…ommit it would leave in a reflog alone, so the repository git kept for an emptied mount is read through `--git-dir` and kept with a line that names it, the holder's own bootstrap carries the same keep sentence, and a members line says `--recursive` checks out each member's own legs too (#413)

`submodule_update_next` named a keep line only for a POPULATED mount
detached off its pin (`off_pin`). The line moves three more HEADs that can
hold the only copy of a commit, and after the paste each such commit was
held by a reflog entry alone, with nothing said.

(a) A mount emptied by `git submodule deinit -f` keeps its repository in
    `.git/modules/<name>`, and `submodule update` links the mount to it
    again and checks the gitlink out over the commit. `emptied_reach`
    finds that repository by the submodule's NAME (`gitmodules_entry`,
    which reads `.gitmodules` where git reads it: the working tree, else
    the index, else HEAD) and `git rev-parse --git-path modules/<name>`,
    and reads its HEAD through `--git-dir` (`kept_head`), never in the
    mount, where git walks up and answers about the root. Detached at a
    commit that is not the gitlink, the rule `off_pin` already used
    (`left_in_reflog`), the commit is named with
    `git --git-dir=<repository> branch NAME <commit>`: in the empty mount
    `git -C <mount> branch NAME` would branch the ROOT. With the mount
    directory itself gone, git refuses every command in that repository
    (`cannot chdir`, exit 128, measured), because its `core.worktree`
    names the mount, so the read and that keep line carry a `--work-tree`.
(b) The holder's own `scripts/bootstrap.py`, the `members` NEXT where it
    can run, runs the same update over every member (`fetch_members`) and
    said only that it leaves each member detached at its pin. Its `why`
    now carries the same keep sentence, built by the same function
    (`checkout_notes`, factored out of `submodule_update_next`), for every
    member it would move off a detached commit, not only those the row
    sends to a checkout, because its update names no path.
(c) `--recursive` checks out each member's own legs at the commits the
    MEMBER records. The row does not walk into them: a `members` line says
    so in general words, with `git -C LEG branch NAME` to run in that leg
    first, wherever a member it reaches has a repository for those legs to
    be in. `--recursive` stays, for parity with the holder's bootstrap.

`detached` takes a `git_dir` for a kept repository, and answers None,
not "detached", when `symbolic-ref` fails for any reason but a detached
HEAD.

Tests at the end of tests/test_doctor_legs_members_next.py, in the shape
of `test_the_keep_line_in_the_explanation_keeps_a_detached_commit`: for
each of (a), (b) and (c) the commit is named before the paste (by its sha
in (a), by its mount's keep line in (b), in general words in (c), which
does not walk), the keep line run first leaves it on a branch, and
without that line no ref holds it afterwards. A member on a branch still
gets no keep line of its own, and the existing test that asserted no
`reflog` at all in its `why` asserts that instead.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t with no `.git` of its own and send it to the git line when git kept its repository, name an unborn HEAD and a mount `.gitmodules` gives no url for with no NEXT instead of a gate that says `pins ok` or a line that exits 128, and say to run the doctor again for a leg the line checks out whose own pin is wrong (#414)

#414's four states, found by the #276 review, and the `members` row,
which reaches each of them:

(1) A populated mount with no `.git` of its own. `head_of` asked git in
    it, git walked up, and the row quoted the ROOT's HEAD as the leg's.
    No HEAD is read there now (`member_state` likewise), and the mount is
    routed on what `submodule update` routes on, whether git kept its
    repository (`checkout_reach`): kept, the update links the two again
    and the row is `ok`, so it is the git line's; gone, the update tries a
    clone into a directory that is not empty and exits 1, so it stays the
    lockstep gate's. The `why` says which.
(2) An unborn HEAD (`unborn_branch`) is no lockstep fact: the gate said
    `pins ok` and the row stayed red, and the update exits 128. Nothing
    cures it without choosing a commit for the person, so the NEXT is None
    and the `why` names the state.
(3) A mount `.gitmodules` gives no url for. The git line's `--init` exits
    128 on `No url found`, for an empty mount and a populated one alike,
    so `gitmodules_entry` is asked before the line is chosen. The gate,
    measured, names no `.gitmodules` entry: `validate-pins.py` refuses
    `pin-submodule-uninitialized` and `validate-family.py`
    `member-uninitialized`, each handing back the same git line. So the
    NEXT is None and the `why` says a person restores the entry from the
    repository's history; no command is printed, so none discards an
    uncommitted `.gitmodules`. A `.gitmodules` deleted from the working
    tree alone is read from the index, as git reads it, and the line
    stands. In a holder, a member with no url takes the holder's own
    bootstrap off the line: its update names no path, git stops it at a
    section with a path and no url before it fetches any member (exit
    128), and the bootstrap still exits 0. The git line over the other
    members is the NEXT.
(4) `rerun` is asked per leg: a leg the line checks out whose own pin is
    not 40 hex, or not its gitlink, gets "run the doctor again" too, and
    so does the holder's bootstrap line.

The precedence stays: a checkout first, then the lockstep gate, then no
line. Both rows route through `checkout_reach`, one definition.

And #533's two stale docstrings, moved here from P6: `submodule_update_next`,
`check_legs` and tests/test_doctor_legs_members_next.py said bootstrap
"never moves an existing branch whose tip is not the pin". Since #475 and
#490 it fast-forwards one behind the pin and moves back one ahead of it
with no commit of its own; any other branch holding a commit the pin does
not contain stays where it is. They say so now.

Tests at the end of tests/test_doctor_legs_members_next.py, for both
rows: each NEXT, pasted, turns the row `ok`, or the NEXT is None and the
`why` or the reason names the state. (1) never quotes the root's HEAD,
(3) never prints a line that exits 128, and one test pins what the git
line and the gate do there, and (4) says to run the doctor again.

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

sourcery-ai Bot commented Oct 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates the doctor’s legs and members NEXT routing to avoid unsafe or ineffective commands, accurately account for every detached commit a checkout or bootstrap may move, and provide shared, tested explanations for retained repositories, recursive updates, unborn HEADs, missing submodule URLs, and follow-up lockstep facts.

Sequence diagram for safe submodule checkout routing

sequenceDiagram
    participant Doctor
    participant Gitmodules
    participant KeptRepo
    participant Git
    Doctor->>Gitmodules: gitmodules_entry(root, rel)
    alt URL is missing
        Gitmodules-->>Doctor: no-url
        Doctor-->>Doctor: NEXT None
    else URL exists
        Doctor->>KeptRepo: kept_gitdir(root, name)
        alt Repository was kept
            Doctor->>KeptRepo: kept_head(root, git_dir)
            KeptRepo-->>Doctor: detached state and commit
        else No kept repository
            KeptRepo-->>Doctor: None
        end
        Doctor->>Doctor: checkout_reach(...)
        alt checkout or relink
            Doctor->>Git: submodule_update_next(...)
            Git-->>Doctor: checkout command and keep notes
        else gone
            Doctor-->>Doctor: lockstep_next(...)
        end
    end
Loading

Flow diagram for legs and members NEXT routing

flowchart TD
    A[Inspect leg or member state] --> B{Checkout can cure it?}
    B -->|No| C{Unborn HEAD or missing URL?}
    C -->|Yes| D[NEXT: None; explain manual repair]
    C -->|No| E[NEXT: lockstep gate]
    B -->|Yes| F[checkout_reach]
    F --> G{Route}
    G -->|checkout or relink| H[git submodule update --init --recursive --checkout]
    G -->|gone| E
    G -->|no-url| D
    H --> I[Add keep and rerun guidance]
Loading

File-Level Changes

Change Details Files
Centralize checkout safety and detached-commit preservation so every command emitted for legs and members accounts for kept repositories, recursive updates, and shell-safe keep instructions.
  • Read .gitmodules using Git’s working-tree/index/HEAD precedence and reject git lines that would fail due to a missing URL.
  • Detect repositories retained under .git/modules, inspect their HEAD through --git-dir, and generate branch commands that preserve detached commits, including missing-mount work-tree handling.
  • Factor shared checkout explanation generation for direct mounts, holder bootstrap behavior, recursive member legs, and relinked mounts.
  • Expand submodule update command metadata and rerun guidance to describe all potentially moved commits and remaining lockstep facts.
shape-doctor.py
Refine legs and members verdict routing so NEXT commands cure the reported state or are withheld when no safe automated repair exists.
  • Stop reading the root HEAD for populated mounts without their own .git and route them based on whether Git retained a submodule repository.
  • Handle unborn HEADs and missing .gitmodules URLs with no NEXT command and explanatory reasons.
  • Route retained no-.git mounts to checkout, mounts with no retained repository to the lockstep gate, and member URL failures away from the holder bootstrap.
  • Apply per-mount rerun detection when checkout leaves invalid pins or gitlinks, while preserving recursive and bootstrap semantics.
shape-doctor.py
Add comprehensive regression coverage for detached-commit naming and all four checkout-routing edge cases across legs and members.
  • Test kept repositories, missing mount directories, bootstrap-moved members, recursive member legs, and preservation of commits via pasted keep commands.
  • Test no-.git mounts, unborn HEADs, missing URLs, Git’s .gitmodules source precedence, and gate-versus-git-line behavior.
  • Verify shell quoting, command exit behavior, row status transitions, and rerun guidance.
tests/test_doctor_legs_members_next.py

Assessment against linked issues

Issue Objective Addressed Explanation
#413 For every detached commit that a legs or members NEXT can move, explain the risk and provide an appropriate keep instruction, including kept gitdirs for emptied mounts, the holder’s bootstrap path, and recursive member legs. ✅
#414 Route a populated mount with no .git of its own according to whether Git retained its submodule repository, without reading or reporting the root's HEAD as the mount's. ✅
#414 For an unborn HEAD or a mount with no .gitmodules URL, avoid handing over a command that does not cure the state; give no NEXT and explain what must happen instead. ✅
#414 When a checkout NEXT leaves a lockstep problem in the same leg, say that the doctor must be run again. ✅

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

…rl` keeps the git line while the root's own config still holds one, a leg's own submodules are named in general words as a member's legs are, the lockstep gate's `why` carries the states no line cures, and tests pin `detached`'s None and every member the holder's bootstrap moves (#414, #413)

Fix round 1 for PR #559, after the review R559 at 83b3865.

The url check asked `.gitmodules` alone, and that was a regression from
main. `git submodule init` copies a mount's url into the root's config,
and `--init` reads it there first. So an INITIALIZED mount whose section
kept its `path` and lost its `url` key still updates: measured, the git
line exits 0 and cures the row, whether the leg is populated past its pin
or emptied by `rm`, and the same holds for a member under the holder's
own bootstrap. The row gave NEXT None and a `why` saying the line would
exit 128. A mount is now `no-url` only when no section gives its path, or
the section has no url and `git config --get submodule.<name>.url` in the
root finds none (`configured_url`). `deinit` drops the configured url and
a missing section leaves git no name to look one up by, so both still get
NEXT None. The reason and the `why` say "in .gitmodules, nor in the
config of this repository".

The `legs` line's `--recursive` checks out the submodules a leg carries of
its own, and a commit made on one's detached HEAD was left in its reflog
unannounced (R559's p3). A leg the line names that carries a `.gitmodules`,
or whose kept repository keeps `modules/`, now gets the general sentence a
member's legs get (#413 c), with the placeholder keep line
`git -C LEG/SUB branch NAME`. The row walks into none of them.
`checkout_reach`'s third answer is now that "carries submodules" fact, for
both rows.

Where the lockstep gate is the NEXT, an unborn HEAD or a mount with no
url beside it is said in the gate's `why` with the same sentence it gets
when no line is printed, and that sentence ends by saying to run the
doctor again. The gate itself cures nothing, so no separate re-run
sentence is added.

`checkout_notes` and `submodule_update_next` no longer say that the
sentence carries none of `;`, `'`, a backtick or a parenthesis. That rule
bound a `   # ` tail pasted with the line, and since #277 the sentence is
the row's `why`, on a line of its own.

New tests at the end of tests/test_doctor_legs_members_next.py:
- the url the root's config keeps, for a leg past its pin and one emptied
  by `rm`, and for a member: the line is offered, and pasted it cures the
  row;
- a leg carrying submodules gets the sentence and a leg without one does
  not, and with a real nested submodule the placeholder keep line, filled
  in and run first, keeps the commit, while without it no ref holds it;
- a second member the row hands to the gate but the holder's bootstrap
  moves is named by its keep line, and pasting proves the move;
- `detached` answers None where `symbolic-ref` fails, and a member whose
  gitdir is gone is not called detached in `--json`;
- an unborn leg beside a lockstep one is said in the gate's `why`.
`add_member_row` is factored out of the url-less member's helper.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap changed the title shape-doctor.py: the legs and members NEXT names every detached commit it would move, and sends a mount with no .git of its own, an unborn HEAD, a mount .gitmodules gives no url for, and a leg with its own lockstep fact to a line that cures it or to none (#413, #414) shape-doctor.py: the legs and members NEXT names every detached commit it would move, and sends a mount with no .git of its own, an unborn HEAD, a mount git finds no url for, and a leg with its own lockstep fact to a line that cures it or to none (#413, #414) Oct 14, 2026
…r's own bootstrap whenever any member with a gitlink is one git finds no url for, routed or not, a commit in a submodule whose directory is gone is kept by a line spelled through its repository, a leg whose working tree dropped its submodule is asked for its repository's `modules/`, and the `.gitmodules` listing is read by a pure helper (#413, #414)

Fix round 2 on #559, from R559v's verification of aa63ff6.

S2 (#414). The holder's own bootstrap updates with no path named
(`fetch_members`), so git stops that whole run at the first gitlinked
member it finds no url for, whatever the row says of that member. R559v
measured it: beside an emptied IRRS, a second member at its pin, which the
row passes over, or recorded off it, which the row hands to the family
gate, with no `.gitmodules` section or a path-only one and no url in the
holder's config, left the bootstrap as the NEXT. Pasted, it printed `FETCH
INCOMPLETE` and `No url found` and the row stayed red, while the git line
over IRRS alone exits 0 and fills it. The url question, the section
(`gitmodules_entry`) and then the root's own config (`configured_url`), is
now one helper, `submodule_url`. `checkout_reach` asks it, and
`url_less_members` asks it of every member with a gitlink before the
bootstrap is chosen. Any such member takes the plain git line, and each one
is named in the `why` by its `no_url_sentence`, so the reader knows which
entry to restore.

N6 (#413). Where the nested trigger is the `modules/` of a repository git
kept for an EMPTY mount, the submodules' directories are gone with it, and
`git -C LEG/SUB branch NAME` exits 128 (`No such file or directory`).
`checkout_reach` now says which keep line a mount's own submodules need
(`nested_kind`: `tree` where the working tree carries a `.gitmodules`,
`repo` where only a repository keeps theirs), and `nested_sentence` spells
the second: `git --git-dir=REPO/modules/SUB --work-tree=ROOT branch NAME
HEAD`. The work tree is the root because the submodule repository's own
`core.worktree` names the missing directory (`cannot chdir`, exit 128,
without it). Filled in and run first, it exits 0 and the commit is on that
branch after the paste, for a leg emptied by `deinit` and for a member's
own leg after the member is.

N7 (#413). A leg with its own `.git`, past its pin at a commit that dropped
its submodule, carries no `.gitmodules`, yet the gitlink's commit mounts
the submodule and `--recursive` links its kept repository again and checks
it out. `keeps_modules` asks the mount's own repository
(`rev-parse --absolute-git-dir`) for its `modules/`, and the same spelled
line keeps the commit. It is under ten lines of code, so it is done here
rather than put on #460's checklist.

S3776. `gitmodules_entry`'s record parsing is now a pure helper,
`section_url(listing, rel)`, unit-tested on git's own `-z` listing of a
`.gitmodules` whose section name holds a space and a dot: the last url a
section gives wins, the last section giving a path wins, and an empty url
is none.

S9073. The three composite test asserts are split into single asserts that
keep their message, so a failure says which half failed.

N8. The members twin of the test that the gate's `why` carries what no line
cures: an unborn HEAD in IRRS beside a member recorded off its pin, the
family gate as the NEXT, and the unborn sentence, which says to run the
doctor again, beside it.

S1192 (`".gitmodules"` repeated) is accepted, as the coordinator ruled.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ean merge for ruling 27

This is a clean merge of main a2243f1 (#557, on #560) with no hand
resolution: `git merge` auto-merged shape-doctor.py and
tests/test_doctor_legs_members_next.py and reported no conflict, and its tree,
e0e7f1e, equals what `git merge-tree
--write-tree 66a9353 origin/main` prints (T). T is also the tree the targeted
set passed on, committed in a scratch worktree before this merge existed:
997 passed, 3 skipped (the three PowerShell tests, Windows only).

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

Copy link
Copy Markdown

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

sourcery-ai Bot commented Oct 15, 2026

Copy link
Copy Markdown

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

You can request another review in 2 days and 11 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 2e6c6fa into main Oct 15, 2026
5 checks passed
@brettheap
brettheap deleted the fix/doctor-next-detached-commits branch October 15, 2026 14:08
brettheap pushed a commit that referenced this pull request Oct 15, 2026
…y-hand test and #581 among them) into #569, so the PR's CI runs on current main

Merges origin/main fb786c1, read from the
shared store without a fetch. Main moved from 7d9bec9 (#569's last merge) by
#556, #557, #558, #559, #560, #562, #567, #568, #579, #580 and #581. #579
(#550) landed tests/test_setup_by_hand_commands.py, which this PR's
`--no-next-steps` makes red; the next commit answers it. The merge itself is
clean, and its tree is the pm569 prediction's #581 step (53b6d0f7)'s, so the
resolution prepared against the predicted main carries over.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 15, 2026
… fix/540-pin-source-unread

This merges origin/main 2bf8fd4 as the shared store held it at
2026-10-15T16:50Z, thirteen landings past the 7d9bec9 this branch last
merged: #555 (shape-doctor's manifest kinds), #565 (scaffold's
`scaffold-remote-exists`), #581 (adopt's `check` of a kept pin path and
an unwritable work directory), #568, #559, #557, #560, #580, #556, #558,
#567, #579 and #562. It is done so that the PR's CI merge ref and this
round's rewording run on current main.

The merge was clean, with no conflict hunk. `adopt-project.py`,
`scaffold-project.py`, `scripts/shape_materialize.py`, `docs/cli.md` and
`tests/test_adopt_root_symlinks.py` took main's changes beside this PR's
by the textual merge, and `scripts/render-cli-reference.py --check`
passes on the result.

Main: 2bf8fd4.

Lane: openreposhape-2 (openRepoShape-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment