Skip to content

the family root's bootstrap.py: git's update ended by a signal fails the run by name, and B4 with a token in use is told from fetches redone without a trace, so the token reaches no file (#998, #996) - #1045

Merged
brettheap merged 55 commits into
mainfrom
fix/996-998-update-stops
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #996 (comment)
Claim: #998 (comment)

Goal

Two stops of git's update in the family root's scripts/bootstrap.py still ended family bootstrap ok with the credential advice. Both were found by lane 5's review of #954 (issuecomment-6253384652):

Both now fail by name, with no advice. Each closes in its own commit:

Stacked on #954 (#892, lane 4's), at its head fe907c84, and lands after it. The branch is fe907c84 plus these two commits. Until #954 lands, GitHub's diff against main shows #954's changes too.

What changed

#998: a signal (da7b14f8)

#996: B4 with a token in use (b2bbb31c)

This follows the issue's "Direction (no trace2)".

  • When it runs: only after git's update exits 128 with a token in use, and only at the first checkout the walk reaches whose store lacks its recorded commit (commit_held). The walk ends there either way, so this costs two fetches at most.
  • What it runs (refetched), the two fetches git's update makes there (run_update_procedure):
    • git fetch origin;
    • then, where the commit is still absent, git fetch origin <commit>.
  • How each fetch runs:
    • with the same -c url...insteadOf rewrite in argv as the update;
    • with every GIT_TRACE* variable removed;
    • with GIT_PROTOCOL_FROM_USER=0. This matches what git submodule gives every git it starts. Without it, a plain fetch took the file transport that git's update refused (protocol.file.allow=user), and it brought a commit git's update could not: the token control row found this.
  • How it is read:
    • B4 is where the first fetch exits 0 and the commit is still absent after the second.
    • A first fetch that fails is the credential's skip, as now.
    • Where either fetch brings the commit, nothing is said (untold).
  • The token appears in no output and in no file:
  • The walk's interface: it now takes a Reached predicate (fetch_reached) in place of the trace's work-tree set. With no token, the predicate is the trace's answer, as before. With a token, it is refetched.
  • Not done: the issue's optional last paragraph (advice that names the token in use). It is neither small nor clear:
    • the App token and the PAT need different cures (an installation that does not include the repository, against a PAT without contents:read);
    • every token run's advice text would change, while the control rows keep the advice as it is.
    • It is better as its own issue.

Probes, before (#954's head) and after (this head)

Setup:

  • git 2.43.0 and Python 3.12.3;
  • the family fixture of tests/test_family.py, in the holder with every member checked out;
  • bootstrap.py --skip-members;
  • a stub gh, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null;
  • the token is the fake w892-a-token-no-forge-takes.
state before after
P3: IRRS's own .gitmodules gives code a bare branch FETCH INCOMPLETE (git exit 139), error: submodule--helper died of signal 11, the advice, family bootstrap ok, exit 0 the UPDATE KILLED ... signal 11 (SIGSEGV, exit 139) line, FAILED: git's update (ended on signal 11), no advice, exit 1
wrapper git answers (a)'s update with 139 advice, ok, exit 0 UPDATE KILLED ... (SIGSEGV, exit 139) ..., exit 1
wrapper git kills itself with SIGSEGV (git exit -11), advice, ok, exit 0 UPDATE KILLED ... (SIGSEGV, exit -11), which killed it., exit 1
wrapper git answers 128, every member at its pin advice, ok advice, ok (unchanged)
B4 at IRSS with SHAPE_LEGS_TOKEN, then with SHAPE_LEGS_APP_TOKEN; the caller sets GIT_TRACE2_EVENT and GIT_TRACE under TMPDIR git's own not our ref lines, the advice to set the token, ok, exit 0; the token is in trace2.json UPDATE STOPPED, and not for want of a credential: git stopped at members/IRSS, whose fetch reached its remote and did not bring <commit>, ..., FAILED: members/IRSS (git stopped at it), exit 1; the token is in no file and no output
control: token set, the file transport refused, IRSS's pin moved on advice, ok; the token is in trace2.json advice, ok; the token is in no file

Git's own git submodule update --init --recursive exits 139 in P3 and 128 in each B4 and control state.

Tests

All rows are in tests/test_family_bootstrap_stops_named.py, #954's file. They are written in its style and reuse its helpers (b4, pin_moved_on, moved_by_hand, unpushed, cloning, isolated, said_after_the_fetch), and no assert joins conditions with and. The file now has 24 tests and 37 rows (25 before).

#998:

#996:

  • test_with_a_credential_in_use_b4_is_named_and_the_token_is_not[SHAPE_LEGS_APP_TOKEN] and [SHAPE_LEGS_TOKEN]: this is test_with_a_credential_in_use_b4_is_read_as_before turned round. Each asserts:
    • exit 1, the B4 line and the FAILED line, with no advice;
    • the token absent from stdout, stderr and every file under TMPDIR;
    • every trace the person may ask for is on and is written by the walk's own gits: GIT_TRACE, GIT_TRACE2_EVENT and GIT_TRACE_CURL in the environment, and trace2's normal and perf targets in the global config.
  • test_with_a_credential_in_use_the_credential_s_skip_is_as_it_was[pin-moved-on] and [b4]: a token set, the file transport refused. Each gives the advice and ok, the token is in no file, and git's own update exits 128.
  • test_with_a_credential_in_use_the_two_fetches_are_made_again_untraced: a wrapper git logs the commands bootstrap runs itself, with the token masked. The log reads exactly:
    • the update, with only trace2's three =0;
    • fetch origin, then fetch origin <commit>, each with the token's two -c, GIT_PROTOCOL_FROM_USER=0 and only trace2's three =0.
      Every caller trace is on during this row too.
  • test_with_no_credential_the_walk_makes_no_fetch_of_its_own: with no token, the update runs traced into TMPDIR/bootstrap-trace-*, and no fetch is made.
  • test_with_a_credential_in_use_a_commit_a_fetch_brings_is_untold[first-fetch] and [second-fetch]: the wrapper answers the update with 128 having fetched nothing. Each gives the advice and ok.
    • [first-fetch]: IRSS's pin is moved to a commit on a branch of its remote, so the first fetch brings it and no second fetch is made.
    • [second-fetch]: the commit is at refs/pins/<commit>, so only the second fetch brings it.

Wrapper rows are skipped on Windows, where a #! script is not run.

A fix to #954's unpushed helper. It now numbers each commit it makes (MADE, a counter for the session). Lane 4's fe907c84 names the commit after the clone's path. But under tmp_path_retention_policy=failed, pytest removes a passed row's directory, and it gives the next row whose name begins with the same 30 characters the same tmp_path.

Fails at #954's head. The new file was run against bootstrap.py at 4f97fedb (sha256 8282da7a..., the same bytes at fe907c84):

bootstrap.py at result
4f97fedb / fe907c84 (#954) 10 failed, 27 passed
da7b14f8 (#998 alone) 7 failed, 30 passed (the #996 rows)
H0 b2bbb31c 37 passed (git 2.43.0, three runs); 36 passed, 1 skipped (git 2.47.1: P3)

Mutants

Each mutant was applied alone to b2bbb31c's bootstrap.py (sha256 e7372f13...; the landing head's file, sha256 50a16751..., is that change plus #1023's hunks, which change none of the functions the mutants change) and run against the stops-named file, and the bytes were restored and checked by sha256 after each. All are killed.

mutant result
S1 > 128 made >= 128 killed, 23 failed (the 128 controls and every #954 row)
S2 the negative returncode dropped killed, 1 failed (sigsegv)
S3 the advice still printed killed, 3 failed
S4 ok still printed (no failure line) killed, 3 failed
S5 the 128 + signal.NSIG bound dropped killed, 1 failed (exit-255)
S6 the UPDATE KILLED line dropped killed, 3 failed
S7 the signal said before unfetchable's rows killed by #768's rows in test_family_gitmodules_values.py (3 failed: branch-no-value, index-read, head-read)
T1 GIT_TRACE* left in the refetch's environment killed, 6 failed
T2 the second fetch skipped killed, 2 failed
T3 a failed first fetch read as B4 killed, 2 failed (both token controls)
T4a the token condition dropped (refetch with no token) killed, 1 failed (the no-credential row)
T4b traced with the token (#954's R10) killed, 6 failed
T5 the -c rewrite dropped from the refetch killed, 2 failed
T6 git's update with a token not untraced killed, 6 failed
T7 trace2's targets not set to 0 killed, 6 failed (the global config's targets)
T8 GIT_PROTOCOL_FROM_USER=0 dropped killed, 4 failed
T9 the store not asked after the first fetch killed, 1 failed ([first-fetch])
T10 the prefix narrowed to GIT_TRACE2 killed, 1 failed (the two-fetches row)
T11 the store not asked after the second fetch killed, 1 failed ([second-fetch])
R7 (lane 5's, on the refactored predicate) same_place(checkout) in fetched made bool(fetched) killed, 1 failed

Runs

All runs used /usr/bin/python3 -m pytest -q -p no:cacheprovider -o tmp_path_retention_policy=failed, with one reused basetemp, and this environment:

  • EMAIL=service@opensoft.dev;
  • a stub gh first on PATH, GH_TOKEN=bogus, an empty GH_CONFIG_DIR;
  • GIT_SSH_COMMAND=false, GIT_CONFIG_GLOBAL=/dev/null;
  • LANES_* and GIT_ALLOW_PROTOCOL unset.
git files result
2.43.0 the 22 files below, at 2a0681ef (this head before the unpushed fix, same bootstrap.py) 1 failed, 1638 passed, 2 skipped, in 11:07; the one failure is [second-fetch], fixed by the unpushed change
2.43.0 H0 b2bbb31c: the stops-named file + test_repo_hygiene.py, test_agents_md_codes.py, test_cli_reference.py, test_no_auto_maintenance.py 265 passed, 2 skipped
2.47.1 this head: test_family_bootstrap_stops_named.py 36 passed, 1 skipped (P3)
2.43.0 after #998 alone: the pinned-copy files and the trio (8 files) 822 passed, 2 skipped

The 22 files:

  • the stops-named file;
  • the two that grep -l 'family-root/scripts/bootstrap' tests/ names: test_family.py and test_update_shape_kept_helper.py;
  • every other file that runs the family root's bootstrap.py: test_family_holder_remedies_compose.py, test_family_holder_bootstrap_texts.py, test_family_bootstrap_gate_order.py, test_family_gitmodules_values.py, test_bootstrap_without_git.py, test_family_unnamed_gitlink.py, test_family_config_appends.py, test_family_member_make_jobserver.py, test_family_siblings_bootstrap_unreadable.py, test_family_head_lost_gitmodules.py, test_family_members_not_a_list.py and test_family_makefile_siblings.py;
  • the files that pin the file's text: test_pinned_copy_text.py, test_bootstrap_spelling.py, test_template_text_placeholders.py and test_no_auto_maintenance.py;
  • the trio: test_repo_hygiene.py, test_agents_md_codes.py and test_cli_reference.py.

Skips and scope:

  • The skips are the environment's: test_no_auto_maintenance.py skips 2 rows because the session began with GIT_CONFIG_GLOBAL set.
  • The full suite was not run.

Static checks:

  • flake8 finds in bootstrap.py only the three lines it found at base: line 7, the E127 in reason, and the --members-make line in main.
  • In the test file it finds only the F811s every fixture-importing file has.
  • Both files are ASCII apart from the em dash already in bootstrap.py, with no raw control characters.

Merges

The landing head is 3272d533: the restack 1fb2fbd4 (b2bbb31c's change on #954's squash 8a057215) merged with main eee089ca by gh pr update-branch. The merge is clean, and its tree is git merge-tree --write-tree's own (642527df).

The pinned copy

templates/family-root/scripts/bootstrap.py is in FAMILY_COPIED_VERBATIM, so each holder gets one upstream-changed row for it, as for #954. No Windows or macOS run was made, since CI is Linux only (#597).

Closes #996
Closes #998

🤖 Generated with Claude Code

Summary by Sourcery

Make family bootstrap accurately report Git update failures and credential-authenticated missing commits without exposing tokens.

Bug Fixes:

  • Fail family bootstrap when Git's update terminates because of a signal, reporting the signal and exit code without suggesting a credential problem.
  • Detect credential-authenticated B4 fetch failures by safely retrying the relevant fetches without tracing, preventing tokens from being exposed while naming the stopped member.

Enhancements:

  • Preserve existing behavior for ordinary Git exit codes and credential skips while distinguishing fetches that reach a remote from those blocked by missing credentials.

Tests:

  • Add coverage for signal-terminated updates, token-protected B4 cases, credential-skip controls, refetch behavior, trace suppression, and token non-disclosure.

Brett Heap and others added 30 commits October 15, 2026 22:59
…HEAD's `.gitmodules` gives is `member-gitmodules-absent`, so a holder whose HEAD lost the file no longer says `family ok`, `remove` refuses it before `deinit` instead of leaving the mount empty, and the gitlink remedies name a case variant and the commit `add` needs (#611)

571(b). In a holder whose HEAD lost `.gitmodules` (the commit #407
describes), `family.py remove --dry-run` exited 0, and the real run did
`git submodule deinit` and `git rm`, then died at its commit's `git
--literal-pathspecs add -f -- .gitmodules family.yaml` (`pathspec
'.gitmodules' did not match any files`); the rollback left `members/<P>`
empty. `_checked_gitmodules_held` now refuses that holder, before any git
command that writes and under `--dry-run` alike: no `.gitmodules` in the
work tree, the index or HEAD while the index records the member's gitlink.
Refused, not committed without the file, as #407 refuses a `.gitmodules`
the work tree lacks. The remedy writes back, with `git config -f
.gitmodules`, the section `family.py add` writes for each member whose
gitlink the index records, then `git add -f .gitmodules`; `remove` then
runs as it always has.

571(c), #407's decision 3. `validate-family.py` reads HEAD's `.gitmodules`
(`_head_gitmodules`, HEAD's root tree listed with no pathspec) and makes
`member-gitmodules-absent` for each row at `members/<project>` whose
gitlink no section gives, in every mode, `--no-members` included. Its two
lines write the section back as `family.py add` writes one, keeping the url
of a section of that name that has one. A member it names that is not
checked out is not refused `member-uninitialized`: that refusal named a `git
submodule update` that exits 0 there and dropped the finding, which is what
a fresh clone and CI met. It reads HEAD, not the work tree, so a deletion
nobody committed, an unstaged edit and a sparse checkout that leaves the
file out are no finding, and a holder with no members is asked nothing.

574/2 (#363's item 3). A row whose gitlink HEAD records only at a case
variant of its path (`Members/<P>`) is refused `member-gitlink-absent`
naming each variant, and the move that puts it back in the index alone:
`git rm -r --cached` of the variants and `git update-index --add
--cacheinfo 160000,<pin>,members/<P>`, then a commit. `family.py add`, the
plain refusal's remedy, cannot help there: the row exists.

574/3. A hand mount's `member-gitlink-unnamed` remedy says to commit the
`git rm` before the `add`: since #574, `add` refuses a path under another
spelling of `members/` that HEAD holds and the index no longer does.

`templates/family-root/scripts/validate-family.py` is a pinned copy: each
holder sees it as `upstream-changed` on its next `update-shape.py check`.

Tests: tests/test_family_head_lost_gitmodules.py (new, 24); the pinned
remedy text and the case-variant states in
tests/test_validate_family_unnamed_mounts.py; a docstring in
tests/test_family_remove_gitmodules.py.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-absent` reads and the members `remove`'s refusal is for are pinned, so a finding for a row with no gitlink or a hand-edited path, or the refusal over a member with no gitlink, no longer passes (#611)

Two tests. In a holder whose HEAD lost `.gitmodules`: a row with no
gitlink (`member-gitlink-absent`'s) and a row whose `path:` was edited by
hand (`member-path`'s, its mount `member-gitlink-unnamed`'s) get no
`member-gitmodules-absent`; and `remove` of a member with no gitlink is
not that refusal, but the dry run's `did not match any file(s) known to
git`, as before. Each holds a condition of the first commit that its
other tests did not: both pass on BASE 396c0ff and on that commit.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….gitmodules` over an unmerged gitlink too, and the case-variant move prints `--ignore-unmatch` and the `mv` a case-sensitive disk needs, so the mount is no longer emptied there and the printed lines work half typed or with both directories on disk (#611)

Fix round 1, from the review of 6f938ed.

`_checked_gitmodules_held` (scripts/family.py) took the member's gitlink
only at stage 0. `git submodule deinit` and `git rm` run on an unmerged
gitlink as on a merged one, so with `members/<P>` held only at stages 1,
2 and 3 in a holder whose HEAD lost `.gitmodules`, the dry run exited 0
and the real run emptied the mount and died at its commit. It now takes a
gitlink at any stage, and both runs refuse `member-gitmodules-absent`
before `deinit`, with the holder byte for byte as it was. The docstring
says so.

The case-variant refusal of `member-gitlink-absent`
(templates/family-root/scripts/validate-family.py, pinned) now prints
`git rm -r --cached --ignore-unmatch -- <variants>`: a variant whose
removal is staged and not committed is still named from HEAD, and the
plain line failed there (`pathspec ... did not match`). Its remediation
also says what is left on disk: where nothing is checked out at the row's
path, as in a fresh clone, `git submodule update --init --recursive` then
checks the member out; on a case-sensitive disk a directory left at a
variant stays, untracked, and `family.py add` refuses it as
`family-members-dir-variant`, so it gives an `mv` of each out of the
holder, to `../<variant with / as ->`, and says to leave it on macOS and
Windows, where it is the row's own directory.

The validator's module docstring names `member-gitmodules-absent`, says
what a staged, uncommitted `.gitmodules` does (HEAD's file is what counts,
as #407's decision 3 says: an uncommitted `add` is the finding for its
member, a staged section removal is none, a staged restore is still the
finding), and gives EXIT CODES' one exception, an unregistered member that
is not checked out (exit 1, not 2).

Tests (tests/test_family_head_lost_gitmodules.py, 12 more, 38 in all):
the unmerged gitlink refused alike and repaired as printed; one test per
condition no test held (a checked-out member still gets its tree check
beside the finding; HEAD's unreadable file refused in every mode; the
`<url>` placeholder; `remove`'s remedy names only members with a gitlink
in the index; one finding per path for a duplicate row); the variant move
half typed (both disks); and, on a case-sensitive disk, every printed
line typed with both directories present, after which the gate passes and
another member is added. tests/test_validate_family_unnamed_mounts.py
pins the `--ignore-unmatch` line.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…modules: the gitlink remedy keeps #611's commit-the-removal clause and ends with BRANCH_LEFT (#611 x #607)

This merges origin/main at 95780dd, which
holds #637's squash (95780dd, "family.py and validate-family.py: where an
earlier act left the branch ...", #607). It adds `BRANCH_LEFT` to
scripts/family.py and templates/family-root/scripts/validate-family.py, and
the guard `test_every_remedy_that_names_a_switch_line_ends_with_the_sentence`
in tests/test_family_switch_branch_exists.py. Lane 2's #634 (#610) is not in
main yet, so `_check_member_gitlink` is untouched here.

Two hand resolutions, one sentence in two places, as #639's body named it:

- templates/family-root/scripts/validate-family.py, the end of
  `member-gitlink-unnamed`'s remedy: #611's "and commit that removal, since
  family.py add refuses a path under another spelling of members/ that HEAD
  holds and the index no longer does, then mount it with the third, ..."
  clause is kept, and #607's `BRANCH_LEFT` ends the prose, as
  "... if one is still there. {BRANCH_LEFT}\n".
- tests/test_validate_family_unnamed_mounts.py, `GITLINK_REMEDY`: the same
  clause, ending ".git/modules if one is still there. " + BRANCH_LEFT.

No other file conflicted. #637's guard test passes on this merge without a
further change: the remedies #611 added name no `_switch_line`, so no
BRANCH_LEFT is appended beyond the one resolved above.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ck out fails the family bootstrap, every row's gitlink is asked before any row's checkout, and `--no-members` prints the case-variant move, so a clone of a holder whose HEAD lost `.gitmodules` or of #363's state no longer ends `family bootstrap ok` or hides the variant behind `member-uninitialized` (#648)

(a) `templates/family-root/scripts/bootstrap.py` said "every member and
leg fetched" and ended `family bootstrap ok`, exit 0, over members it had
not checked out: in a fresh recursive clone of a holder whose HEAD lost
`.gitmodules` (git exits 0), and in a plain clone of that holder or of
#363's state, where git dies `No url found for submodule path` (exit 128).
After the fetch it now asks this holder's own records of each row whose
member is not checked out: no gitlink at the row's path
(`recorded_gitlink`, as `member-gitlink-absent` reads it), or no section
of `.gitmodules` giving that path, or, where git exited 0, any such row.
Each is named on one `NOT CHECKED OUT, and not for want of a credential`
line with why, which names `validate-family.py`, and the run ends
`family bootstrap FAILED: <P> (not checked out)`, exit 1. "every member
and leg fetched" is said only where git exited 0 and every row is checked
out. A row git could not fetch for want of a credential is still the skip
it was (`test_bootstrap_degrades_when_a_member_cannot_be_fetched`), and a
good holder's run prints what it printed.

(b) `validate-family.py` asked each row whole, gitlink then checkout,
before the next. In a plain clone of #363's state plus `git submodule
update --init --recursive`, which dies at `Members/<P>` before it checks
out any member, an earlier row was refused `member-uninitialized` (naming
that same update) and the later row's variant refusal was never reached.
`_check_member` now stops at the gitlink and returns the member
(`Gitlinked`); `_check_member_checkout` asks the digest and identity of
each once every row's gitlink has been asked.

(c) Under `--no-members`, `member-gitlink-unnamed` for a member's only
gitlink, in the wrong case, printed no command. Where the row's path is
its canonical one and its pin a commit, it now prints the index move the
row's members-mode refusal prints, word for word (`_wrong_case_move`,
shared by both), to the pinned commit, every variant on one line.

Docs: the validator's WHAT IS CHECKED docstring (the order; the move
under --no-members) and bootstrap's (a) and (c). README.md's "A member it
cannot fetch is reported and skipped, never a failure" is no longer true,
so it says "One it lacks a credential for is reported and skipped, not a
failure", in the same line count (README is at its 1449-line cap), and
docs/handbook.html says the same, with its § Families marker set to the
segment's new blob.

Pinned copies changed: templates/family-root/scripts/bootstrap.py and
templates/family-root/scripts/validate-family.py.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tstrap-and-gate-order, as lane 5's coordinator asked (#648 x #611)

This merges origin/fix/611-validate-family-head-gitmodules at
08b6245, #639's head after its own merge
of main 95780dd (#637's squash, #607), keeping that merge's one hand
resolution: `member-gitlink-unnamed`'s hand-mount remedy ends with #611's
commit-the-removal clause and then `BRANCH_LEFT`.

No file conflicted. The remedy #648 adds under `--no-members`
(`_wrong_case_move`) names no `_switch_line`, so #637's guard asks no
`BRANCH_LEFT` of it. README.md's § Families segment, which #648 edits, is
the same in both parents, so docs/handbook.html's marker for it stays the
blob #648 set.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…x/648-bootstrap-and-gate-order, before main takes #639's squash (#648 x #611)

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…der, keeping this tree: main's tree a25f083 is #639's final head 159a08f's, merged in the commit before (#648 x #611)

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…member THE LOCKSTEP RULE rides on `Gitlinked`, so the two-pass gate (#648) passes it to the digest finding (#648 x #610)

One file conflicted, `templates/family-root/scripts/validate-family.py`,
in two regions, both where this branch split `_check_member` in two
(the gitlink first, the checkout once every row's gitlink is asked) and
#634 (#610) made THE LOCKSTEP RULE a per-member `_lockstep(...)` value
passed to the gitlink and tree checks:

1. `_check_member`'s gitlink call. #634 computes
   `rule = _lockstep(root, project, str(row.get("id") or project))`
   and passes it to `_check_member_gitlink`; this branch returns a
   `Gitlinked` there. Resolved: the call passes `rule`, as #634 has it,
   and `Gitlinked` gains a sixth field, `rule`, which the return
   carries.
2. `_check_member`'s tail, which this branch moved into
   `_check_member_checkout`. #634 passes `rule` to `_check_member_tree`.
   Resolved: `_check_member_checkout` passes `asked.rule`.

So each member's gitlink-mismatch and digest-mismatch findings end at
the same rule #634 gives them, naming that member, and the closing
count's `_lockstep(root)` is #634's, merged clean. `Gitlinked`'s
docstring names the new field. No other file conflicted. The
resolution is the one rv648 built and tested in its scratch clone
(the same blob of #634's file, 519b71f), plus that docstring.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n gives a url fails the run naming every row, and a member git exited 0 over is given the update that checks it out, so a stray case variant, a hand mount or a url-less section no longer ends `family bootstrap ok` (#648, rv648)

The fix round for rv648's review of 8b39cd1 (1 MUST, 2 SHOULD, 5 NIT).

M1. In a plain clone, `git submodule update --init` dies in its init
step at the first gitlink, in index order, that no section of
`.gitmodules` gives a url, and checks out no member. `unfetchable` asked
only each row's own gitlink and section, so a stray `Members/<P>`
beside `members/<P>` (#361's STRAY), a member mounted by hand with no
section, or a row's section that lost its url (rv648's S1, H1 and U1)
passed every row as the credential skip, and the run ended ok.

`init_stop` now asks the holder's records which gitlink git's init
stops at: the index's gitlinks in order, each against the work tree's
sections (`gitmodules_sections`, which replaces `gitmodules_paths`) and
the git config. Measured before it was taken, rv648's prototype failed
three states that are a missing credential: a recursive clone of the
stray or of the hand mount with no credential, and a url-less section
an earlier `git submodule init` registered. So two of git's rules are
kept: where the config sets `submodule.active` (a recursive clone sets
`.`), init asks only the gitlinks a section gives, and a section with no
url does not stop it where the config holds a url under its name. Where
git exited non-zero and init stops at a gitlink, every row not checked
out is named `git stopped at <gitlink>, a gitlink no section of
.gitmodules gives a url`, and the run fails.

What cures it: `validate-family.py --no-members` names a gitlink no row
names and the commit that cures it (members checked, it refuses
`member-uninitialized` for the first row in these states, a checklist
candidate). Where a row names the gitlink, nothing in the validator does,
so the line says to write that section back as `family.py add` writes
one. Each why now carries its own sentence.

S1. For "git exited 0 and checked nothing out there" (a subset clone, an
`update = none`), the line printed the validator sentence, whose
`member-uninitialized` remedy exits 0 and checks nothing out. It prints
`git submodule update --init --checkout --recursive -- <path>` instead;
typed, it checks the member out in both states and the run ends ok.

S2. `test_a_missing_credential_is_still_the_skip_it_was` asserts that
"  every member and leg fetched" is not printed (rv648's mutant R2).

NITs. README and the handbook say `make bootstrap` skips a member it
lacks a credential for, and the § Families marker is the new segment's
blob, b3d19092; README stays at 1459 lines, its cap. The docstrings say
"an empty mapping" and "a missing credential exits non-zero too", and
`validate-family.py`'s WHAT IS CHECKED says the move under
`--no-members` is printed only for a canonical path and a 40-hex pin.

Tests: the variant-plain case and the round trip name MEMBER too; new
tests for S1 and H1 (typed through `--no-members` and committed, the run
checks both out), U1 (the section written back), the two recursive
clones with no credential and the registered url (still the skip), and
the update that exits 0, by `update = none` and by a subset clone, with
its printed line typed.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing in #414's url-less member state now fails, as 1a69505 makes it, so the test pins exit 1 and `git stopped at members/Ghost` where it pinned the old `ok` (#648, rv648's U1)

`test_414_3_there_the_bootstrap_cures_nothing_and_the_git_line_cures_one`
builds rv648's U1 state: a member, Ghost, whose `.gitmodules` section
gives its path and no url. Git's init stops the holder's update there,
before it checks out the other member. The test asserted bootstrap's
exit 0, the false `ok` rv648's M1 asked to end. It asserts exit 1 now,
and the line naming where git stopped. Its point is unchanged: the
bootstrap cures nothing, and the doctor's git line cures the other
member.

Found by the targeted run of every test file that drives the family
bootstrap (1 failed, 3797 passed, 32 skipped); the file now passes
alone, 124 passed.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ember git has no url for is its own reason, and a gitlink outside members/ gets a cure true for it, so a later duplicate section, a section under a name git ignores or a url git drops no longer ends `family bootstrap ok`, and a subset clone's printed update no longer dies `No url found` (#648, rv648's verify)

rv648's verify of 45c512e (1 MUST, 1 SHOULD, 3 NIT).

M1. `init_stop` read `.gitmodules` with `git config --file --list`, which
keeps entries git's submodule parser drops or overrides. In three plain
clones git's init died (`No url found`, exit 128) and the run still said
ok: a second section for a member's path, with no url, after its own
(D1); the member's section under the name `../Nova` (Q1); and its url
`-nowhere` (O1). `gitmodules_sections` now returns a `Gitmodules` with
git 2.43's own reading beside the one the validator's findings use:

- a section whose name is empty or has a `..` component, split on `/`
  and on a backslash, is ignored whole (`check_submodule_name`);
- a `path` or `url` that begins with `-` is dropped, and the value
  before it stands (`looks_like_command_line_option`);
- where two sections give one path, the later decides, and a section
  that gives a path again lets go of the one it gave, even where a later
  section gave that one too (`cache_put_path`, `cache_remove_path`).

`has_url` asks the section git reads for a path, then the config under
that section's name. `init_stop` and the reasons ask it. Where more than
one section gives the path git has no url for, the line names
`validate-family.py --no-members`, whose `gitmodules-path-twice` says to
take the others out: a section written back would be one more (D1).

S1. In a subset clone of a member whose section has no url (A1), git
exits 0 and the line printed the exit-0 skip's update, which dies `No
url found` when typed. "git has no url for that path" is now its own
reason, asked before "git exited 0", with the write-it-back cure. The
stop's sentence says "a gitlink it has no url for", which is true in D1
too, where a section does give the path a url.

NIT 1. A gitlink outside members/ that git stopped at (H2) was sent to
`--no-members`, which reads only members/ and says `family ok` there. It
now gets `git rm --cached -- <path>` and a commit: measured, that ends
ok and the gate passes in every mode, while a section written for it
leaves `gitmodules-path-unnamed` in every mode.

NIT 2. The `cures` docstring says what members mode refuses for #363's
variant (`member-gitlink-absent`, with the move) as well as for a stray
or a hand mount (`member-uninitialized`).

Tests in tests/test_family_bootstrap_gate_order.py: U1, Q1 (three names)
and O1 in one parametrized test, each written back; D1, typed through
`gitmodules-path-twice`; D2, a name with dots and a url `-x` after a
url, each still the credential's skip and then a true ok; A1, typed
through both rounds; and H2, typed and the gate passing in every mode.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts own reason, since git reads no section from it, so a plain clone mid-merge whose update dies `No url found` no longer ends `family bootstrap ok` (#648, rv648's MM1)

rv648's verify of 23e1105 (1 MUST). In a plain clone, a merge that
conflicts in `.gitmodules`, resolved in the work tree and not added,
leaves the index holding the file only at its three conflict stages.
Git then reads no `.gitmodules` at all (`repo_read_gitmodules` asks
`is_gitmodules_unmerged` first), so its init dies at the first gitlink,
exit 128. `gitmodules_sections` read the work tree's file, found every
section with its url, and the run ended ok.

`gitmodules_sections` now asks `git ls-files -u -z -- .gitmodules`
first. Where the index holds the file at a conflict stage, it returns
nothing, with `Gitmodules.unmerged` set. `unfetchable` asks that first:
every row not checked out gets "git reads no .gitmodules while a merge
leaves it unmerged", and the line says to resolve the merge in
.gitmodules and `git add .gitmodules`, then run this again. Added, the
same run checks both members out.

Test: rv648's MM1 in tests/test_family_bootstrap_gate_order.py, the two
sides of the merge each giving OTHER's section another `branch`; the
cure typed, and the run ends ok.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n as it reads the file fails `make bootstrap` naming the key, and the family gate names it in every mode, so `update = None` no longer ends `family bootstrap ok` (#768)

Git 2.43 dies reading a `.gitmodules` that gives a submodule a `path`,
`url`, `update`, `ignore` or `branch` with no value (a `branch` with none
crashes it, exit 139), an `update` other than `none`, `checkout`,
`rebase` or `merge`, a `shallow` that is no boolean, or a
`fetchRecurseSubmodules` that is neither a boolean nor `on-demand`, before
`git submodule update` checks out any member. The family holder's
bootstrap listed the file with `git config --list`, which reads all of
them, and ended `family bootstrap ok`; the gate refused
`member-uninitialized` members checked, naming the update that dies, and
said `family ok` under `--no-members`.

bootstrap.py (the family root's, pinned):
- `gitmodules_sections` checks each entry against git's rules
  (`refused_value`; a boolean is read by git itself, `git config
  --type=bool`). Where one would make git die it returns those entries
  and no sections, and `unfetchable` gives every row not checked out the
  reason "git cannot read .gitmodules: `<key>` is `<value>`" (or "has no
  value"). The cure names each key and what git takes for it. Where every
  member was checked out, the run fails on `.gitmodules` all the same,
  since git updated none of them.
- Where the work tree has no `.gitmodules`, it reads the index's, then
  HEAD's, with `git config --blob`, as git does. A key with no value ends
  the reading of such a blob, where git calls the line bad and goes on.
- `init_stop` reads a section's own `submodule.<name>.active` before
  `submodule.active`'s patterns, and matches a plain path pattern, as
  `git clone --recurse-submodules=<path>` writes one, to that path and
  what is under it. A pattern with pathspec magic or a glob keeps the old
  reading.

validate-family.py (the family root's, pinned):
- A new finding, `gitmodules-value-refused`, for each such key in the
  work tree's `.gitmodules` and in HEAD's, asked before any member check
  and in every mode. Its remedy prints `git config -f .gitmodules
  --unset-all submodule.<name>.<key>`, one command to a line, writes a
  member's `path` or `url` back where taking it out would leave none,
  says which values git takes, and ends with the commit. It says which
  file gives the value; where HEAD's alone does, it says to commit the
  work tree's, and where the work tree has no `.gitmodules`, it puts
  HEAD's back first.
- Where the work tree's file gives such a value, `gitmodules-path-twice`
  and `gitmodules-path-unnamed` read nothing from it, as git reads
  nothing, so no command they print meets one of its lines. Where either
  file gives one, a member that is not checked out is not refused
  `member-uninitialized`: the finding fails the run.

tests/test_family_gitmodules_values.py: every row of the issue's table
and `branch` with no value, in a plain clone, through bootstrap and the
gate in three modes, the printed commands typed; the no-misfire values;
the index's and HEAD's file; `submodule.<name>.active` and the subset's
patterns; and which file gives the value.

tests/test_validate_family_without_git.py: with no git and no work tree
`.gitmodules`, the first check to run git is now the reading of HEAD's
`.gitmodules`, so the `git-not-runnable` refusal names that read, where it
named the gitlink listing. Exit 2 and the remedy are as they were.

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

# Conflicts:
#	templates/family-root/scripts/bootstrap.py
#	templates/family-root/scripts/validate-family.py
…ues.py: a `branch` with no value is read as the git that runs reads it, a work tree `.gitmodules` git cannot parse fails the run with every member checked out, and the boolean probe reads no config of the person's, so CI's git 2.55 passes the row (#768, rv819)

CI's run 39031235254 (git 2.55.0) failed
`test_a_value_git_dies_on_fails_bootstrap_and_every_mode_of_the_gate[branch-no-value]`,
and lane 2's review rv819 of c12b5f8 (NOT READY) found one MUST, four
SHOULDs and five NITs. This commit answers them.

MUST 1. The row pinned git's own update at exit 139, the crash w768
measured on git 2.43.0. Git 2.55 refuses the line, `error: missing value
for 'submodule.members/IRSS.branch'` and `fatal: bad config line 7`, exit
128; the same assertion fails on git 2.47.1 (`assert 128 == 139`). The
table's exit column is gone: the premise is that git's update died and
checked nothing out, and every other assertion is the tools' own.

SHOULD 2. Measured on git 2.43.0 and 2.47.1, a `branch` with no value
after a submodule's url, then `git submodule update --init --recursive`
in a plain clone: from the work tree's file, 2.43.0 crashes (exit 139)
and 2.47.1 dies (exit 128); from the index's file or HEAD's, a blob,
2.43.0 crashes and 2.47.1 says `error: bad config line 4 in blob`, reads
every section before it, checks the submodule out and exits 0, as for a
`url` with no value. git's submodule-config.c gained `if (!value) ret =
config_error_nonbool(var);` for `branch` in v2.43.1 and v2.44.0; v2.43.0,
v2.42.4 and v2.39.5 lack it. Bootstrap kept 2.43.0's rule for every git,
so on 2.43.1 and later such a blob had every member not checked out named
"git cannot read .gitmodules" and the run failed, where git failed only
for want of a credential (measured on 2.47.1 at c12b5f8).
`crashes_on_branch` now reads `git version`: before 2.43.1, or a version
it cannot read, the key is refused as before; from 2.43.1 on, the line
ends the reading of a blob (`ends_blob_reading`). The gate is unchanged:
it reads the work tree's file and HEAD's, which a clone checks out as
its work tree's, and every git dies on the key there. The comments that
said git crashes say which git does.

SHOULD 3. Where every member was checked out, a work tree `.gitmodules`
`git config --list` cannot parse (a conflict marker, an open quote) ended
`family bootstrap ok` over git's `fatal: bad config line`. `Gitmodules`
now says `unreadable`, and the run says NO MEMBER UPDATED and fails on
`.gitmodules`, as for a refused value; a row not checked out is said to
be that (`git cannot parse .gitmodules`), where it was "no section gives
that path", and the cure is the file mended by hand.

SHOULD 4. New tests catch each survivor rv819 named: the index's file
staged apart from HEAD's; `update = rebase` and `update = merge`; a key
with no value inside an ignored section of a blob; a `submodule.active`
pattern that is only the start of a path; `submodule.<name>.active` set
twice; a url beginning with `-` before the one with no value; the
`<url>` placeholder; a section name with a backslash; and HEAD's value
alone leaving the work tree's file to `gitmodules-path-twice`.

SHOULD 5. `gitmodules_sections` is split into `gitmodules_source`,
`gitmodules_entries`, `ends_blob_reading` and `read_sections`;
`unfetchable` gives each row's reason to `row_reason`; `cures` gives the
`.gitmodules` sentences to `gitmodules_cures`; `_check_gitmodules_values`
gives its text to `_value_said` and `_value_taken_out`.

NIT 6: the gate-in-three-modes block and the good-run block are
helpers. NIT 8: where HEAD's file alone gives the value, the finding says
git dies on it in a clone. NIT 9: both boolean probes ask git for the
`--default` of a key in an empty file (`git config --file <null>`), so no
`probe.value` in the person's config is read; one in it made both tools
refuse `shallow = true`. NIT 7 (one copy of the rules) and NIT 10 (the
body's count) are not code changes here.

tests/test_family_gitmodules_values.py: 52 tests, from 28.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…762) landed, so validate-family.py keeps both sides, `stop` is asked only where `not refused`, and `_check_removals_staged` is handed the sections as read

#820 (#762, `b9b70692`) landed on main after #819's last head `e1379a9d`. The
two PRs conflict in `templates/family-root/scripts/validate-family.py` only;
`templates/family-root/scripts/bootstrap.py` merges by itself. The resolution
is the one lane 2 measured on #819 and #820 (rv819, rv820): keep both sides,
with two changes that its notes for the second lander ask for.

Conflicts, all in `templates/family-root/scripts/validate-family.py`:

1. The module docstring's EXIT CODES sentence. #819 added "and any while
   `.gitmodules` gives a value git dies on" with `gitmodules-value-refused`;
   #820 added "or gives a url, or whose removal is staged" with
   `member-gitmodules-url-absent` and `member-gitlink-removal-staged`. Now one
   sentence names all four exit-1 codes.
2. The `_check_members` docstring. Both paragraphs stay: #820's sentence
   about `_init_stop_said` ends the #648 paragraph, and #819's paragraph about
   a value git dies on follows it, with a clause that `_init_stop_said` is not
   asked there.
3. The `_check_member_checkout` call: `set(gitlinks) if refused else
   unregistered` (#819) and `stop` (#820), as `_check_member_checkout(root,
   asked, report, set(gitlinks) if refused else unregistered, stop)`.

Two lines outside the blocks, which lane 2's notes ask for:

- `stop` is computed only where `not refused`. Given #819's emptied
  `sections`, `_init_stop_said` names the first gitlink as a stop git never
  reaches when the work tree's file gives a value git dies on. Today
  `set(gitlinks)` keeps that text from being printed, but only by ordering.
- `_check_removals_staged` is handed `as_read`, the sections before #819
  empties them. Given `{}`, a staged `git rm --cached` printed
  `git checkout HEAD -- .gitmodules` as putting back "the .gitmodules section
  git rm took out", and `git rm --cached` took none (rv820).

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…side a value git dies on in the work tree's `.gitmodules` is a finding that prints the reset alone, so `_check_removals_staged` handed the emptied sections no longer puts `git checkout HEAD -- .gitmodules` in it (#768, point 3 of the merge with `b9b70692`)

The merge of main `b9b70692` (#820, #762) into this branch hands
`_check_removals_staged` the work tree's sections as read (`as_read`), not
as #768 empties them where the work tree's file gives a value git dies on
(rv820's note for whichever of #819 and #820 landed second). No test held
that line: a mutant giving it `sections` again passed all 191 tests of
`test_validate_family_typed_states.py`, `test_family_gitmodules_values.py`,
`test_family_head_lost_gitmodules.py`,
`test_validate_family_remedies_typed.py` and
`test_validate_family_unnamed_mounts.py`.

`test_a_staged_removal_beside_a_refused_value_puts_no_section_back` holds
the state: the work tree's file gives OTHER's section `update = None`, and
`git rm --cached -- members/IRRS` is staged. In all three modes the gate
finds `gitmodules-value-refused` and `member-gitlink-removal-staged`, whole
(`taken_out` and `removal_staged`), the second's commands are
`git reset -q -- members/IRRS` alone, and `git checkout HEAD --
.gitmodules` is not in it.

The test passes on `e32ed55a`'s validator. With the call changed back to
`sections` it fails at the first mode: the finding's last line is the
`git checkout HEAD -- .gitmodules` it should not print.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… advice and a member's exit code say what happened, and the gate's `git rm --cached`, guarded `mv` and section lines type as printed where they failed (#839)

Seven lines of #839, in the holder's two pinned scripts, on #819's head
(233a334):

1. `fetch_members` printed "If the members are PRIVATE, set
   SHAPE_LEGS_TOKEN ..." for every failed `git submodule update`, then
   "NOT CHECKED OUT, and not for want of a credential" for every row
   `unfetchable` names. `credential_advice` prints it only where git
   asked for a credential: not where its init died at a gitlink it has
   no url for, nor where it read nothing from `.gitmodules` (unmerged, a
   value git dies on, a file it cannot parse). Beside a denial it says it
   is for the members that line does not name. A leg left unfetched in a
   checked-out member keeps it.
2. `run_member_target` said `make <target> exited 2` for a member's
   finding (1), its refusal (2) and make's own failure alike: GNU make
   exits 2 for each (measured, GNU Make 4.3). `run_make` passes make's
   stderr on as it comes and reads the code from its last `Error <code>`
   line: `make bootstrap: the member's command exited N`, or `make
   bootstrap exited 2 and printed no ... line` where make printed none.
   Make is still what runs, so `--members-make validate` gets it too.
3. In the gate's state after `git mv members/<P> Members/<P>`, staged,
   with a commit or an untracked file in the moved checkout, the printed
   `git rm --cached -- Members/<P>` (the STRAY finding) refused, exit 1.
   `_forced` reads where the index stages a variant at a commit HEAD does
   not record there over a checkout; the STRAY line and the wrong-case
   move's `git rm` then carry `-f` and say what it does, as family.py's
   move back says it (#828).
4. The wrong-case move's guard followed a link (`-ef`, `-e`), so a link
   at the variant to the row's own directory or to a clone was kept, and
   `family.py add` refused it; with nothing there the `mv` failed `cannot
   stat`. `_guarded_move` prints `[ ! -L V ] && { [ ! -e V ] || [ V -ef R
   ] || [ -e V/.git ]; } || mv -- V ../<V>`, run in dash and bash on seven
   layouts. Comment 6237599648 is the same fault.
5. Comment 6237354577 (a): bootstrap already reads the work tree's, the
   index's, then HEAD's `.gitmodules` at 233a334 (#819); a test asks it.
6. Comment 6237354577 (b): `member-gitmodules-absent`'s `git config -f`
   lines made a one-section file where the work tree's `.gitmodules` is
   gone. The first finding that writes the file puts it back first
   (`_gitmodules_put_back`, `_back_first`), and no other, nor any after a
   `gitmodules-value-refused` that put HEAD's back; a second
   `member-gitmodules-url-absent` put the file back over the first's url.

Tests: tests/test_family_holder_bootstrap_texts.py (48), and
`variant_refused` in tests/test_family_head_lost_gitmodules.py and the
make line in tests/test_bootstrap_spelling.py follow the new texts.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as `_check_gitmodules_values` returns it, so `_check_members` gains no condition for the put-back `gitmodules-value-refused` already printed (#839)

Where the work tree holds `.gitmodules`, `_gitmodules_put_back` names no
line to put it back either way, so `refused` alone says the same as
`refused and not os.path.lexists(root / GITMODULES)`, and the cognitive
complexity of `_check_members` stays at its base's.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…section's path outside `members/` out of the index before the section, and a gitlink there that no section gives is `gitlink-unnamed`, so the remedy typed as printed no longer leaves a gitlink at which a plain clone's `git submodule update --init` exits 128 while the gate passes (#878)

`_member_gitlinks` listed gitlinks in `members/` alone, so no mode asked
about one elsewhere. `git submodule add <IRSS> tools/x`, committed, or a
member added at `Members/IRRS` and moved with `git mv` to `renamed-1`:
the gate printed only the section's `--unset-all` lines, and typed as
printed they left the gitlink. The gate then passed in every mode while
`git submodule status` and a plain clone's update exited 128 with no
member checked out, which is how the holder's CI checks its members out.

- `_member_gitlinks` lists every gitlink (`_in_members` says which are
  in `members/`); `member-gitlink-unnamed` still asks only those.
- `gitmodules-path-unnamed` for a section at such a gitlink prints `git
  rm --cached -- <path>` and its commit first, then its own lines, once
  per path (`_gitlink_out_first`). Where HEAD's `.gitmodules` holds no
  section of its name, it says to stage the file and that nothing of it
  is left to commit.
- `gitlink-unnamed` (`_check_gitlinks_elsewhere`): a gitlink outside
  `members/` that no section of the work tree's `.gitmodules` gives,
  with bootstrap's cure, `git rm --cached` and the commit, or the commit
  alone where its removal is staged (`_second_path_out`).
- Each of those `git rm --cached` lines takes `-f`, with #828's clause,
  where the index stages the gitlink at a commit HEAD does not record
  and a checkout is on disk at it (`_forced`, #876), and comes after
  `git add -- .gitmodules` where lines printed before it may leave that
  file unstaged, or it is (`_stage_first`): git takes no gitlink out of
  the index then (`please stage your changes to .gitmodules`, exit 128),
  measured after a value finding's lines (#879's C3 order), an earlier
  unnamed section's, and a hand edit.

Tests: tests/test_family_unnamed_gitlink.py (new, 17 cases), and two
tests that pinned the old scope: `test_only_members_is_asked_about` and
rv648's H2 test, whose `--no-members` now names bootstrap's own line.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s squash 41baf92

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…printed in order, put a `.gitmodules` gone from the work tree back once, stage it before a gitlink's `git rm`, and name the checkout `git mv` moved before the update, and bootstrap fails where git's init stopped with every member checked out, so no remedy undoes another or exits 128 and no `family bootstrap ok` follows git's exit 128 (#879)

- C1: where the work tree's `.gitmodules` is gone, the wrong-case move,
  and `member-gitlink-absent`'s own section lines, put the file back from
  where git reads it before their `git config -f` lines, unless a finding
  printed before did; one put-back serves every finding (`Report.put_back`).
- C2: `member-gitlink-removal-staged` reads "section taken" from the file
  git reads, puts nothing back after an earlier finding did, and writes a
  section a plain `git rm` took out back key by key into that file.
- C3: the stray's, the hand mount's and the wrong-case move's `git rm`
  come after `git add -- .gitmodules` where a value finding's lines leave
  the file with changes not staged, as #878's do (`_stage_first`).
- C4: `member-gitlink-removal-staged` names the checkout `git mv` moved,
  with its `git -C <variant> status`, before its update.
- C5: `unfetchable` asks `init_stop` with no row left, and `fetch_members`
  fails on the stop, or on an unmerged `.gitmodules`, through `cures`.
- bootstrap.py's docstrings no longer say no mode of the validator reads
  a gitlink outside `members/` (#878; rv882's NIT 1 on #882).

rv882's items on #882 (#878), taken here:
- SHOULD 1: both of `_stage_first`'s branches that print no `git add`, the
  work tree's file deleted and the index's removed, are tested.
- SHOULD 2: `gitlink-unnamed` reads the paths `.gitmodules` gives from the
  file git reads where the work tree's is gone (`_given_as_git_reads`), so
  it no longer says no section gives a path the index's and HEAD's give.
- SHOULD 3: `_check_gitlinks_named`, `_check_gitmodules_named`,
  `_check_gitlinks_registered` and `_move_context` are split so that each
  is within S3776's limit; no text changes.
- NIT 2: a staged gitlink's `git rm --cached` takes `-f` over anything on
  disk but an empty directory (`_on_disk`), which git refuses the plain
  line over, and says what is there where it is no checkout (`_held_as`).
- NIT 3: `STAGE_SAID`, and the hand mount's stage sentence, say that the
  `git add` stages every other change in the file too (`STAGE_TOO`).

Tests: tests/test_family_holder_remedies_compose.py; the `STAGE_SAID`
constant in tests/test_family_unnamed_gitlink.py.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-20T09:45Z) @codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T09:51:25.374619Z b2bbb31 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b2bbb31c13

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +654 to +655
first = subprocess.run(["git", *rewrite, "fetch", "origin"],
cwd=str(checkout), capture_output=True,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the checkout's configured default remote

When a member or leg is on a branch whose branch.<name>.remote is not origin, recursive submodule update fetches that configured default remote, while both retries here probe origin; Git documents that “the default remote is the remote of the remote-tracking branch of the current branch.” Consequently, if the real upstream fetch fails but origin succeeds without the pin, bootstrap falsely reports B4, while an unreachable origin can preserve the credential advice and successful bootstrap even though the actual upstream was reached and lacks the pin. Retry the initial fetch without an explicit remote and resolve that same default remote for the object-specific fetch.

Useful? React with 👍 / 👎.

@brettheap

brettheap commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Independent review of #1045 (#998, #996) at b2bbb31c, for lane openRepoShape-5: READY

Both fixes work as the issues ask, and nothing changes where no token is set. I found no MUST and no SHOULD: five NITs and two checklist candidates. Every count in the body that I measured again matches, except one mutant count (NIT 5). I changed no branch, pushed nothing and posted nothing.

Findings, most severe first

NIT 1: three of my mutants survive, all on defensive lines. Each line works today (measured below); no row pins it.

  • R1 survives (37 passed). It drops capture_output=True from both fetches in refetched (templates/family-root/scripts/bootstrap.py:655 and :660), so git's output from those fetches reaches bootstrap's own stderr.
    • That capture is what backs the body's "what the two fetches print is neither read nor shown".
    • siblings.py's scrub says git's error text carries "the url it tried ..., token and all".
    • A row that would kill it: in the [b4] token control, git's own update already prints fatal: transport 'file' not allowed twice. Assert it is printed no more often than that. Or have the wrapper echo a marker to stderr on fetch, and assert the marker is absent.
  • R2 survives. It changes returncode < GIT_DIED + signal.NSIG (:758) to <=. No row uses exit 193.
  • R4 survives. It drops the try/except ValueError in killed_said (:769-772). No row uses a signal Python cannot name, such as 32.

NIT 2: one docstring claim is broader than git's behaviour. credential_advice (bootstrap.py:725-728) and the body say that an update ending 128 + N "met none" — that is, no fetch that failed.

  • In git's source (builtin/submodule--helper.c 2.47.1, lines 2700-2704 and 2768-2773), the update keeps the last non-zero code and stops only at 128.
  • A member's nested update returns its own code. A leg clone that fails for want of a credential gives 1 (quickstop), and the update goes on.
  • So a later crash leaves 139, and the advice is dropped where a credential was in fact missing.
  • The run still ends FAILED, never a false ok. I reasoned this from the source and did not run it.

NIT 3: refetched is not quite the same two fetches git makes (bootstrap.py:654 and :659).

  • Git's first fetch is a bare git fetch. Its second is git fetch <default remote> <commit>, where the default remote is origin on a detached HEAD and otherwise branch.<b>.remote (source lines 43-72, 2329-2345).
  • The writer's version always fetches origin. The two differ only for a member checkout on a branch that tracks another remote.
  • Where there is no origin, the first fetch fails, which falls back to the old reading (the advice and ok).

NIT 4: the body's merge table is out of date.

NIT 5: mutant T8 is killed by 5 rows, not the body's 4. The extra one is [pin-moved-on] at tests/test_family_bootstrap_stops_named.py:1040. With GIT_PROTOCOL_FROM_USER dropped, the redone fetch takes the file transport and brings the commit, so git's own update afterwards exits 0 instead of 128.

Checked and true

Could not verify

  • git 2.55 (CI's version), Windows and macOS.
  • An https remote: the token's rewrite only reaches github.com, and these probes are offline. So I could not check whether git's own error text, or a configured credential.helper storing a url's credentials, would carry the token.
  • The writer's 22-file run (1638 passed) and the remaining writer mutants (S1–S4, S6, S7, T1, T2, T4–T7, T9–T11, R7).
  • A deterministic reproduction of the same-commit collision the unpushed change fixes.
  • NIT 2's state, which I read from git's source and did not run.

Checklist candidates

  1. A signal is told only when it is git's last failing code. Measured on 2.43:
    • The state: P3's crash in IRRS plus IRSS's pin moved on, with no credential.
    • Git's update prints submodule--helper died of signal 11, then exits 128 at IRSS.
    • Bootstrap gives the advice and family bootstrap ok, exit 0, although IRRS's legs were never updated.
    • A crash followed by a later exit 1 would read the same way (reasoned from the source).
  2. templates/family-root/scripts/siblings.py passes the token's -c rewrite to git in the caller's own environment.

Reviewer: rv996, lane 5's independent reviewer, not the writer (w996).

Lane: openreposhape-5 (openRepoShape-5)

Brett Heap and others added 3 commits October 20, 2026 11:17
Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… for: templates/family-root/scripts/bootstrap.py tests/test_family_bootstrap_stops_named.py

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

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

Delta check of #1045's refresh 3272d533 (the merge with main eee089ca, bringing #1023), for lane openRepoShape-5: READY

The merged bootstrap.py keeps both changes' behaviour. The merge is the exact union of the two patches. They share no function, and only the module docstring was edited by both, which merges cleanly. Neither patch changes an environment, argv or exit code that the other reads. A probe ran both paths in one bootstrap run, and it fails against either parent's bytes alone. I found no MUST and no SHOULD, and one NIT. I pushed, posted and filed nothing.

Findings

MUST: none.

SHOULD: none.

NIT 1: the PR body still describes b2bbb31c, not this head.

Checked and found true

Textual merge

Semantic interaction

Pins and upgrade rows

Lint

  • flake8 finds the same three lines (E127, E501 at 80, E501 at 82) at the base, at both parents and at the head.
  • The maximum line width is 82 in all four. The non-ASCII count is the same 4 in all four.

Probe (test_rv1045m_probe.py, 8 rows, with the fake token w892-…, every trace turned on, no --skip-members, a real named pipe, and a fake make answering "GNU Make 4.4.1")

Runs at 3272d533 through run9.sh (with EMAIL, GIT_SSH_COMMAND=false and GIT_CONFIG_GLOBAL=/dev/null; never more than two at once; never the full suite)

git files result
2.43 A: the stops-named file, #1023's two (test_family_member_make_jobserver.py, test_update_shape_kept_helper.py), test_pinned_copy_text.py, the trio 567 passed, 1 skipped (the real-4.4 row: this host's make writes 3,4)
2.43 B: 13 files that run or pin the family bootstrap.py, including test_family.py, test_update_shape.py, the merged-away and landing rows, and test_family_makefile_siblings.py 1176 passed
2.43 C: 9 more files that run the holder's bootstrap 320 passed
2.43 the probe 8 passed
2.47.1 the stops-named file, #1023's two files, the probe 317 passed, 2 skipped (P3, and the real-4.4 row)

Token check: outside the main-only control, the fake token is in no log, no output and no trace file. The only other files holding it are copies of tests/test_family_bootstrap_stops_named.py (where the constant is defined) and my saved copy of the PR body. The main-only control's retained basetemp holds it in 12 trace files, which is the leak that control was meant to show.

Could not verify

Housekeeping

  • Both scratch worktrees (rv1045m-head, rv1045m-ctl) were removed with git worktree remove. The ref my fetch created (refs/remotes/origin/pr1045-rv1045m) was deleted with update-ref -d.
  • The shared checkout's HEAD is unchanged at 24769920, with only ?? handoffs.
  • The failed controls' basetemps are kept on purpose: /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1045m-c1 and /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v110/bt/rv1045m-c2.

Files are in /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/rv1045m/:

  • /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/rv1045m/test_rv1045m_probe.py (the probe)
  • /tmp/claude-1000/-workspace-projects-openRepoShape/50cede29-ab82-4ca3-9f67-d98e1a58c8d2/scratchpad/v118/rv1045m/tools/fnsplit.py (the AST split)
  • run logs: run-a-243.log, run-b-243.log, run-c-243.log, run-probe-243.log, run-247.log, run-ctl1-1045only.log, run-ctl2-mainonly.log
  • diffs: d1045.diff, d1023.diff, dmerge-bootstrap.diff

Reviewer: rv1045m, lane 5's independent reviewer, not the writer (w996).

@brettheap
brettheap merged commit 00f6d7f into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/996-998-update-stops branch October 20, 2026 16:58
brettheap pushed a commit that referenced this pull request Oct 20, 2026
Main moved past eee089c: #1045 (00f6d7f) changed the holder's
bootstrap.py and #1025 (6476c17) changed validate-family.py, two of the
four copies of the git-not-runnable rule. The merge is clean, and the four
copies' rule is still one text.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 20, 2026
…a signal fails the run by name wherever the update met it, read from git's trace, not only where its code is the update's last; with a token in use that stays a limit (#1056) (#1076)

Where a git that git's update ran dies of a signal, `make bootstrap` in a family holder now fails naming it wherever the update met it, not only where the signal's code is the update's last. This is #1056, found by rv996, lane 5's independent review of #1045 (issuecomment-6288783192, checklist candidate 1).

Git keeps only the last code that is not 0 (`update_submodules` in git 2.47.1's `builtin/submodule--helper.c`), and only 128 stops it. So a member's crash or kill before a later gitlink's 128 or 1 left no signal in the update's exit code, and #1045's `ended_on` saw none: the run said the credential advice and `family bootstrap ok`, exit 0, with IRRS's legs never updated.

One commit, on main `00f6d7f7` (which holds #1045 and #1023). It changes two files: the family root's `templates/family-root/scripts/bootstrap.py` and `tests/test_family_bootstrap_stops_named.py`.

### The signal is read from git's own trace (no token in use)

- With no token in use, git's update is already traced into a directory of the run's own (#892). `traced_in` now reads that trace once and returns a `Trace`: `fetched` (#892's work trees, as before) and `died`, read by the new `died_in`.
- **`died_in`** reads the events by the code, never git's words, as #892 reads a fetch:
  - a git writes a `child_start` event (with the argv) and a `child_exit` event (with the code) for each child it starts;
  - `child_of` pairs the two by the writing git's `sid` and the `child_id`;
  - a `child_exit` whose code is `128 + N`, N below `signal.NSIG` (`died_of`, now shared with `ended_on`), for a child whose argv begins `git` (`git_child`), is a git dead of signal N;
  - a hook or another program is not counted. Its code is its own: a `post-checkout` hook that exits 137 is no signal, and `git checkout` exits 1 over it (measured, both gits).
- **The first signal** is the first by the time each event gives, which is the order git met them. Where trace2 gives no time (its brief form), it is the order of the files and their lines.
  - File order alone would name the wrong one. The files go in the order their gits started, so a member's own update comes after git's update's file.
  - Measured on both gits: SIGKILL at IRRS's `code` leg, then SIGTERM at its `spec` leg. That ended IRRS's own update 143, and git's update's `child_exit` 143 for it came first in the files.
- **`signal_met`** chooses what (a) tells:
  - where git's update exited 0, nothing;
  - else the trace's first signal, where there is one;
  - else the update's own exit code, through `ended_on` as #998 reads it (`128 + N`, or `-N` where git itself was killed).
- **Nothing where the update exited 0.** It did all it set out to do then, whatever a git it ran died of on the way. Measured on both gits: a `reference-transaction` hook killed IRSS's `git fetch` after it wrote the branch, git found the commit reached, checked it out and exited 0. The run says every member and leg was fetched, as before.

### What the run says

- Where the signal's code is the update's own, the line is #998's, word for word, so the controls read as before:
  ```
    UPDATE KILLED, and not for want of a credential: git's update ended on signal 9 (SIGKILL, exit 137), which a git it ran died of. Git's own words above say which git and where.
  family bootstrap FAILED: git's update (ended on signal 9)
  ```
- Where the trace gave it and the update ended with another code:
  ```
    UPDATE KILLED, and not for want of a credential: a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update went on and ended with exit 128. Git's own words above say which git and where.
  family bootstrap FAILED: git's update (a git it ran died of signal 9)
  ```
- No credential advice in either case (`credential_advice`).

### A signal and B4 both told: the signal first

- Before, `fetch_members` returned one failure line, the signal's or the walk's stop's. Now `told_past` says each that is there, each with its own failure line.
- Where both are told, **the signal is said first**. Why:
  - It is git's order. A death past the holder's records (128) ends git's update, so git met the signal at an earlier gitlink and went on past it.
  - Git's own words, printed just above, say them in that order too.
  - The two cures do not depend on each other.
- Measured: the killed checkout in IRRS, then B4 at IRSS, with the credential. Before, the B4 line alone was said. Now:
  ```
    UPDATE KILLED, and not for want of a credential: a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update went on and ended with exit 128. Git's own words above say which git and where.
    UPDATE STOPPED, and not for want of a credential: git stopped at members/IRSS, whose fetch reached its remote and did not bring <commit>, ...
  family bootstrap FAILED: git's update (a git it ran died of signal 9), members/IRSS (git stopped at it)
  ```
- A `NOT CHECKED OUT` row or a whole-holder stop (`unfetchable`, `updated_none`) still comes first and alone, as for #998's signal (mutant S7's order is unchanged).

### With a token in use: the limit, said and pinned

The issue offered two ways. I measured both, and chose to say the limit and pin it with a row.

| measured (killed checkout, then IRSS's pin moved on, the token's fake value) | git 2.43.0 | git 2.47.1 |
|---|---|---|
| a directory trace target with the token's `-c` in argv: events holding the token | 1 (the top git's `start` argv) | 1 (the same) |
| the rewrite given in the environment instead, IRSS's origin an https url through a refused local proxy: events holding the token | 8 (the remote helper's `start` and `child_start` events give the url) | 0 |
| an `af_unix:dgram:` socket target read by a thread, in memory | 189 datagrams, 1 holding the token, the `child_exit` 137 for `git checkout` there; no file | 194, 1, the same; no file |

So:
- **Any file target writes the token**: in argv on both gits, and, moved to the environment, in git 2.43.0's remote helper url.
- **Git's one target that is no file, a Unix socket, works**, but this PR does not take it:
  - this process would have to read it for the whole update, and every git the update starts waits on it once Linux has queued 10 events (`net.unix.max_dgram_qlen` is 10 here), so a stalled reader hangs `make bootstrap`;
  - the socket's path must fit in the 107 bytes Linux takes. This harness's TMPDIR is 151 bytes, and a socket path under this session's scratchpad (114 bytes) was refused (`AF_UNIX path too long`);
  - git for Windows builds without it (read, not run).
  It would be a second trace mechanism, and is better as its own issue if wanted.
- **What the docstrings now say:**
  - `ended_on`: with a token in use the exit code is all there is, and a signal before a later failure is not told.
  - `credential_advice`: the limit and the measurement.
  - `run_update`: points at both.
- **The row:** `test_with_a_credential_in_use_a_signal_is_told_only_from_git_s_code`.
  - `[alone]`: the killed checkout alone is named, from git's own 137.
  - `[then-pin-moved-on]`: the killed checkout, then IRSS's pin moved on with the transport refused, is the credential's skip, the advice and ok.
  - Both: the token is in no output and no file under TMPDIR, with every caller trace on.

### Two docstrings corrected

- `ended_on`: git's update ends with a signal's code only where no gitlink after it fails, since it keeps the last code that is not 0. (It said the update "ends with that code".)
- `credential_advice`: a failed fetch does not always end the update with 128. A clone that fails gives 1 and the update goes on (rv996's NIT 2 on #1045), so the advice is dropped beside a signal even where a credential was also wanting. The run fails on the signal, and a run made again says the advice. (It said an update ending `128 + N` "met none".)
- The module docstring's (a) and (c), `fetch_members`, `run_update`, `fetched_in`, `GIT_DIED`, `UPDATE_KILLED` and `TRACE_ENV` say the same.

The probe is a pytest file kept out of the commit. It builds each state with `tests/test_family_bootstrap_stops_named.py`'s own helpers, runs the family root's `bootstrap.py --skip-members` (offline), and records the exit code, whether the credential advice is said, and whether the fake token is in any output or any file under TMPDIR. It also runs git's own update, traced, on a copy of the same state with no token. It was run at main `00f6d7f7` and at this head, on git 2.43.0 (`/usr/bin/git`) and on git 2.47.1 (a local build, first on PATH). Each cell is the verdict, the signal said, and the exit code:

| state (in IRRS, then in IRSS) | main 2.43.0 | head 2.43.0 | main 2.47.1 | head 2.47.1 |
|---|---|---|---|---|
| killed checkout, then IRSS's pin moved on, no credential | ok + advice (0) | FAILED signal 9 (1) | ok + advice (0) | FAILED signal 9 (1) |
| killed checkout, then IRSS's checkout refused, with the credential | ok + advice (0) | FAILED signal 9 (1) | ok + advice (0) | FAILED signal 9 (1) |
| killed checkout, then B4 at IRSS, with the credential | FAILED + B4 stop (1) | FAILED signal 9 + B4 stop (1) | FAILED + B4 stop (1) | FAILED signal 9 + B4 stop (1) |
| control: killed checkout alone, with the credential | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) |
| P3, then IRSS's pin moved on, no credential | ok + advice (0) | FAILED signal 11 (1) | ok + advice (0) | ok + advice (0) |
| P3, then B4, with the credential | ok + advice (0) | FAILED signal 11 (1) | ok + advice (0) | ok + advice (0) |
| control: P3 alone, with the credential | FAILED signal 11 (1) | FAILED signal 11 (1) | ok + advice (0) | ok + advice (0) |
| a hook that exits 137 (no signal), then IRSS's pin moved on, no credential | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |
| a hook that exits 137, alone, with the credential | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |
| SIGKILL at IRRS, then SIGTERM at IRSS, with the credential | FAILED signal 15 (1) | FAILED signal 9 (1) | FAILED signal 15 (1) | FAILED signal 9 (1) |
| SIGKILL at IRRS's `code` leg, then SIGTERM at its `spec` leg, with the credential | FAILED signal 15 (1) | FAILED signal 9 (1) | FAILED signal 15 (1) | FAILED signal 9 (1) |
| the same two legs, then IRSS's pin moved on, no credential | ok + advice (0) | FAILED signal 9 (1) | ok + advice (0) | FAILED signal 9 (1) |
| token in use: killed checkout, then IRSS's pin moved on | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |
| token in use: killed checkout alone | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) | FAILED signal 9 (1) |
| token in use: killed checkout, then B4 | FAILED + B4 stop (1) | FAILED + B4 stop (1) | FAILED + B4 stop (1) | FAILED + B4 stop (1) |
| token in use: killed checkout, then IRSS's checkout refused | ok + advice (0) | ok + advice (0) | ok + advice (0) | ok + advice (0) |

Read across the table:
- **Every no-token row of the probe that holds a signal now fails naming it, with no advice** (the rows that said `ok` with the advice, and the one that named SIGTERM): both killed-checkout rows (pin moved on, checkout refused) on both gits, and P3 (then IRSS's pin moved on, then B4) on git 2.43.0. P3 on git 2.47.1 is not a crash there (git refuses the line and exits 128, so nothing is killed), so its rows read as before.
- **Killed checkout, then B4:** the B4 line alone was said at main. Now both are said, the signal first.
- **A hook's own code is no signal:** a `post-checkout` hook that exits 137 reads the same at main and at this head: `ok`, with the advice.
- **The first signal is the first git met:** SIGKILL at IRRS, then SIGTERM at IRSS (and the two legs of IRRS) named SIGTERM at main, from git's update's own last code, and name SIGKILL now.
- **A signal in an update that exits 0 is not said.** A `reference-transaction` hook that kills IRSS's `git fetch` once, after it wrote the ref (git then finds the commit reached, checks it out and exits 0): measured at this head on both gits (main reads no trace, so it says the same), the run says `every member and leg fetched` and `family bootstrap ok`, exit 0. The trace there holds a `git fetch` `child_exit` of 137, and the row `test_a_git_killed_in_an_update_that_exits_0_is_not_said` pins that nothing is said (mutant M7).
- **With a token in use nothing changed**, on purpose (the four `token in use` rows): the signal is told only where its code is git's last.
- **The fake token** was in no output and in no file under TMPDIR in any run of any row (probe lines `token in output: False`, `token in temp files: []`).

All in `tests/test_family_bootstrap_stops_named.py` (#954's and #1045's file), reusing its helpers and its style, with no assert that joins conditions with `and`. The module docstring gains the new rows' list, and the P3 state moved into a `p3()` helper so two rows build it. 11 new rows (37 before, 48 after).

The issue's Verify rows (fail at main):
- `test_p3_before_a_later_failure_fails_naming_the_signal`: P3, then IRSS's pin moved on, no credential, git 2.43.0: exit 1, signal 11 (SIGSEGV) named, no advice, FAILED. Skipped where git does not crash, as #1045's P3 row is.
- `test_a_killed_checkout_before_a_later_failure_fails_naming_it[pin-moved-on-no-credential]` and `[checkout-refused-with-the-credential]`: the killed checkout (a `post-checkout` hook sending SIGKILL to its parent, `kill -s KILL $PPID`), then IRSS's pin moved on with no credential, and then IRSS's checkout refused with the credential. Each exits 1, names signal 9 (SIGKILL), gives no advice. Every POSIX git.

The choice rows (fail at main):
- `test_a_signal_and_b4_after_it_are_both_named_the_signal_first`: the killed checkout, then B4 at IRSS, with the credential: both lines, the signal first, and the failure line names both.
- `test_the_first_signal_is_named[two-members]` and `[two-legs]`: SIGKILL, then SIGTERM, at two members and at two legs of one member. The first by time is named (SIGKILL), where git's update's own last code, and the files' order, name SIGTERM.

The controls and the limit (pass at main, as intended, so they hold the rest as it was):
- `test_a_killed_checkout_alone_is_named_as_before`: #998's line, word for word.
- `test_a_hook_s_own_code_is_no_signal`: a hook that exits 137 is not a signal.
- `test_a_git_killed_in_an_update_that_exits_0_is_not_said`: the `reference-transaction` row above.
- `test_with_a_credential_in_use_a_signal_is_told_only_from_git_s_code[alone]` and `[then-pin-moved-on]`: the token's limit, pinned. Alone, the killed checkout is named from git's own 137. Then IRSS's pin moved on, the run is the credential's skip, the advice and `ok`, as before. Both: the fake token is in no output and in no file under TMPDIR, with every caller trace variable on.

The controls the issue names that already existed are untouched and pass: the signal alone (`test_an_update_ended_on_a_signal_fails_naming_it`, the P3 row), the credential's skip (`test_the_credential_s_skip_is_as_it_was`) and `test_a_code_no_signal_gives_is_read_as_before[exit-128]` and `[exit-255]`.

**Failing at main:** main's `bootstrap.py` bytes (sha256 `50a16751...`) against this branch's 48-row file, git 2.43.0: **6 failed, 42 passed**. The six are exactly the rows under "fail at main" above (the P3 row, the two killed-checkout rows, the both-named row and the two first-signal rows). They pass with the change.

Each mutant applied alone to the change's `bootstrap.py` (a script replaces one exact string and asserts it occurs once), the stops-named file run on git 2.43.0 (`/usr/bin/git`) through the lane's runner, the file restored from the head's bytes and its sha256 checked after each. All on the final 48-row file (M1 to M3 were run once on the first 47 rows and again on the 48, with the same rows failing).

| | mutant | result |
|---|---|---|
| M1 | the trace read dropped (`Trace(fetched_in(events))`) | killed: 6 failed, 42 passed |
| M2 | `128 + N` read as `>= 128` (`GIT_DIED <= code`) | killed: 31 failed, 17 passed |
| M3 | a `child_exit` of a child that is not `git` taken (`git_child` accepts any `child_start`) | killed: 1 failed (the hook row) |
| M4 | the last signal for the first | killed: 2 failed (`two-members`, `two-legs`) |
| M5 | the advice still printed beside a signal | killed: 10 failed |
| M6 | the sort by time dropped (file order) | killed: 1 failed (`two-legs`) |
| M7 | the exit-0 gate dropped (`signal_met` tells a signal where git exited 0) | killed: 1 failed (the `reference-transaction` row) |
| M8 | a child paired by `child_id` alone, without the writing git's `sid` | killed: 1 failed (the hook row) |
| M9 | B4 dropped beside a signal | killed: 1 failed (the both-named row) |
| M10 | B4 said before the signal | killed: 1 failed (the both-named row) |
| M11 | the "went on and ended with exit R" text never said | killed: 6 failed |
| M12 | that text always said | killed: 5 failed |
| M13 | git's update's own exit code asked before the trace | killed: 2 failed |
| M14 | the failure line always "ended on signal N" | killed: 6 failed |
| M15 | the `child_exit` kind check dropped from `died_in` | **survives**: 48 passed |

**M15 is equivalent, and the check stays as a reader's help.** `died_in` only takes an event whose `(sid, child_id)` is one `child_start` named for a git, and only a `child_exit` event carries a `code`. A `child_start` event has none, so `died_of(None)` is None whether or not the kind is asked. No input reaches a different answer.

All through the lane's runner (the real `gh` replaced by a stub, every `LANES_*` variable cleared, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`), at most two at once, never the full suite. Git 2.43.0 is `/usr/bin/git`; git 2.47.1 is a local build, first on PATH. `bootstrap.py`'s sha256 began `f1c156e29d07e149`, which the runs recorded and the committed file has (the test file's begins `1d558af665c3`).

| run | git | result |
|---|---|---|
| `tests/test_family_bootstrap_stops_named.py` (48 rows) | 2.43.0 | **48 passed** |
| the same | 2.47.1 | **46 passed, 2 skipped** (both P3 rows: that git exits 128 there, not 139) |
| main's `bootstrap.py` bytes, this branch's 48 rows | 2.43.0 | 6 failed, 42 passed (the rows listed under Tests) |
| the probe, after, 17 states | 2.43.0 and 2.47.1 | 17 passed each |
| group A: the stops-named file, `test_family.py`, `test_update_shape_kept_helper.py`, `test_family_holder_remedies_compose.py`, `test_family_gitmodules_values.py`, `test_pinned_copy_text.py`, `test_repo_hygiene.py` | 2.43.0 | **707 passed** |
| group B: `test_family_holder_bootstrap_texts.py`, `test_family_bootstrap_gate_order.py`, `test_bootstrap_without_git.py`, `test_family_unnamed_gitlink.py`, `test_family_config_appends.py`, `test_family_member_make_jobserver.py` (#1023's), `test_family_siblings_bootstrap_unreadable.py`, `test_family_head_lost_gitmodules.py`, `test_family_members_not_a_list.py`, `test_family_makefile_siblings.py`, `test_bootstrap_spelling.py`, `test_template_text_placeholders.py`, `test_no_auto_maintenance.py`, `test_agents_md_codes.py`, `test_cli_reference.py` | 2.43.0 | **1010 passed, 3 skipped** (GNU make's jobserver spelling here, and two rows that need a session without `GIT_CONFIG_GLOBAL`) |
| the 15 mutants | 2.43.0 | see Mutants |

Both groups and every file that names the family root's `bootstrap.py` (`grep -l 'family-root/scripts/bootstrap' tests/*.py` gives two files, both in the groups), `test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`) are in those two groups. No other test pins the functions or lines this changes (a grep of `tests/`, `docs/` and `scripts/` for them finds only a docstring's mention in `test_family_holder_bootstrap_texts.py`, in group B, and an unrelated workflow-validator row). The runs were made at main `00f6d7f7`'s tree plus this change, before main moved (see Merges).

`git merge-tree --write-tree` of this head `73906948` (parent main `00f6d7f7`), after fetching with `gc.auto=0` and `maintenance.auto=false`:

| against | result |
|---|---|
| `origin/main` `4a6b9afde` (3 commits past `00f6d7f7`) | clean (tree `ad8dab73`); none of the 3 touches `bootstrap.py` or the stops-named file |
| #1062 `fix/1040-windows-start-errors` `957ba038` (touches the family root's `bootstrap.py`, in `git_not_started` only) | clean (tree `70c5a715`); the merged `bootstrap.py` parses, and holds `died_in` and #1062's `4551` |
| #1075 `fix/1055-siblings-untraced` `c3cf5154` (lane 5's: `siblings.py`, `tests/test_family_siblings_untraced.py`, `tests/test_update_shape_kept_helper.py`) | clean (tree `6770fa61`); its `siblings.py` imports `untraced` from `bootstrap.py`, which this does not touch |

The other open PRs (#1073, #1072, #1071, #1070, #1069, #1068, #1064, #1031) touch neither file.

`templates/family-root/scripts/bootstrap.py` is in `FAMILY_COPIED_VERBATIM`, so each holder gets one `upstream-changed` row for it, as for #1045. The only import is unchanged (`signal` came with #998), and `siblings.py`'s import list from it is unchanged. No Windows or macOS run was made, since CI is Linux only (#597).

Review: rv1076, lane 5's independent reviewer, not the writers (w1056 and f1056): READY at `73906948` (issuecomment-6302104583) with no MUST or SHOULD; of its three NITs, the body's is fixed, and the other two (a docstring clause on a member's failed clone, and a text no row reaches) go to a follow-up. CI (Brett Heap's ruling 27): H0 is the head 7390694 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 9dae534 over main bccae28: the family_bootstrap_stops_named, repo_hygiene, agents_md_codes, cli_reference sets, 218 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 #1056

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 added a commit that referenced this pull request Oct 23, 2026
…y a member's failed clone ends git's update with 1 before any checkout but a leg's lets it go on, and `killed_said` says git's update was itself killed, not that it went on (#1085) (#1088)

The two texts #1076 (#1056) left in the family root's `bootstrap.py` say what git does. A member's clone that fails ends git's update before any checkout, where a leg's lets it go on, and a run whose git was itself killed is not said to have gone on. This is #1085, found by rv1076, lane 5's independent review of #1076 (its NITs 1 and 2).

Round 1 was one commit, on main `c13c3985` (where #1076 landed); round 2 (below) adds a second. It changes two files: the family root's `templates/family-root/scripts/bootstrap.py` (docstrings, and one line of `killed_said`) and `tests/test_family_bootstrap_stops_named.py` (one row). 2 files, 55 insertions, 14 deletions.

### NIT 1: two docstrings, no behaviour

- **`credential_advice`** said "a clone that fails, a member's or a leg's, gives 1, and git's update goes on". That is true of a leg's clone and not of a member's. It now says:
  - a member's fetch that fails ends the update with 128 (as before);
  - a member's clone that fails a second time, git having retried it, ends the update with 1 before any gitlink at that level is checked out (`quickstop`, git's `update_submodules`);
  - a leg's clone that fails gives its member's update 1, and git's update goes on (rv996's NIT 2 on #1045, kept).
- **`ended_on`** said `128 + N` is the update's code "where a git it ran died of signal N ... and no gitlink after it failed". A member's `git submodule--helper clone` that dies of signal N never gives that code: git retries it, and where the retry fails too the update ends in `quickstop`'s 1, even with nothing after it. The docstring now says so, and what that reads as:
  - **traced (no token in use):** `died_in` names the signal, since `git submodule--helper clone` is a git the update ran, and `signal_met` asks the trace first;
  - **with a token in use** (the update is not traced, #996): the update's code is 1, which reads as the credential's skip, the advice and `ok`, the limit `credential_advice` already says for a signal before a later failure.

### NIT 2: `killed_said`, one line

Where git's trace gave a git's signal (`Died(9, 137)`) and git's update returned `-9` (Python's code for git itself killed), the line said:

```
a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update went on and ended with exit -9. Git's own words above say which git and where.
```

git's update did not go on and end: it was killed. It now says:

```
a git that git's update ran died of signal 9 (SIGKILL, exit 137), and git's update was itself killed (exit -9). Git's own words above say which git and where.
```

Every other line is byte for byte as before. I compared main's and this head's `killed_said` over 23,814 `(Died, returncode)` pairs (for each update code from -70 to 299: the update's own `ended_on` signal, where it gives one, and a trace's signal N = 1 to 64 with code `128 + N`), with `ended_on` itself:

- `ended_on` differs for 0 codes of -70..299;
- the pairs where the code is the update's own (the 134 codes that give a signal, #998's texts): 0 differ;
- the pairs where a trace's signal and a positive update code differ: 0;
- 4,480 pairs differ, each with a negative update code and a trace code (70 codes x 64 signals), and each now says "was itself killed (exit -N)".

(A script that loads main's and the head's `bootstrap.py` from `git archive` copies; its output is in lane 5's scratch notes, `v118/w1085/runs/probe-texts.txt`.) The failure line `(a git it ran died of signal N)` is unchanged, as it is true.

### Texts that pin those words

A grep of `tests/`, `README.md`, `docs/` (the handbook included), `AGENTS.md` and `contracts/` for "went on and ended", "killed_said", "went on", "member's or a leg's", "goes on (rv996", "UPDATE KILLED" and "quickstop" finds the words only in `bootstrap.py` and in `tests/test_family_bootstrap_stops_named.py`. There, `WENT_ON` pins "went on and ended with exit {ended}" with `ended` 128 and 1 (real runs, a positive code), which is unchanged and still passes. The README, the docs and the handbook say none of them.

The stops-named file's module docstring gains one paragraph for the new row.

I read, and did not run (no network clone, no real gh, no token):

- **git 2.47.1**: the local build tree of the git the lane's runs use first on PATH (`builtin/submodule--helper.c`, `run-command.c`, `trace2/tr2_tgt_event.c`, `usage.c`, `git-submodule.sh`).
- **git 2.43.0**: upstream's `v2.43.0` tag, the same five files (`builtin/submodule--helper.c` is byte for byte the copy an earlier lane 5 writer kept, w648c on #682, sha256 `9383338c...`; the other four I fetched as raw files from the tag, a read, not a clone). `/usr/bin/git` here is Ubuntu's `2.43.0-1ubuntu7.3`, which I did not read the patches of.

| claim | source (2.47.1 line, 2.43.0 line) |
|---|---|
| a member's clone is a `git` child, `git submodule--helper clone` | `prepare_to_clone_next_submodule`: `child->git_cmd = 1`, args `submodule--helper clone` (:2141-2146, :2053-2058); trace2's `child_start` writes `git` first for a `git_cmd` child (`tr2_tgt_event.c` :358-361, :359-361) |
| a child killed by signal N gives 128 + N | `wait_or_whine` `code += 128` (`run-command.c` :576, :569); `finish_command` hands it on and writes `child_exit` (:986-993, :976-983) |
| that code never reaches the update's own | `pp_collect_finished` passes it to `update_clone_task_finished` (:1700-1703, :1690-1693), which answers 0 ("Retry scheduled", :2265) the first time and, the second, sets `quickstop` and answers 1 ("a second time, aborting", :2276-2280; 2.43.0 :2185-2189) |
| `quickstop` ends the update with 1 before any checkout | `update_submodules`: `if (suc.quickstop) { ret = 1; goto cleanup; }` (:2737-2740, :2621-2624), before the loop that runs `update_submodule` for each gitlink |
| a member's fetch that fails ends it with 128 | `run_update_procedure`: `return die_message(...)`, and `die_message` returns 128 (`usage.c` :230-238, :227-235); the loop stops at 128 only (:2772, :2647) |
| a leg's clone that fails gives its member's update 1 and git's update goes on | the same `update_submodules` runs in the member's own `git submodule--helper update` (`update_submodule`: `ret = run_command(&cp)`, :2700, :2584, which discards `die_message`'s 128 and returns the member's 1); the holder's loop keeps going on any code but 128 |
| `git submodule update` passes the helper's code on | `git-submodule.sh`: the helper is `cmd_update`'s last command, no `\|\| exit` (:380-403, :341-363) |

Two offline probes, local paths only (no network, no gh, no token, `git config` never run, `GIT_CONFIG_GLOBAL=/dev/null`; lane 5's scratch notes, `v118/w1085/probe/quickstop.sh`, with its outputs `out243.txt` and `out247.txt`), on git 2.43.0 and 2.47.1, give the same on both:

| state | git's update | what is in the work trees |
|---|---|---|
| holder with `members/A`, `members/B` (a url that is not there), `members/C` | exit 1, `Failed to clone 'members/B'. Retry scheduled`, then `a second time, aborting` | A and C cloned, **none checked out** (`.git` only), B empty |
| holder with `members/M` (its leg `legs/L2` has a url that is not there) and `members/N` after it | exit 1, `Failed to clone 'legs/L2'. Retry scheduled` | **M and N both checked out** (git's update went on past M's 1); M's leg L1 cloned, not checked out |

**Not run:**
- `bootstrap.py` itself over a member's clone killed by a signal. Git's update over one was measured in round 2 below (rv1088's probe, which I ran again), and the trace's `child_start` argv for it is from `tr2_tgt_event.c`;
- git 2.43.0's remote helper or any network clone;
- Windows and macOS (CI is Linux only, #597).

One thing I read and left alone, as the brief says every other line stays byte for byte: where the trace names a signal and the update's code is a positive 1 from `quickstop`, `killed_said` still says "git's update went on and ended with exit 1". It ended there rather than went on. It is a wording nit of the same kind, not a behaviour; I did not change it here.

`test_an_update_itself_killed_after_a_signal_is_not_said_to_go_on` (new; 48 rows before, 49 after). It loads the family root's `bootstrap.py` as a module (`loaded`, from `tests/test_family_holder_bootstrap_texts.py`, which the stops-named file already imports from) and calls `killed_said(Died(9, 137), -9)`, as `signal_met` hands it its arguments. No run here reaches that line short of the OOM killer taking a checkout and later the top `git`. One assert, `said == KILLED_AFTER.format(...)`, no `and` or `or`.

Main's `bootstrap.py` bytes (sha256 `f1c156e2...`) against this branch's 49-row file, git 2.43.0: **1 failed, 48 passed**. The failure, from `v118/w1085/runs/mut-mainbytes.log`:

```
E         - s update was itself killed (exit -9). Git's own words above say which git and where.
E         + s update went on and ended with exit -9. Git's own words above say which git and where.
```

The other 48 rows pass on main's bytes (the old `WENT_ON` rows say only positive codes). At this head it passes.

Each applied alone to the change's `bootstrap.py` (a script replaces one exact string and asserts it occurs once), the stops-named file run on git 2.43.0 (`/usr/bin/git`) through `run9.sh`, the file put back from the head's bytes and its sha256 checked (`a52483c8...`) after each. Lane 5's scratch notes, `v118/w1085/runs/mutants-summary.txt`.

| | mutant | result |
|---|---|---|
| M1 | the new branch dropped (the ternary replaced by the old wording alone) | killed: 1 failed (the new row), 48 passed |
| M2 | the negative test inverted (`returncode > 0`) | killed: 7 failed, 42 passed: the new row and the six that say "went on" (both killed-checkout rows, P3, the both-named row, `two-members`, `two-legs`) |
| M3 | the old "went on and ended with exit N" kept for negative codes (the branch's text is the old wording, its test kept) | killed: 1 failed (the new row) |
| M4 | "went on" kept beside the new words (`went on and was itself killed (exit -9)`) | killed: 1 failed (the new row) |
| M5 | the test asks the trace's code, not the update's (`died.code < 0`) | killed: 1 failed (the new row) |
| M6 | the code not said (`was itself killed`) | killed: 1 failed (the new row) |

M1 and M3 behave the same (both give the old wording for a negative code) and differ in the text of the mutation only.

All through `run9.sh` (the real `gh` replaced by a stub, `GH_TOKEN` a fake, every `LANES_*` variable cleared, `EMAIL=service@opensoft.dev`, `GIT_SSH_COMMAND=false`, `GIT_CONFIG_GLOBAL=/dev/null`), at most two at once, never the full suite. At this head, `bootstrap.py` sha256 `a52483c8...`, the test file `2fb6b4dd...`.

| run | git | result |
|---|---|---|
| `tests/test_family_bootstrap_stops_named.py` (49 rows) | 2.43.0 (`/usr/bin/git`) | **49 passed** |
| the same | 2.47.1 (`git --version` printed `git version 2.47.1`; the lane's local 2.47.1 build first on PATH) | **47 passed, 2 skipped** (the two P3 rows: that git exits 128 there, where 2.43.0's crashes with 139) |
| `tests/test_family.py`, `tests/test_update_shape_kept_helper.py` (the two files `grep -l 'family-root/scripts/bootstrap' tests/*.py` gives) and `tests/test_family_holder_bootstrap_texts.py` | 2.43.0 | **328 passed** |
| `tests/test_pinned_copy_text.py` and the trio (`test_repo_hygiene.py`, `test_agents_md_codes.py`, `test_cli_reference.py`), with `test_template_text_placeholders.py` | 2.43.0 | **399 passed** |
| the six mutants and main's bytes | 2.43.0 | see above |

flake8 on `bootstrap.py` shows the 3 findings main has (E501 at line 7 and 2327, E127 at 1168), and on the test file the same 120 F811 findings main's file has (the fixture imports). New lines are 79 characters at most, ASCII only.

`git merge-tree --write-tree` of this head `88af1b7b8` against `origin/main`, fetched again with `-c gc.auto=0 -c maintenance.auto=false` just before the push (it moved once while this was open: `06cbd4789`, #1081, one test file), and against each open PR that touches `bootstrap.py`, read with `gh pr list --state open --json number,headRefName,files` (9 open; #1062 touches the family root's `bootstrap.py`; #1075's files are `siblings.py` and two test files; #1087, new since the first read, touches neither):

| against | result |
|---|---|
| `origin/main` `06cbd4789` (one commit past this branch's base `c13c3985`; it touches only `tests/test_doctor_gitlink_repairs.py`) | clean, tree `a10ffa31c` (against `c13c3985` the merge was this head's own tree, `deeab934f`) |
| #1062 `fix/1040-windows-start-errors` `957ba0386` (touches `bootstrap.py`, in `git_not_started`) | clean, tree `46f25219c`; the merged family root `bootstrap.py` parses, and holds `died_in`, `quickstop`, "was itself killed (exit" and #1062's `4551` |
| #1075 `fix/1055-siblings-untraced` `041f7831b` (lane 5's: `siblings.py`) | clean, tree `02b01b276`; the merged `siblings.py` imports `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap.py`, and all six are defined at module level in the merged `bootstrap.py` |

None of the other open PRs touches `bootstrap.py` or the stops-named file. This change defines and renames nothing a caller imports.

`templates/family-root/scripts/bootstrap.py` is in `FAMILY_COPIED_VERBATIM`, so each holder takes one `upstream-changed` row for it, as for #1045 and #1076. Nothing else is pinned. No Windows or macOS run was made.

rv1088 (#1088 issuecomment-6314332205) found the review READY with one NIT: the sentence this PR added to `ended_on` said git's update retries a killed member clone, and it does not always. This round is one commit on top of `88af1b7b8` (the branch was not rebased), docstring only, one file (`templates/family-root/scripts/bootstrap.py`, +8 -4). No behaviour changes, and `killed_said`'s "went on and ended with exit 1" line is left as it is (rv1088 found it true).

**Measured, offline, on git 2.43.0 and 2.47.1** (rv1088's `killprobe.sh`: a remote helper that SIGKILLs the `git submodule--helper clone` above it; I ran both modes again on both gits and got the same):

| the member's clone | git's update | work trees |
|---|---|---|
| killed on both attempts | exit 1: `Retry scheduled`, then `a second time, aborting` | A and C hold `.git` only, B empty: nothing checked out |
| killed once, its own `git clone` child left running (it writes `members/B/.git`) | exit 0: `Retry scheduled`, then `BUG: submodule considered for cloning, doesn't need cloning any more?`, no second attempt | A, B and C all checked out |

`ended_on` now says: git's update retries a killed member clone where it left no `.git` at the member's path, and where the retry fails too ends in `quickstop`'s 1 before any gitlink at that level is checked out; where its own `git clone` lived on and wrote that `.git`, git retries nothing and goes on, and exits 0 with every gitlink checked out (measured, git 2.43.0 and 2.47.1). "Read, not run" is now "measured". `signal_met` already says nothing for an exit of 0, and `credential_advice`'s "fails a second time, git having retried it" stays true, so neither changes.

No other text pins those words: a grep of `tests/`, `README.md`, `docs/` (the handbook included) and `AGENTS.md` for "retries it", "doesn't need cloning", "never gives that code" and "quickstop" finds them only in `bootstrap.py`, and the round-1 words "(read in git 2.43.0's and 2.47.1's submodule--helper.c, not run)" are gone everywhere. "not run" alone appears in other files only in unrelated senses (rv1088's re-check). flake8 shows the same 3 findings as before (the lines moved by 4).

**Runs** at this head, through `run9.sh` with `EMAIL=service@opensoft.dev` (`GIT_SSH_COMMAND=false` and `GIT_CONFIG_GLOBAL=/dev/null` set by the runner), the stops-named file and the trio at the same time:

| run | git | result |
|---|---|---|
| `tests/test_family_bootstrap_stops_named.py` | 2.43.0 (`/usr/bin/git`) | **49 passed** |
| `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_cli_reference.py` | 2.43.0 | **170 passed** |
| `tests/test_family_bootstrap_stops_named.py` (`git --version` printed `git version 2.47.1`, the lane's 2.47.1 build first on PATH) | 2.47.1 | **47 passed, 2 skipped** (the two P3 rows, as before) |

**Merges**, `git merge-tree --write-tree` of this head against `origin/main` re-fetched with `-c gc.auto=0 -c maintenance.auto=false`:

| against | result |
|---|---|
| `origin/main` `16f9a0e81` (#1087 and the commits since this branch's base; #1062 has landed, so main's `bootstrap.py` moved) | clean, tree `b3b454168`; the merged family root `bootstrap.py` parses and holds `died_in`, `git_not_started`, "was itself killed (exit" and the new BUG sentence |
| #1075 `fix/1055-siblings-untraced` `962ae43c1` (its head moved since round 1) | clean, tree `a4c72197c`; the merged `siblings.py` imports `REWRITE_TARGETS`, `credential`, `member_make_environment`, `member_path`, `members_of` and `untraced` from `bootstrap.py`, and all six are defined in the merged `bootstrap.py` |

The other open PRs touch neither `bootstrap.py` nor the stops-named file. #1088 stays a draft.

Review: rv1088, lane 5's independent opus reviewer, which wrote none of #1088: READY at `26f49d85`, H0 of this landing (issuecomment-6316937429), with no MUST or SHOULD; its one body NIT is fixed in the body. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 26f49d8 passed tests, tests-git-bash and SonarCloud; the head dedc0da is a clean update-branch merge of main 0d76e4c, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 377691f over main 0d76e4c: the family_bootstrap_stops_named, repo_hygiene, agents_md_codes, cli_reference sets, 220 passed. Landed on Brett Heap's word of 2026-10-23T13:10:26Z, relayed to every lane by lane 3 (#473 issuecomment-6365697123): "merge v1.2.1 PRs once reviewed and green".

Closes #1085

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

Labels

None yet

Projects

None yet

1 participant