Skip to content

shape-doctor.py: a leg's gitlink that no .gitmodules section registers, beside one whose sections register other paths or beside none, is named with its own way out, as #914 names one beside a .gitmodules with no section (#1080) - #1092

Merged
brettheap merged 4 commits into
mainfrom
l3-1080-unregistered-gitlinks
Oct 21, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What a person now sees

#1073 (#914) named a submodule beside a .gitmodules of its leg that holds no section. #1080 asked for the two states it left unnamed:

  • a gitlink that no section registers, beside a .gitmodules whose sections register other paths;
  • a gitlink in a leg whose index tracks no .gitmodules, where the copy git reads instead (an untracked file on this disk, or else HEAD's) does not register it either.

In both, a recursive clone of the leg exits 0 and leaves the gitlink's directory empty, and git submodule status there exits 128 with fatal: no submodule mapping found in .gitmodules for path 'vendor/lib' (measured on real legs, git 2.43). On main the doctor said nothing in either state. Now the placement row names each one, with that state's own way out:

The --json names go under the same leg key, unregistered_submodules, and unregistered_beside says which state (other_sections or no_gitmodules).

A gitlink git counts as registered is not named. Beside an untracked .gitmodules with its section, or beside HEAD's copy with its section once git rm .gitmodules took the file away, git submodule status exits 0 and the row names nothing.

The change (shape-doctor.py)

Decisions the Direction left open

Tests changed beside the new ones

stage_gitlink stages a gitlink in a leg with no .gitmodules, which is the "beside none" state. Three tests in tests/test_shape_doctor.py held the reason exactly, and each now expects the new clause, in the review round's words:

  • test_both_classes_are_said_apart_in_one_row;
  • test_a_submodule_in_the_leg_its_record_names_is_placed;
  • test_a_submodule_in_the_other_leg_from_its_record_is_misplaced.

#914's sectionless test gains two asserts: no unregistered_beside, and one "nothing registers them" in the reason.

Review round

RV1092 reviewed b74efd6 (NOT READY). This round is one commit, a7169b0, on top of it.

Review round 2

RV1092d reviewed a7169b0 (READY, five NITs). This round is one commit, 83627b2, on top of it: docstrings in shape-doctor.py, one test case and one test helper. No code behaviour changes. Every string literal in shape-doctor.py outside a docstring or comment is the one it was (checked with ast: the dumps of the module with its docstrings left out are identical at a7169b0 and 83627b2), so #1073's clause and entry and every refusal code are unchanged.

Tests at 83627b2, each file alone, under the same stub gh and environment as above:

  • tests/test_doctor_plan_overrides.py: 60 passed in 53.35s
  • tests/test_doctor_recorded_answers.py: 29 passed in 34.80s
  • tests/test_doctor_next_is_a_command.py: 61 passed in 49.70s
  • tests/test_shape_doctor.py (once): 445 passed, 3 skipped in 441.17s (0:07:21)
  • tests/test_repo_hygiene.py: 100 passed in 1.63s
  • tests/test_no_auto_maintenance.py: 60 passed in 5.70s
  • pyflakes 4.0.0: no warning in shape-doctor.py; tests/test_doctor_plan_overrides.py has the same 28 fixture-argument warnings as at a7169b0, and no new one.

Merges at 83627b2, after a fresh fetch (git merge-tree --write-tree):

Follow-up

Follow-up: #1093, reading the index's copy where this disk lacks the tracked .gitmodules.

Out of scope

#1080 has no "Needs a ruling" or "Documented limit" section. These are left as they were, and the module docstring names the first four as limits, with the two states that review round 2 (N-1) added to the second and the third:

Reproductions

Round 1. A leg with vendor/lib committed as a gitlink, cloned with --recurse-submodules (real git):

  • beside [submodule "other"] path = other: clone exit 0, vendor/lib empty, git submodule status exit 128;
  • beside no .gitmodules: the same.

The doctor at main (de75bf0) said nothing in either state.

Review round. unregistered_gitlinks against git submodule status, with one fresh leg per case (git 2.43), at b74efd6 and at a7169b0. In each case the leg's index tracks no .gitmodules:

case status rc b74efd6 names a7169b0 names
no copy anywhere 128 vendor/lib vendor/lib
untracked, registers it 0 vendor/lib (wrong) nothing
untracked, other section 128 vendor/lib vendor/lib
untracked, no section 128 vendor/lib vendor/lib
untracked, git refuses it (bare path) 128 vendor/lib nothing (limit)
untracked link to a file that registers it 0 vendor/lib (wrong) nothing
untracked link to a file with another section 128 vendor/lib nothing (limit)
HEAD's copy registers it, then git rm .gitmodules 0 vendor/lib (wrong) nothing
HEAD's copy has another section, then git rm 128 vendor/lib vendor/lib
HEAD's copy has a bare path, then git rm 128 vendor/lib vendor/lib
HEAD's copy registers it, untracked other section on disk 128 vendor/lib vendor/lib
HEAD's copy has another section, untracked file registers it 0 vendor/lib (wrong) nothing
HEAD's copy registers it, git rm --cached .gitmodules 0 vendor/lib (wrong) nothing

RV1092's 32-case probe, its script unchanged, agrees with git in 28 cases at b74efd6 and in 29 at a7169b0:

The new test cases, run with shape-doctor.py at b74efd6 (-k "no_section_registers or section_written"): 7 failed, 11 passed, 41 deselected.

  • untracked-and-registering, untracked-and-refused and in-head-and-registering fail because b74efd6 names the gitlink ('no_gitmodules' == None).
  • no-gitmodules, no-gitmodules-and-recorded, untracked-with-other-sections and in-head-with-other-sections fail only on the clause's new words.

At a7169b0 the whole file passes.

Tests

Each file was run alone at a7169b0's tree, under a stub gh (exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR and EMAIL=tests@openreposhape.invalid, as /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch> -o tmp_path_retention_policy=failed <file>:

  • tests/test_doctor_plan_overrides.py: 59 passed in 42.73s
  • tests/test_doctor_recorded_answers.py: 29 passed in 32.24s
  • tests/test_doctor_next_is_a_command.py: 61 passed in 46.54s
  • tests/test_shape_doctor.py (once): 445 passed, 3 skipped in 392.32s (0:06:32)
  • tests/test_scaffold_leg_path_collisions.py: 104 passed in 3.07s
  • tests/test_repo_hygiene.py: 100 passed in 1.50s
  • tests/test_no_auto_maintenance.py: 60 passed in 4.26s
  • Round 1 also ran tests/test_agents_md_codes.py (47 passed), tests/test_cli_reference.py (23 passed) and tests/test_update_add_codes_documented.py (6 passed). The review round changes no refusal code and no doc, so they were not re-run.
  • pyflakes 4.0.0:
    • shape-doctor.py and tests/test_shape_doctor.py: no warning at b74efd6 or at a7169b0.
    • tests/test_doctor_plan_overrides.py: the same 28 fixture-argument warnings (redefinition of unused 'standard'/'project') at both, and no new one.

Mutants

Round 1, at b74efd6. Each was run against the new tests, then the file was restored and checked identical with cmp. Every one was KILLED:

Review round, at a7169b0. Each was applied to shape-doctor.py and run with -k "no_section_registers or section_written", then the file was restored and checked identical with cmp. Every one was KILLED:

  • (a) where the index tracks no .gitmodules, read no disk file (if False and os.path.lexists(disk)): 2 failed, [untracked-and-registering] and [untracked-and-refused].
  • (b) skip HEAD's copy (return set() before it is read): 1 failed, [in-head-and-registering].
  • (c) fold a refused untracked file into an empty set (registered_paths(disk) or set()): 1 failed, [untracked-and-refused].

Merges

git merge-tree --write-tree of a7169b0, after a fresh fetch:

Closes #1080

Lane: openreposhape-3 (openRepoShape-3)
🤖 Generated with Claude Code

Summary by Sourcery

Identify and explain unregistered gitlinks across additional .gitmodules states while matching Git’s registration semantics and preserving placement verdicts.

New Features:

  • Report unregistered gitlinks beside unrelated .gitmodules sections or in legs whose index tracks no .gitmodules, including the applicable remediation.
  • Expose the unregistered-submodule context through unregistered_beside in placement JSON output.

Bug Fixes:

  • Align unregistered-gitlink detection with Git’s effective .gitmodules source and registration behavior, avoiding false reports when untracked or HEAD copies register the gitlink.

Enhancements:

  • Split diagnostic clauses by .gitmodules state while preserving the existing sectionless diagnostic unchanged.
  • Handle .gitmodules parsing edge cases and document cases that remain intentionally unsupported.

Documentation:

  • Update shape-doctor documentation and diagnostic comments for the newly reported submodule states and known limits.

Tests:

  • Add coverage for registration precedence, malformed and non-regular .gitmodules files, HEAD and untracked copies, remediation commands, JSON output, and unchanged verdict behavior.

…ers, beside one whose sections register other paths or beside none, is named with its own way out, as #914 names one beside a `.gitmodules` with no section (#1080)

#914 named a submodule beside a `.gitmodules` of its leg that holds no
section. Two other states were named by no row, though in each a
recursive clone of the leg exits 0 and leaves the submodule's directory
empty, and `git submodule status` exits 128 with "no submodule mapping
found in .gitmodules" (measured, git 2.43):

- a gitlink beside a `.gitmodules` whose sections register other paths;
- a gitlink in a leg whose index holds no `.gitmodules`.

The doctor now compares each leg's gitlinks with the paths its
`.gitmodules` registers, read as git reads them: the last `path` each
section gives, of a section git does not ignore, matched whole. Each
gitlink none registers is named under the same `--json` key
`unregistered_submodules`, and the leg's entry says which state under
`unregistered_beside` (`other_sections` or `no_gitmodules`). It is said
in the placement row, never a finding, as #914's clause is.

Each state has its own way out, typed in a real leg in the tests:
beside sections, a section with its `path` and `url` in that file, or
`git rm --cached` once that file is staged; beside none, a section in a
`.gitmodules` added to the leg, or `git rm --cached` alone, since there
is nothing to stage there.

#914's case is unchanged, byte for byte: its clause, and its entry,
which gains no `unregistered_beside`.

Three tests in test_shape_doctor.py stage a gitlink in a leg with no
`.gitmodules` and held the reason exactly; each now expects the new
clause.

Closes #1080

Lane: openreposhape-3 (openRepoShape-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap brettheap added this to the v1.2.0 milestone Oct 21, 2026
@sourcery-ai

sourcery-ai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

Extends shape-doctor to identify unregistered gitlinks in legs with unrelated or absent .gitmodules files, classify them in JSON, and present state-specific note guidance without changing verdicts, backed by comprehensive Git-behavior and output regression tests.

Sequence diagram for state-specific unregistered gitlink guidance

sequenceDiagram
    participant Doctor
    participant Leg
    participant Gitmodules
    Doctor->>Leg: audit_leg
    Leg-->>Doctor: gitlinks and tracked paths
    alt .gitmodules absent from index
        Doctor->>Doctor: unregistered_gitlinks
        Doctor-->>Leg: unregistered_beside = no_gitmodules
        Doctor-->>Leg: Suggest add .gitmodules section or git rm --cached
    else .gitmodules has unrelated sections
        Doctor->>Gitmodules: registered_paths
        Gitmodules-->>Doctor: registered path set
        Doctor-->>Leg: unregistered_beside = other_sections
        Doctor-->>Leg: Suggest stage .gitmodules, add section, or git rm --cached
    end
    Doctor-->>Leg: Keep existing note or ok status
Loading

Entity relationship diagram for unregistered submodule JSON output

erDiagram
    LEG_ENTRY {
        string path
        string unregistered_submodules
        string unregistered_beside
    }
    GITMODULES {
        string section
        string path
        string url
    }
    GITLINK {
        string path
        string mode_160000
    }
    LEG_ENTRY ||--o{ GITLINK : names
    GITMODULES ||--o{ GITLINK : registers
    LEG_ENTRY }o--|| GITMODULES : may_hold
Loading

Flow diagram for detecting unregistered gitlinks

flowchart TD
    A[audit_leg] --> B[empty_gitmodules]
    B -->|No existing empty-section finding| C[unregistered_gitlinks]
    C --> D{.gitmodules in leg index?}
    D -->|No| E[unregistered_beside = no_gitmodules]
    D -->|Yes| F[registered_paths]
    F --> G{Gitlink path registered?}
    G -->|No| H[unregistered_beside = other_sections]
    G -->|Yes| I[No new finding]
    E --> J[Write unregistered_submodules and state to leg JSON]
    H --> J
    I --> K[Preserve existing status]
Loading

File-Level Changes

Change Details Files
Detect and classify gitlinks that are not registered by their leg’s .gitmodules, including legs with unrelated sections or no .gitmodules.
  • Compare staged gitlink paths against Git-parsed .gitmodules path mappings.
  • Distinguish other_sections from no_gitmodules and preserve the existing sectionless behavior.
  • Handle Git’s last-path semantics, ignored sections, exact path matching, malformed/unreadable files, and non-UTF-8/path edge cases.
  • Invoke detection during leg auditing and emit unregistered_submodules plus optional unregistered_beside JSON fields.
shape-doctor.py
Add separate note clauses with state-specific remediation while keeping the existing placement verdict and sectionless clause behavior unchanged.
  • Report distinct guidance for adding a section or removing the gitlink in each state.
  • Keep the new condition as a note rather than a finding and avoid duplicating the existing clause.
  • Document the new JSON keys and behavior in module and key comments.
shape-doctor.py
Expand regression coverage for detection semantics, user-facing output, JSON shape, and remediation commands. tests/test_doctor_plan_overrides.py
tests/test_shape_doctor.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1080 Detect each leg-index gitlink that is not registered by a path in that leg’s .gitmodules, including when the file registers other paths or is absent. ✅
#1080 Report those unregistered gitlinks under the leg’s unregistered_submodules key and explain the appropriate registration or removal remedy for each state, without changing the existing sectionless-file behavior. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

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

Customizing Your Experience

Access your dashboard to:

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

Getting Help

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-21T04:10:22Z) Independent review RV1092 of #1092 at b74efd6: NOT READY, one MUST.

MUST

  • shape-doctor.py:8768-8769 (unregistered_gitlinks): a gitlink git counts as registered is named. The leg's index holds no .gitmodules, but an untracked .gitmodules on disk has a section for the gitlink. git submodule status -- vendor/lib exits 0 (git reads the disk copy, tracked or not). The doctor still names code/vendor/lib under unregistered_beside: no_gitmodules with "nothing registers them" (confirmed end to end through placement()). In the tracked twin (a section added on disk but not staged) the doctor stays silent, so the same state gets opposite answers. Fix: in that branch read the disk copy as git does:
    registered = registered_paths(mount / adopt.GITMODULES) or set()
    named = [f"{rel}/{path}" for path in held if path not in registered]
    return named, (BESIDE_NO_GITMODULES if named else None)
  and add an untracked-and-registering case at tests/test_doctor_plan_overrides.py:468 (status rc 0, nothing named). Tried in the reviewer's clone: that probe then agrees with git, and the other untracked and no-.gitmodules cases are still named.

SHOULD

  • shape-doctor.py:8770-8773: a tracked .gitmodules deleted from disk is missed. git reads the index copy and exits 128; the doctor says nothing. Read ':.gitmodules' when the disk has no copy (gitmodules_listing(mount, ["--blob", ":.gitmodules"], partial=True), then submodule_keys(..., working_tree=False)), or open a follow-up and name the limit in the module docstring.

NIT

Held

Lane 3's plan:

Lane: openreposhape-3 (openRepoShape-3)

…s` is named only where the copy git reads instead, an untracked file on this disk or else HEAD's, does not register it (#1080)

Review round on #1092 (lane 3's review RV1092).

M-1. Where a leg's index tracked no `.gitmodules`, the doctor named every
gitlink under `unregistered_beside: no_gitmodules`, though git reads the
working tree's file when there is one, tracked or not, else the index's
copy, else HEAD's (`config_from_gitmodules`, the order
`gitmodules_entry` already reads in). Beside an untracked `.gitmodules`
with a section for the gitlink, `git submodule status` exits 0 and the
row said "nothing registers them". `registered_unindexed` now reads the
copy git reads there:

- an untracked file on this disk, as `registered_paths` reads a tracked
  one: one that registers the gitlink names nothing; one with other
  sections, or none, names it beside none; one git refuses, or that is
  not a regular file, names nothing, as the tracked branch does;
- with none on this disk, HEAD's copy, as `gitmodules_entry` reads it
  (`--blob HEAD:.gitmodules`, partial, `working_tree=False`). Measured,
  git 2.43: after `git rm .gitmodules`, a HEAD copy that registers the
  gitlink gives `git submodule status` 0, one with another section 128;
- with no copy anywhere, the gitlink is named beside none, as before.

`no_gitmodules_clause` now says "sit in a leg whose index tracks no
`.gitmodules`", and the comment on `BESIDE_NO_GITMODULES` and the
docstrings for this state say the same. Both ways out are typed in the
tests beside an untracked file and beside HEAD's copy: `git rm --cached`
alone exits 0, and a section written in that file, then `git add
.gitmodules`, gives status 0.

S-1. A `.gitmodules` the index tracks and this disk lacks still names
nothing. The module docstring and `unregistered_gitlinks` say that git
reads the index's copy there, and that neither printed way out is true
until the file is restored (#1093).

N-1. The docstring of `test_both_classes_are_said_apart_in_one_row` no
longer says "each submodule once": each is named in its own class's
clause, and #1080's clause then names both again.

N-2. The module docstring's sentence on #1093 also names the other
states where the doctor names nothing though git counts the gitlink
unregistered: a gitlink whose path is not UTF-8 (`UNWRITABLE_IN_A_PLAN`),
and a `.gitmodules` git refuses, or a link on this disk.

#914's clause and entry and `other_sections_clause` are unchanged, byte
for byte.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-21T04:56:56Z) Independent delta review RV1092d of #1092 at a7169b0 (fix round 1 on b74efd6): READY. No MUST, no SHOULD, five NITs.

M-1 is fixed. Over 71 probe rows against git submodule status (git 2.43), no gitlink git counts as registered is named.

NIT

  • shape-doctor.py:262-268 (limits sentence). It reads as a full list, but in three more states git exits 128 and nothing is named (measured):
    • a gitlink path with a control character, e.g. vendor/a<TAB>b. UNWRITABLE_IN_A_PLAN (:588) covers \x00-\x1f and \x7f, not only bytes that are not UTF-8;
    • a directory at .gitmodules. git reads nothing there and does not fall back to HEAD. b74efd6 named this state;
    • an unmerged .gitmodules (tracked branch). The PR body lists it; the docstring does not.
  Fix: "whose path is not UTF-8 or holds a control character (UNWRITABLE_IN_A_PLAN); beside a .gitmodules that is unmerged; and beside a .gitmodules git refuses, or one on this disk that is not a regular file: a link, which git reads through, or a directory". Or return set() for a directory in registered_unindexed, as git does.

Held

Lane 3's plan:

Lane: openreposhape-3 (openRepoShape-3)

…its 128 and the doctor names nothing, and says what `git add .gitmodules` would do in the #1093 state, and a test holds HEAD's copy read up to its first bad line (#1080)

Review round 2 on #1092 (lane 3's delta review RV1092d, READY with five
NITs). No code behaviour changes: shape-doctor.py's docstrings only, and
one test case.

N-1. The module docstring's limits sentence now lists every state in
which `git submodule status` exits 128 and the doctor names nothing: the
#1093 state; a submodule whose path is not UTF-8 or holds a control
character (`UNWRITABLE_IN_A_PLAN`); beside a `.gitmodules` that is
unmerged; and beside a `.gitmodules` git refuses, or one on this disk
that is not a regular file: a link, which git reads through, or a
directory. `unregistered_gitlinks`' docstring says the same of the
files the doctor cannot read as git does.

N-2. In the #1093 state the removal typed as printed works, so "neither
printed way out is true until the file is restored" overstated. Both
docstrings now say that `git add .gitmodules` would stage the file's
deletion, and that no section can be written in it until it is restored.

N-3. A case in `test_a_gitlink_no_section_registers_is_said` holds
HEAD's copy read up to its first bad line, as git reads it: HEAD's copy
is another section, a bad line, and then the gitlink's section; git
exits 128 and the gitlink is named. The mutant that reads HEAD's copy
whole (`partial=False` in `registered_unindexed`) is killed by it.

N-4. `pinned_at_its_head` takes the digest from `repo_shape.tree_digest`,
the `sorted-ls-tree-r-v1` definition itself, and no longer builds the
records and the digest itself; `import hashlib` goes. The digest is the
one the helper built, for each leg the in-head cases make.

Every string literal in shape-doctor.py outside a docstring is the one it
was (`ast`, docstrings left out: the dumps are identical).

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-21T05:15:46Z) Independent delta check RV1092e of #1092 at 83627b2 (fix round 2 on a7169b0): READY.

All five RV1092d NITs are fixed as asked, and nothing else changed (2 files, +19/-16):

Held

NIT (optional)

  • shape-doctor.py:268 and :8799: "beside a .gitmodules that is unmerged" is broader than the doctor. With .gitmodules unmerged and its disk copy resolved to another path's section only, git exits 128 and the doctor names code/vendor/lib under other_sections, agreeing with git. It names nothing only while the disk copy is the conflict (git refuses it) or registers the submodule. If wanted: "beside a .gitmodules that is unmerged, while its copy on this disk is the conflict or registers the submodule".

Lane 3's plan: this head lands as it is. The optional NIT goes with #1093, which rewrites the same limits sentence. The pilot (key 1092a, the squash files at lane 3's land/1092-*) is chained behind #1095's and starts once #1095 has LANDED, so package on main's push run runs on ubuntu-24.04. It waits for tests on this head, gates the predicted squash over main, then takes the HOLD.

Lane: openreposhape-3 (openRepoShape-3)

brettheap pushed a commit that referenced this pull request Oct 21, 2026
…04 that `ubuntu-latest` now gives out runs the suite out of disk under pytest's basetemp (#1096)

#1092's `tests` job ran on an `ubuntu-26.04` runner (job 119050702775, image
version 20261012.163.1) and ended `2436 failed, 11382 passed, 380 skipped,
5579 errors in 3048.23s`, with 28325 lines of `[Errno 122] Disk quota
exceeded`, and later `No space left on device`, all under pytest's basetemp,
`/tmp/pytest-of-runner/pytest-0`. It is the image move of #1094, and the job
passed on 24.04, so it is pinned there until the suite runs on 26.04, which
#1096 tracks.

.github/workflows/tests.yml: `tests` is on `ubuntu-24.04`, with a comment
that gives the job, its counts and the quota errors and says it stays until
#1096. The `package` comment said "`tests` here asks for `3.x` ... on
ubuntu-latest", which this makes false; it now says that `tests` is on
ubuntu-24.04 too, for its own reason, that `tests-git-bash` runs on
windows-latest, and that release.yml's `build` asks for `3.12` on
ubuntu-latest, which 26.04 has. The 3.9 reason is as it was.

tests/test_release_workflow.py: a new test holds `tests` on ubuntu-24.04, and
its docstring names #1096 and the quota errors. The pairs
`setup_python_pairs()` yields are the same but for `tests`, from
`ubuntu-latest` to `ubuntu-24.04`, and the allow-list test passes as before.

tests/test_doctor_legs_members_next.py: the docstring said `tests.yml` runs
the suite on ubuntu-latest alone; it says ubuntu-24.04.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 21, 2026
… on `ubuntu-24.04`, since the Ubuntu 26.04 that `ubuntu-latest` now gives out has no 3.9 for setup-python and runs out of `/tmp` under the suite (#1094) (#1095)

`ubuntu-latest` has started giving out Ubuntu 26.04 runners, and two of
this repository's jobs cannot run there.

setup-python's manifest has no Python 3.9 build for 26.04: it lists only
3.10 to 3.15 there. The `package` job of `tests.yml` asks for 3.9, the
floor that `requires-python` names, and it failed in setup-python
wherever it landed on a 26.04 runner, first on #1092's run at 04:35:25Z.
The release's install-check row for 3.9 would fail the same way. Both
now run on `ubuntu-24.04`, whose manifest has 3.9.

The full suite runs out of `/tmp` on a 26.04 runner. #1092's `tests` job
there ended with 2436 failed and 5579 errors, nearly all of them
`Disk quota exceeded` errors under pytest's basetemp in `/tmp`. This
PR's own run at an earlier head failed the same way. The `tests` job now
runs on `ubuntu-24.04` too, where the suite passed, until it can run on
26.04 (follow-up #1096).

Each pin has a comment saying why. `tests-git-bash` stays on
`windows-latest`, and release.yml's `build` asks for `3.12` on
`ubuntu-latest`, which 26.04 has. The release row's job name becomes
`install check (ubuntu-24.04, Python 3.9)`, and nothing else reads that
name.

`tests/test_release_workflow.py` pins all three runners. A new test
reads every setup-python step of both workflows, with each matrix
`include` row expanded. On `ubuntu-latest` it allows only `3.x` and
`3.12`, and it requires every 3.9 to run on `ubuntu-24.04`. A matrix of
any other form fails the test, and so does a runner or Python it cannot
read.

`requires-python` stays `>=3.9`.

Closes #1094

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

Copy link
Copy Markdown

@brettheap
brettheap marked this pull request as ready for review October 21, 2026 09:33
@sourcery-ai

sourcery-ai Bot commented Oct 21, 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 9 hours and 46 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap
brettheap merged commit 2a119f2 into main Oct 21, 2026
5 checks passed
@brettheap
brettheap deleted the l3-1080-unregistered-gitlinks branch October 21, 2026 09:33
brettheap added a commit that referenced this pull request Oct 21, 2026
… fetch that carry it run in bootstrap.py's `untraced()` environment, so `make siblings` and `family.py siblings` write the token to no trace the caller asks git for (#1055) (#1075)

`make siblings` and `family.py siblings` now write the token in `SHAPE_LEGS_TOKEN` or `SHAPE_LEGS_APP_TOKEN` to no trace the caller asked git for. #996 (#1045) did the same for `make bootstrap`.

Lane 5 found this in its independent review of #1045 (rv996, #1045 issuecomment-6288783192) and measured it in #1055.

`templates/family-root/scripts/siblings.py` gives the credential's `-c url.https://x-access-token:<token>@github.com/.insteadOf=...` rewrite, in argv, to two gits:
- the clone in `place`, for a member with no clone beside the holder;
- the fetch in `place`, for a member whose clone is there.

Both ran in the caller's own environment. So every trace2 target the caller named wrote the token in the `start` event of each, one line per member. `scrub` kept it out of the output, and only that part held.

Now:
- **`rewrite_environment(token)`**, a new function in `siblings.py`:
  - with a token in use, it returns `untraced()` from the `bootstrap.py` beside it. That is every `GIT_TRACE*` variable taken out, and `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` set to `0`, which also turns off a target the global or the system config names;
  - with no token, it returns None, so the two gits keep the caller's environment and stay traced where the caller asked, as before.
- **The clone and the fetch** each run with `env=rewrite_environment(token)`. Nothing else in the file changes environment.
- **The member's own `scripts/bootstrap.py`**, which `place` runs after a clone, is given no token in its argv, and no `env`, so it keeps the caller's environment and its traces. That environment holds `SHAPE_LEGS_TOKEN` and `SHAPE_LEGS_APP_TOKEN` as before; the shipped member bootstrap (`templates/assembly-root/scripts/bootstrap.py`) reads neither. A test row holds this, and so does `tests/test_validator_child_encoding.py`, which reads that call as handing no `env`.
- **#1023's jobserver handling is untouched**: `dispatch` still runs each member's make with `member_make_environment(make)`, and no `MAKEFLAGS` reading changed.
- **The docstrings** now say the promise in full: `git_prefix` says the token is "never written into `.git/config`, never persisted, never printed, and never traced". `place`'s ABSENT paragraph in the module docstring says the same, and `rewrite_environment` says what was measured.

**One thing the issue did not ask for.** `GIT_TRACE_PERFORMANCE` writes a git's whole command line, `-c` included, on git 2.43.0 and 2.47.1 alike. So at main it held the token too, two lines per run, and the rows below set it. `untraced()` already takes it out: every `GIT_TRACE*` variable goes.

This follows #1045's `untraced()` (#996): the clone and the fetch run in the very environment `make bootstrap` gives every git it hands the token.

`siblings.py` imports `untraced` from the `bootstrap.py` beside it, next to `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path` and `members_of`.

The issue left this to the writer, because a holder may keep a `bootstrap.py` older than its `siblings.py` (`update-shape.py apply --accept-local`), and an import of a name that copy lacks would stop `make siblings`, `park` and `resume` with an `ImportError`. The issue's text was written against main `1a47ed07`, where `siblings.py` still kept its own copy of the `MAKEFLAGS` readings. Since then #1023 (`9e154d87`, lane 4's) has decided the question for this pair of files:
- it replaced that copy with an import of `member_make_environment`, because a copy in each script failed SonarCloud's duplication gate;
- it named the price, in the import's comment and its commit: a holder that keeps its own `bootstrap.py` while `apply` writes `siblings.py` is refused, `update-accept-local-missing-name` (#536, #604), before a byte is written, and merges `bootstrap.py` by hand first.

`untraced` takes the same road for the same reasons:
- **One rule.** Which traces a command carrying the token is kept out of has one answer, for `make bootstrap` and `make siblings` alike. A copy here could drift from `bootstrap.py`'s, and the drift would be a token in a file.
- **A kept old `bootstrap.py` is refused in the one-step case, and not in a two-step one.** The guard reads names. `untraced` is new with #996 (#1045), so a kept `bootstrap.py` from before it is refused by that name, where `--accept-local` names it in the same `apply`. Measured with `update-shape.py`'s own readers, in process:
  - `siblings.py` at this head takes `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap`;
  - the holder's `bootstrap.py` at main `00f6d7f7` lacks none of them;
  - the holder's `bootstrap.py` at `6476c170` (main just before #1045) lacks `untraced` alone, which is the line the refusal names.
- **The one-step case, measured end to end (rv1075).** A holder cut at `6476c170`, and `apply --at c3cf515 --accept-local scripts/bootstrap.py`: exit 2, `scripts/bootstrap.py (kept) lacks untraced, which scripts/siblings.py imports from it`, and the holder's files, HEAD and status unchanged.
- **Two applies in a row are not refused (rv1075's SHOULD 1, filed as #1082).** A holder whose earlier apply kept a `bootstrap.py` without `untraced` (rv1075 applied `--at 00f6d7f --accept-local scripts/bootstrap.py`: that target's `siblings.py` imports no `untraced`, so there was nothing to refuse), and then an apply at this head without `--accept-local`, gets `siblings.py` copied. `scripts/siblings.py` then fails with `ImportError: cannot import name 'untraced' from 'bootstrap'`, and so do `make siblings`, `park` and `resume`. The cause is the guard in `update-shape.py` (`:4288-4289`): it checks only the files `--accept-local` names in the same run. That is the gap of #536's guard, and it is on main already for #1023's `member_make_environment`; this change puts `untraced` into it and does not widen it. The text above this paragraph said no `ImportError` could reach a holder, which was too strong; #1082 is the follow-up, and it is not done here.
- **No copy, so no test to hold two copies to one answer.** `tests/test_update_shape_kept_helper.py`'s `test_what_siblings_takes_from_the_holders_bootstrap_is_found_defined` already holds that every name `siblings.py` takes is defined in the holder's `bootstrap.py`. Its docstring now says six names, not five.

**Setup:**
- git 2.43.0 (`/usr/bin/git`) and git 2.47.1, Python 3.12.3;
- the family of `tests/test_family.py` (two members, local bare remotes, the file transport allowed through `GIT_CONFIG_COUNT`), laid out as `<tmp>/InkRouter/InkRouter`;
- offline: a stub `gh` first on PATH, a bogus `GH_TOKEN`, an empty `GH_CONFIG_DIR`, `GIT_SSH_COMMAND=false`;
- the holder's own `scripts/siblings.py` was main's (sha256 `86bf57e0...`) or this change's;
- the fake token `w1055-a-token-no-forge-takes`.

**Each case was run three ways:**
- `make siblings`, which clones both members;
- `make siblings` again, which fetches both;
- `family.py siblings --family-root <holder>` in a fresh layout, which clones both.

**Traces were asked for in one of three ways:**
- the seven variables `GIT_TRACE`, `GIT_TRACE2`, `GIT_TRACE2_EVENT`, `GIT_TRACE2_PERF`, `GIT_TRACE_PACKET`, `GIT_TRACE_CURL` and `GIT_TRACE_PERFORMANCE`, each set to a file under the run's TMPDIR;
- `trace2.eventTarget`, `normalTarget` and `perfTarget` in a scratch `GIT_CONFIG_GLOBAL`;
- the same three in a scratch `GIT_CONFIG_SYSTEM`.

Each way was run with `SHAPE_LEGS_TOKEN`, with `SHAPE_LEGS_APP_TOKEN`, and with no token.

That is 108 runs at main and at the change, 54 for each git. Each run's TMPDIR and the whole family folder (the clones' `.git/config` included) were searched for the token.

| traces asked by | main, a token set (both variables, both gits) | this change, a token set | no token, either |
|---|---|---|---|
| the seven variables | `trace2-event.json`, `trace2-normal.txt`, `trace2-perf.txt`, `trace-performance.txt`: 2 lines each, in the clone, the fetch and `family.py` alike | none | no token anywhere; the same files written |
| the global config | `cfg-event.json`, `cfg-normal.txt`, `cfg-perf.txt`: 2 lines each | none | the same |
| the system config | `sys-event.json`, `sys-normal.txt`, `sys-perf.txt`: 2 lines each | none | the same |

**What the table rests on:**
- Every one of the 108 runs (and the 54 runs again on this head's final bytes, sha256 `b1e7bb21...`) exited 0 and ended `siblings ok: 2 member(s) beside the holder in ...`, with the token in neither stdout nor stderr.
- The 2 lines in each file are the `start` (or `performance:`) line of the git given the `-c`, one for each member. Each carries the token twice, once for each spelling the rewrite covers.
- With every member a local path, `GIT_TRACE`'s file was written and held no token, and `GIT_TRACE_CURL` wrote nothing, with no http remote. An https member is a different case, measured below.
- With the change and a token set:
  - every trace file but `GIT_TRACE_PACKET`'s was still written, by the run's other gits and the member's own bootstrap;
  - the packet trace was not written, since only the clone and the fetch write one here.

**An https remote, measured offline (rv1075, then the row of Round 2).** The rewrite reaches only `github.com`, and the probes above are offline, with local paths. A member whose `.gitmodules` url is `https://github.com/<org>/<repo>.git` is reached by it, and can be tried offline behind a dead proxy, `http.proxy=http://127.0.0.1:9`, so that nothing leaves the host:
- rv1075 set one member's url so, with `make siblings`, a fake `SHAPE_LEGS_TOKEN`, and the seven variables set to files, on git 2.43.0 and 2.47.1. Main's `siblings.py` (`86bf57e0`) held the token in `trace.txt`, `trace2-event.json`, `trace2-normal.txt`, `trace2-perf.txt` and `trace-performance.txt`; this head held it in no file and no output. The clone failed cleanly, `git clone exited 128`, and `make siblings` exited 2.
- `GIT_TRACE`'s file holds it too, in the `run_command: git remote-https origin https://x-access-token:<token>@github.com/...` lines: 3 lines on git 2.43.0, 4 on 2.47.1 (rv1075).
- Round 2 below holds this in a test row.

**Still not measured:** a github.com clone or fetch that succeeds, since no run here reaches the network.

`tests/test_family_siblings_untraced.py` is new. It uses `tests/test_family.py`'s `family` and `estate`, `offline` from `tests/test_family_add_refusals.py`, `files_holding` and `CREDENTIALS` from `tests/test_family_bootstrap_stops_named.py`, and `NO_CREDENTIAL` and `needs_make` from `tests/test_siblings_family_name.py`. No assert joins conditions with `and`. The token is the fake `w1055-a-token-no-forge-takes`.

**The rows.** Each entry point is crossed with each way traces are asked for:
- entry points: `make siblings` cloning, `make siblings` again fetching (after a first run with no token and no trace), and `family.py siblings` cloning;
- ways: the seven variables, the three targets in a scratch global config (a copy of the session's, which keeps git's automatic maintenance off), and the three targets in a scratch system config.

**Three tests** (the third is round 2's):
- **`test_with_a_token_in_use_the_token_is_in_no_trace`**: 18 rows (3 entries x 3 ways x 2 variables). Each asserts:
  - exit 0 and `siblings ok: 2 member(s) ...`;
  - the token in neither stdout nor stderr;
  - the token in no file under the test's directory: TMPDIR, every trace and config file, and the clones and their `.git/config`;
  - every trace file the way asks for written: 5 for the variables, 3 for each config;
  - the member's own bootstrap traced where a member was cloned: a git traced in a leg of each clone, which only that bootstrap runs. Where nothing was cloned, nothing ran there.
- **`test_with_no_token_the_clone_and_the_fetch_are_traced_as_asked`**: 9 controls (3 entries x 3 ways). Each asserts exit 0, `siblings ok` and the same trace files written. It also asserts the trace2 event file holds `siblings.py`'s own git at each member, read from the events: `git clone -q --no-local ...` to each member's slot, or `git fetch --quiet origin` in each member's clone.

**Fails on main.** The file was run against main's `siblings.py` (sha256 `86bf57e0...`, put in place and then restored, sha256 checked):
- **18 failed, 9 passed**: every row with a token failed, and the 9 controls passed;
- the variables rows found the token in `trace-performance.txt`, `trace2-event.json`, `trace2-normal.txt` and `trace2-perf.txt`;
- the global-config rows found it in `cfg-event.json`, `cfg-normal.txt` and `cfg-perf.txt`;
- the system-config rows found it in `sys-event.json`, `sys-normal.txt` and `sys-perf.txt`.

- **`test_an_https_member_with_a_token_in_use_is_in_no_trace`**: 3 rows, one for each of `CALLERS_CONFIG` (round 2 and round 3, below).

Each mutant was applied alone to the worktree's file and the new test file run (git 2.43.0). The bytes were restored and checked by sha256 after each:
- `siblings.py` sha256 `b776c69b...`: this head's code; it differs from the head only in two docstring lines;
- `bootstrap.py` sha256 `50a16751...`, main's, unchanged here.

All are **killed**.

| mutant | result |
|---|---|
| M1 the token condition dropped: `untraced()`'s environment always | killed, 9 failed (every control) |
| M1b the condition inverted | killed, 27 failed |
| M1c the clone untraced always, the fetch as it is | killed, 6 failed (the clone controls) |
| M2 the clone left in the caller's environment | killed, 12 failed (every clone row with a token) |
| M3 the fetch left in the caller's environment | killed, 6 failed (every fetch row with a token) |
| M4a `GIT_TRACE2_EVENT` not set to `0` (`TRACE2_OFF`) | killed, 12 failed (the global- and system-config rows) |
| M4b `GIT_TRACE2` not set to `0` | killed, 12 failed (the same) |
| M4c `GIT_TRACE2_PERF` not set to `0` | killed, 12 failed (the same) |
| M5 the `GIT_TRACE*` removal dropped (`env = dict(os.environ)`) | killed, 6 failed (the variables rows, by `GIT_TRACE_PERFORMANCE`) |
| M6 the prefix narrowed to `GIT_TRACE2` | killed, 6 failed (the same) |
| M7 the member's own bootstrap run untraced too | killed, 12 failed (the clone rows' bootstrap check) |

M5 and M6 are killed only because the variables rows set `GIT_TRACE_PERFORMANCE`. With a local-path remote, of the issue's six variables none but trace2's writes the `-c`, and trace2's three are set to `0` whatever the prefix does. With an https remote `GIT_TRACE` writes it too, in the `run_command: git remote-https` lines; that is what the row of Round 2 holds, and the mutant R4 below, which keeps the caller's `GIT_TRACE`, passed all 27 earlier rows.

**How every run was made:**
- through lane 5's runner: `/usr/bin/python3 -m pytest -q -rs -p no:cacheprovider -o tmp_path_retention_policy=failed`, one basetemp per run;
- `LANES_*` cleared, a stub `gh` first on PATH, `GH_TOKEN=bogus`, an empty `GH_CONFIG_DIR`;
- `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null` at the start of the session.

| git | files | result |
|---|---|---|
| 2.43.0 | `test_family_siblings_untraced.py`, on this change before the docstring fix | 27 passed |
| 2.43.0 | the same file against main's `siblings.py` | 18 failed, 9 passed |
| 2.43.0 | gate shard A: 19 of the 41 files below | 1623 passed, 4 skipped, in 19:51 |
| 2.43.0 | gate shard B: the other 22 of the 41 files | 2415 passed, 8 skipped, in 15:03 |
| 2.47.1 | `test_family_siblings_untraced.py` | 27 passed, in 1:14 |

**What the 41 gate files are:**
- the new file;
- `test_family_member_make_jobserver.py`;
- every file `grep -l siblings tests/` names;
- the pinned-copy and upgrade-row files: `test_pinned_copy_text.py`, `test_update_shape.py`, `test_update_shape_add.py`, `test_update_shape_kept_helper.py`, `test_no_auto_maintenance.py`, `test_template_text_placeholders.py`, `test_bootstrap_spelling.py`;
- the trio: `test_repo_hygiene.py`, `test_agents_md_codes.py` and `test_cli_reference.py`.

**The gate runs** (both shards at once, then the 2.47.1 run) were made on this change as it is committed: `siblings.py` sha256 `b1e7bb21...` and the new test file sha256 `7ba7a416...`, at main `00f6d7f7` (this branch's base; no rebase was needed, see Merges). The runner's `START` line read `dirty=2`, the two tracked files this commit changes, and a sha256 check after the runs found `siblings.py` unchanged (neither it nor the test files were modified after the runs started). The first pair of shards was stopped part way and re-run whole; only the re-runs count here.

**The 41 files, by shard:**
- shard A (19): `test_shape_doctor.py`, `test_validator_child_encoding.py`, `test_family_py_unreadable.py`, `test_pinned_copy_text.py`, `test_siblings_inputs.py`, `test_siblings_family_name.py`, `test_family_member_make_jobserver.py`, `test_update_shape_add.py`, `test_adopt_printed_sentences.py`, `test_family_printed_quoting.py`, `test_family_makefile_siblings.py`, `test_template_text_placeholders.py`, `test_park_resume_targets.py`, `test_windows_paths.py`, `test_doctor_gitmodules_not_utf8.py`, `test_local_remote_dir_urls.py`, `test_family_siblings_untraced.py`, `test_setup_family_folder.py`, `test_bootstrap_refusal_exit.py`;
- shard B (22): `test_repo_hygiene.py`, `test_doctor_legs_members_next.py`, `test_update_shape.py`, `test_family.py`, `test_update_shape_kept_helper.py`, `test_yaml_reader_spaces.py`, `test_bootstrap_spelling.py`, `test_agents_md_codes.py`, `test_family_siblings_placement.py`, `test_no_auto_maintenance.py`, `test_cli_reference.py`, `test_family_siblings_bootstrap_unreadable.py`, `test_bootstrap_branch_bytes.py`, `test_family_init_asks_every_value.py`, `test_validate_workflow_gitmodules_urls.py`, `test_adopt_contracts_dir.py`, `test_doctor_unfinished_merge.py`, `test_family_members_not_a_list.py`, `test_family_init_landing.py`, `test_family_docs_one_spelling.py`, `test_doctor_members_spelled.py`, `test_pins_child_kind_and_siblings_root_spelling.py`.

**The skips are the environment's, each named by its reason:**
- shard A: 3 in `test_shape_doctor.py` (the PowerShell line is printed, and `powershell.exe` exists, only on a Windows host) and 1 in `test_family_member_make_jobserver.py` (`make -j2` here writes `--jobserver-auth=3,4`, not GNU make 4.4's named pipe);
- shard B: 2 in `test_doctor_legs_members_next.py` (this git crashes on `submodule init`, exit 139, as git 2.43 does on a bare `branch`), 4 in the same file (this git dies on a `shallow` of -2**31, as git 2.43 does), and 2 in `test_no_auto_maintenance.py` (the session began with `GIT_CONFIG_GLOBAL` set, and its file includes that one);
- the 2.47.1 run of the new file skipped none.

The full suite was not run.

**Static checks:**
- flake8 finds in `siblings.py` only the three lines it finds at main (two E501, one E127, each moved down by the added lines);
- in the new test file it finds only the F811s every fixture-importing file has;
- both files are ASCII, apart from the dashes and the CJK example already in `siblings.py`.

Fetched with `-c gc.auto=0 -c maintenance.auto=false`, on 2026-10-20 at about 21:03Z. Round 1, `git merge-tree --write-tree` of the first head `c3cf5154` against (round 2's are in its own section):
- **origin/main `6c861635`**: clean, exit 0, tree `225b2a99`.
  - Main has moved two commits past this branch's base `00f6d7f7`: `9cbba9d5` (#1063: `scripts/family.py`, `scripts/shape_materialize.py`, `docs/cli.md` and two tests) and `6c861635` (#1065: `adopt-project.py` and three tests).
  - Neither touches `siblings.py`, `bootstrap.py`, `tests/test_update_shape_kept_helper.py` or the new test file, so there was no rebase and the gate runs above stand.
- **#1062** (`fix/1040-windows-start-errors`, head `957ba038`), the one open PR that touches a family-root script this change reads (`templates/family-root/scripts/bootstrap.py`, with the assembly root's and `validate-family.py`, `validate-pins.py` and three tests): clean, exit 0, tree `84d4b54a`.
  - Its diff does not touch `untraced()`.
  - In the merged tree `bootstrap.py` still defines `untraced` (and the five other names `siblings.py` takes from it), and `siblings.py` is byte-identical to this head's (sha256 `b1e7bb21...`).
- **The other 9 open PRs** (#1073, #1072, #1071, #1070, #1069, #1068, #1067, #1064, #1031): none touches `siblings.py`, `tests/test_update_shape_kept_helper.py`, or a file the new test imports from (`conftest.py`, `test_family.py`, `test_family_add_refusals.py`, `test_family_bootstrap_stops_named.py`, `test_siblings_family_name.py`), read from `gh pr list --state open --json number,headRefName,headRefOid,files`.

`templates/family-root/scripts/siblings.py` is in `FAMILY_COPIED_VERBATIM`, so each holder gets one `upstream-changed` row for it, as #1045 gave one for `bootstrap.py`:
- a holder that takes it with `bootstrap.py` from #1045 or later needs nothing more;
- a holder that keeps an older `bootstrap.py` with `--accept-local` in the same apply is refused, `update-accept-local-missing-name`, naming `untraced`, before anything is written;
- a holder whose earlier apply kept a `bootstrap.py` without `untraced`, and that then applies this change without `--accept-local`, is not refused: `siblings.py` is copied, and `make siblings`, `park` and `resume` meet an `ImportError`. That is the gap of #536's guard, which `member_make_environment` has on main already, and it is #1082's.

No Windows or macOS run was made, since CI is Linux only (#597).

rv1075, lane 5's independent reviewer, found `c3cf5154` READY (#1075 issuecomment-6302090874): no MUST, two SHOULDs and a NIT. One commit on top of it, `041f7831`, with no rebase: main has touched none of this PR's files.

**The three findings:**
- **SHOULD 1** (the body said no `ImportError` reaches a holder, and two applies in a row make one): answered in the body above, in "Where the helper comes from" and "The pinned copy". The gap is #536's guard's, it is on main for `member_make_environment` already, and it is #1082's; this PR does not change `update-shape.py`.
- **SHOULD 2** (every row used a local file remote, so a leak through `GIT_TRACE` on an https member had no test, and rv1075's mutant R4 passed all 27 rows): answered by the new row below.
- **NIT** (three places said the member's own bootstrap "is given no token"): `siblings.py`'s module docstring, `rewrite_environment`'s docstring and the test file's docstring now say it is given no token in its argv. It inherits `SHAPE_LEGS_TOKEN` and `SHAPE_LEGS_APP_TOKEN` in its environment, because `run_member_bootstrap` passes no `env`, and the shipped one reads neither. `rewrite_environment`'s docstring also names the https measurement.

**The code of `siblings.py` is unchanged.** Its syntax tree with the docstrings taken out is the same at `c3cf5154` and at `041f7831` (compared with `ast`); the file's sha256 moved from `b1e7bb21...` to `19107ad4...` over comments and docstrings only. So round 1's two gate shards (1623 passed, 4 skipped; 2415 passed, 8 skipped) were not re-run; the targeted runs below are.

**The new row,** `test_an_https_member_with_a_token_in_use_is_in_no_trace` (the file has 28 rows now). Its setup:
- the estate of `tests/test_family.py`, with `IRRS`'s url in the holder's `.gitmodules` given as `https://github.com/InkRouter/IRRS.git`, and `IRSS` still a local path;
- `http.proxy=http://127.0.0.1:9`, a dead proxy, in git's command scope after the entries the session holds, and `NO_PROXY` and `no_proxy` empty, so nothing leaves the host;
- a fake `SHAPE_LEGS_TOKEN` and the seven trace variables set to files;
- `make siblings`, cloning.

It asserts:
- the proxy is set where the row sets it: `http.proxy=http://127.0.0.1:9` is in the command scope the run's git reads (`command_scope_at`, which runs `git config --show-origin --list` and writes nothing);
- exit 2 and `IRRS: git clone exited 128:` on stderr, which is how the head fails (rv1075 measured the same);
- `IRSS` was cloned and `IRRS` was not;
- the token is in neither stdout nor stderr, and in no file under the test's directory;
- the five trace files the variables ask for are written, by the run's other gits.

No assert names the proxy in git's message. The git 2.47.1 this lane builds has no `git-remote-https` (`git: 'remote-https' is not a git command`, `fatal: remote helper 'https' aborted session`), so there the clone fails at once without reaching the proxy, still with 128. `GIT_TRACE` has written the `run_command: git remote-https origin https://x-access-token:<token>@github.com/...` line before that, so the row finds the leak on both gits. A first local version asserted the proxy's message and failed on 2.47.1 for that reason; the assert was dropped, and the commit amended, before anything was pushed.

**It fails on main.** The file was run against main's `siblings.py` (sha256 `86bf57e0...`, put in place and then restored, sha256 checked each time):
- git 2.43.0, the whole file: **19 failed, 9 passed**, the 18 rows of round 1 and the new row. The new row fails at `assert files_holding(tmp_path, TOKEN) == []`, with 5 files holding the token: `temp/trace-performance.txt`, `temp/trace.txt`, `temp/trace2-event.json`, `temp/trace2-normal.txt` and `temp/trace2-perf.txt`. The round 1 rows find 4 of those, since a local path leaves `temp/trace.txt` clean;
- git 2.47.1, the row alone: **1 failed**, the same 5 files.

**It kills R4.** R4 is rv1075's mutant, in `rewrite_environment`: `return {**untraced(), **{k: v for k, v in os.environ.items() if k == "GIT_TRACE"}} if token else None`, which keeps the caller's `GIT_TRACE` for the clone and the fetch. Applied alone, then restored and sha256-checked:
- git 2.43.0, the whole file: **1 failed, 27 passed**: the new row alone, with `temp/trace.txt` the one file holding the token;
- git 2.47.1, the row alone: **1 failed**, the same one file.

**The runs on the pushed head `041f7831`** (the runner's `START` line read `dirty=0`), through the same runner as round 1:

| git | files | result |
|---|---|---|
| 2.43.0 | the new file, `test_update_shape_kept_helper.py`, `test_family_makefile_siblings.py`, `test_family_member_make_jobserver.py`, `test_pinned_copy_text.py`, and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 589 passed, 1 skipped (the jobserver file's named-pipe row, as in round 1), in 5:09 |
| 2.47.1 (`git version 2.47.1`, printed from the exact path first on PATH) | the new file | 28 passed, in 2:13 |
| 2.43.0 | the new file against main's `siblings.py` | 19 failed, 9 passed |
| 2.47.1 | the new row against main's `siblings.py` | 1 failed |
| 2.43.0 | the new file with R4 | 1 failed, 27 passed |
| 2.47.1 | the new row with R4 | 1 failed |
| 2.43.0 | on a predicted merge with main (below): the new file, `test_update_shape_kept_helper.py`, `test_pinned_copy_text.py`, `test_family_makefile_siblings.py` | 305 passed, in 2:50 |

The full suite was not run. flake8 finds in `siblings.py` the same three lines as at round 1, and in the test file only F811s, from the fixtures it imports. Both files are ASCII, apart from the dashes and the CJK example already in `siblings.py`.

**The merges of round 2.** Fetched with `-c gc.auto=0 -c maintenance.auto=false` at about 23:00Z; `git merge-tree --write-tree` of `041f7831` against:
- **origin/main `c13c3985`** (#1076's squash, which touches `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py`, among others): clean, exit 0, tree `8ae10d0c`. None of the three files this PR changes is among the files main changed since the base `00f6d7f7`, so there is no rebase. In that tree `siblings.py` is byte-identical to this head's, `untraced()` has the same syntax tree as at the base, and `CREDENTIALS` and `files_holding`, which the new file imports from `test_family_bootstrap_stops_named.py`, are still defined. The 305-passed run above is on that tree;
- **#1062** (`fix/1040-windows-start-errors`, head `957ba038`): clean, exit 0, tree `e6ed1ba2`. Its `untraced()` has the same syntax tree as main's;
- **the other open PRs** (#1031, #1069, #1070, #1072, #1073, #1081, #1084): none touches `siblings.py`, `tests/test_update_shape_kept_helper.py`, `bootstrap.py`, or a file the new test imports from, read from `gh pr list --state open --json number,headRefName,headRefOid,files` at about 23:00Z. #1076 (`fix/1056-signal-anywhere`, head `73906948`) was open when the runs were made, and merged clean with this head then (tree `6ae3e65f`); it has landed since as `c13c3985`, above.

**The failure.** Lane 5's landing gate (log `c1075.log`, #1075 issuecomment-6304830183) failed the https row of round 2 at `041f7831`: 1 failed, 356 passed. The change in `siblings.py` was not at fault; the row depended on the caller's git config.
- The gate ran with neither `GIT_CONFIG_GLOBAL=/dev/null` nor `GIT_SSH_COMMAND=false`, which the writers' runs and round 2's runs had.
- `conftest.py`'s session global file includes the caller's global config (or `~/.gitconfig`, where `GIT_CONFIG_GLOBAL` is unset). The operator's `~/.gitconfig` has `url.git@github.com:.insteadOf=https://github.com/`.
- Git keeps the first of two `insteadOf` prefixes of the same length, and the config files are read before the command line, so that rewrite beat the token's `-c` rewrite of the same prefix. Measured with scratch files and a stand-in token, on git 2.43.0: under a global file with that rewrite, `git -c url.https://x-access-token:T@github.com/.insteadOf=https://github.com/ ls-remote --get-url https://github.com/InkRouter/IRRS.git` printed `git@github.com:InkRouter/IRRS.git`.
- So the row's `https://github.com/InkRouter/IRRS.git` went over ssh to `InkRouter/IRRS`, a real private repository. The dead proxy never applied, the clone succeeded, `make siblings` exited 0 and the placed members were both there, and the row failed `assert result.returncode == 2`.

**The fix** is in `tests/test_family_siblings_untraced.py` only. No other file changed in this round, and `siblings.py` is byte-identical to `041f7831`'s (sha256 `19107ad4...`).
- **`hermetic_config`** gives the https row's git a global file of its own, `session_global_config("")` (`conftest.py`'s own function: automatic maintenance off, and no include of the caller's file), `GIT_CONFIG_NOSYSTEM=1` and `GIT_SSH_COMMAND=false`.
  - It is the least invasive way: only the one row with an https url sets it, `conftest.py` and every other row stay as they were, and the other rows' remotes are local paths, which no rewrite of GitHub's url reaches.
  - `GIT_CONFIG_GLOBAL` set takes git's two user-level files out of the reading, and `GIT_CONFIG_NOSYSTEM` the system file whatever `GIT_CONFIG_SYSTEM` says (checked on git 2.43.0 and 2.47.1 with scratch files).
  - `GIT_SSH_COMMAND=false` is a second wall: a url somehow sent over ssh runs `false`. No mutant can show it needed, since the runner exports it too.
- **`url_after_config`** is a new assert before the clone: `git ls-remote --get-url <url>` (it applies the config's `insteadOf` and asks nothing of the network) must give the url unrewritten under the row's environment. A config that reaches the row now fails by name, before any clone.
- **The row is parametrized by `CALLERS_CONFIG`**: `none` (round 2's row), and `global` and `system`, each a stand-in for a caller's config that would send the url somewhere reachable. `as_the_callers_config` writes a scratch file of the test's own, `url.<file URI of the family's own IRRS remote>.insteadOf=https://github.com/InkRouter/IRRS.git`, into this process's `GIT_CONFIG_GLOBAL` (the session file includes it) or `GIT_CONFIG_SYSTEM`. It is built from tmp files only: no network, and never the real `~/.gitconfig`. The file now has 30 rows.
- With the fix, in each of the three, the clone fails at the proxy with git's 128, `make siblings` exits 2, `IRSS` is placed and `IRRS` is not, and the token is in no output and in no file under tmp_path.

**The variants fail without the fix.** Each was applied alone to the worktree's test file (or `siblings.py`) and run through the runner, with the file's sha256 checked after it was restored:
- **`041f7831`'s file as pushed, under a caller's global config.** Lane 5's runner now honours `L5_GIT_CONFIG_GLOBAL=<scratch file>` as the caller's global config, with ssh impossible. The scratch file rewrote `https://github.com/InkRouter/IRRS.git` to the run's own IRRS remote (a `file://` url under the run's basetemp). The whole file, git 2.43.0: **1 failed, 27 passed**, the https row at `assert 0 == 2`, the gate's failure reproduced with no network.
- **T2, `041f7831`'s row plus the variants:** the new file with `hermetic_config` and the `url_after_config` assert taken out. The https rows, git 2.43.0: **2 failed, 1 passed**, `[global]` and `[system]` at `assert 0 == 2` (the clone went to the local remote and exited 0), and `[none]` passes. On git 2.47.1 the same.
- **T1,** the row's global file dropped and the assert kept: `[global]` fails at the assert, which sees `file:///...IRRS.git` for the url; the other two pass.
- **T3,** `GIT_CONFIG_NOSYSTEM` dropped and the assert kept: `[system]` fails at the assert; the other two pass.

**R4 stays killed.** rv1075's R4 (`rewrite_environment` keeps the caller's `GIT_TRACE`), git 2.43.0, the whole file: **3 failed, 27 passed**, the three https rows.

**The runs on the pushed head `962ae43c`** (`START` read `dirty=0`), through the same runner as before:

| git | files | result |
|---|---|---|
| 2.43.0 | the new file, `test_update_shape_kept_helper.py`, and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) | 359 passed, in 3:05 |
| 2.47.1 (`git version 2.47.1`, printed from the exact path first on PATH) | the new file | 30 passed, in 1:51 |
| 2.43.0 | the new file, with `L5_GIT_CONFIG_GLOBAL` naming that scratch file | 30 passed, in 2:21 |
| 2.43.0 | `041f7831`'s file, with the same | 1 failed, 27 passed |
| 2.43.0 / 2.47.1 | T2, the https rows | 2 failed, 1 passed / 2 failed, 1 passed |
| 2.43.0 | T1, T3, the https rows | 1 failed, 2 passed each |
| 2.43.0 | R4, the whole file | 3 failed, 27 passed |

The full suite was not run. flake8 finds in the test file only F811s from the fixtures it imports, and nothing over 79 columns; the file is ASCII.

**The merge-tree of round 3.** Fetched with `-c gc.auto=0 -c maintenance.auto=false` at about 10:53Z; `git merge-tree --write-tree` of `962ae43c` against:
- **origin/main `2a119f28`** (#1092): clean, exit 0, tree `b5ea0114`. Main has landed #1076 (`c13c3985`) and #1062 (`ff0c72eb`) since the last round, and has changed `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py` among others, but none of this PR's three files. In that tree `siblings.py` is byte-identical to this head's, `untraced()` has the same syntax tree as at the base `00f6d7f7`, and `CREDENTIALS` and `files_holding` are still defined where the new file imports them from.
- **#1088** (`fix/1085-nit-texts`, head `88af1b7b`), which touches the same two files of main's: clean, exit 0, tree `365df092`, with the same findings.
- **The other open PRs** (#1087, #1090, #1091, #1097): none touches `siblings.py`, `bootstrap.py`, `tests/test_update_shape_kept_helper.py` or a file the new test imports from, read from `gh pr list --state open --json number,headRefName,headRefOid,files` at about 10:53Z.

Lane: openreposhape-5 (openRepoShape-5)

Review: rv1075, lane 5's independent reviewer, not the writers (w1055 and f1055): READY at `c3cf5154` (issuecomment-6302090874) with no MUST; its two SHOULDs and its NIT were taken in one new commit, and the re-check is READY at `041f7831` (issuecomment-6303900180), with no MUST or SHOULD; its one NIT, a merge-tree number in the body, is fixed in the body. CI (Brett Heap's ruling 27): H0 is the head 962ae43 itself, which passed tests, tests-git-bash and SonarCloud; main has touched none of its files since its base 00f6d7f; and the local gate passed on its predicted squash tree fc2afb3 over main 16f9a0e: the family_siblings_untraced, update_shape_kept_helper, repo_hygiene, agents_md_codes, cli_reference sets, 359 passed. Landed on Brett Heap's word of 2026-10-18T14:45:28Z, relayed to every lane by lane 3 (#473 issuecomment-6251255596): "merge v1.2.0 PRs once reviewed and green".

Closes #1055

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 21, 2026
…263572144, which concluded success, and no longer cites job 119180581846 as a pass although its actionlint step failed (#1096)

The comment said #1097's run on 26.04 (job 119180581846) passed. That job
concluded failure: its actionlint step failed on the unknown `ubuntu-26.04`
label, and only its `tests` step passed. It now cites round 3 (job
119263572144, `ubuntu-latest`, image ubuntu-26.04 20261012.163.1, the image
#1092 failed on), whose `df` lines show /tmp at 38M used before and after and
the runner's disk at 57G used before the suite and 83G after. Nothing but the
comment changes.

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

Labels

None yet

Projects

None yet

1 participant