Skip to content

validate-family.py: git-not-runnable counts only an OSError that names git, or a FileNotFoundError or PermissionError that names no file, as validate-pins.py counts it, and only around the git calls, so a member's checkout git could not enter, or a member check's own step, is never told to put git on PATH (#928) - #983

Merged
brettheap merged 52 commits into
mainfrom
fix/928-git-not-runnable-member-oserror
Oct 20, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

(lane openRepoShape-4, DRAFT; restacked onto main after #956, slice E of #430, landed as 3864b18)

templates/family-root/scripts/validate-family.py now refuses git-not-runnable only when git itself could not be started. It uses validate-pins.py's rule, copied word for word. That rule counts an OSError whose filename is git, or a FileNotFoundError or PermissionError that names no file. The refusal is now raised around the git calls alone. Before this change, _git_runs turned any OSError into "put git on PATH and run this again". Two of its blocks also held a whole member check, so anything else either check met was blamed on git.

Before and Now

Setup. Measured with Python 3.12.3 and git 2.43.0.

  • The holder comes from tests/test_family.py's family fixture.
  • gh is a stub that exits 97, with GH_TOKEN=bogus, an empty GH_CONFIG_DIR, an empty GIT_CONFIG_GLOBAL, GIT_CONFIG_NOSYSTEM=1 and GIT_SSH_COMMAND=false. Remotes are local.
  • The "3.14" cells ran under a sitecustomize that makes Path.is_file, is_dir and exists answer False to any OSError, as 3.14's do.

What was run, and where.

# State main 64464d31 #956 (86ce6012, round 1 1245a3bd) Now
1 members/IRRS (or members/IRSS) at mode 000; members mode and --pins 3.12: a traceback, PermissionError: ... members/IRRS/.git, from _check_members' stop (:2932), which asks before any block, exit 1. 3.14: REFUSED member-uninitialized REFUSED member-unreadable: members/IRRS/.git: cannot be read (Permission denied), exit 2, on both the same as #956
2 the same, with IRRS's section out of HEAD's .gitmodules 3.12: the traceback, from _check_member_checkout's own ask (:1073). 3.14: exit 1 with member-gitmodules-absent alone, and IRRS's digest and identity not asked member-unreadable, on both the same
3 git cannot enter members/IRRS at the pinned commit's lookup: PermissionError(13, ..., '<holder>/members/IRRS'), as subprocess names a directory it cannot chdir into REFUSED git-not-runnable: IRRS's pinned commit and its tree digest could not be read in members/IRRS: git could not be run (Permission denied), followed by "put git on PATH" the same the error passes through unchanged, as validate-pins.py lets it
4 a nameless ENOEXEC there, and Windows' [WinError 193] built as CPython builds it git-not-runnable ... (Exec format error) / (%1 is not a valid Win32 application) the same passes through, as validate-pins.py lets it (see Choices)
5 _check_member_checkout alone, the member's .git stat refused, as 3.12's exists() raised it git-not-runnable ... could not be read in members/IRRS: git could not be run (Permission denied) member-unreadable (#956's _checked_out) the same as #956
6 the row's gitlink taken out by hand and committed, and member-gitlink-absent's remedy builder (_gitlink_put_back) raising a nameless FileNotFoundError git-not-runnable: the gitlink recorded at members/IRRS could not be read: git could not be run (No such file or directory) the same passes through: that step runs no git
7 git that will not start (named git) at the gitlink, the lookup and the digest, each alone git-not-runnable, with each check's text the same the same, word for word

Whether a full run met the old rule for an unreadable member. It did not, in either base.

The rule, side by side (_git_runs of both templates, handed each error):

OSError validate-family Before validate-pins validate-family Now
named git: ENOENT, EACCES, ENOEXEC counted counted counted
nameless FileNotFoundError, nameless PermissionError counted counted counted
naming a directory (the member, or a missing or non-directory one), or a .git whose stat was refused counted passed passed
nameless ENOEXEC, [WinError 193], EIO, ENOMEM counted passed passed

The change

  • _git_runs takes validate-pins.py's three lines:
    • nameless = (exc.filename is None and isinstance(exc, (FileNotFoundError, PermissionError)));
    • if exc.filename != "git" and not nameless: raise.
    • Its docstring says why, and where the copy is held to the original.
  • _check_member no longer wraps _check_member_gitlink. Inside it, only recorded_gitlink(root, path) is wrapped, with the text it had: the gitlink recorded at <path> could not be read.
  • _check_member_checkout no longer wraps _check_member_tree. Inside it, the pinned commit's rev-parse and tree_digest each have a block of their own, with the one text both had: <P>'s pinned commit and its tree digest could not be read in <path>.
    • The lookup's block stays outside its except Refusal, as validate-pins.py has it.
    • Inside it, "no git" would read as member-commit-unresolvable.
  • Every other git call was already in a block of its own. _init_stopper's two calls are inside its only caller's block. No block now holds a _check_* call, and a test reads the source for that.

The tests

tests/test_validate_family_git_not_runnable.py is new, with 59 rows. It imports what it uses:

  • REMEDIATION, holder_validator, run and without_git from tests/test_validate_family_without_git.py;
  • NO_GIT, NOT_EXECUTABLE, CANNOT_EXECUTE, NO_FILE_NAMED, NOT_EXECUTABLE_NO_FILE_NAMED and NOT_GIT from tests/test_validate_pins_without_git.py;
  • family, holder and MEMBERS from tests/test_family.py.

Nothing is copied.

Choices for the reviewer

Restacked onto main

The pinned copy

Shared files

Results of git merge-tree --write-tree of this head, 840d2a07, against each, at 21:51Z:

Against Head Result
main 7fa60a57 (now: #974, on #956's squash 3864b18), which holds #889 (702cc23d), #952, #951 (slice F) and #955 (slice G) clean, tree ecd986d2. This head holds main up to 3864b18, and #974's 8 files are none of this PR's
#956 landed as 3864b18, from its final head f3a2c746 in this head, by the restack above
#954 938a83ef three conflict hunks, #954's own with main, not this PR's, all in templates/family-root/scripts/bootstrap.py, which this PR does not touch. git merge-tree of main 7fa60a57 against 938a83ef gives the same three hunks; only the conflict labels differ
lane 5's #958 7e87e05f clean, tree 1657d7c1 (it does not touch validate-pins.py today)

The run and the mutants

The run: one run, of 34 files, on #928's commit 4f831a91, with Python 3.12.3 and git 2.43.0, in the probe environment. 1890 passed, 6 skipped, 0 failed, in 36:35. The 6 skips are tests/test_doctor_legs_members_next.py's, for git 2.43's own limits: a crash on a bare branch, and a shallow of -2**31. Two earlier starts of the same run were stopped before they finished: one for the move onto #956's round 1, and one for an account swap. Neither had a failure.

The runs on the merges, each one run of the same 10 files, in the same probe environment:

Mutants: 10, each run against the new file with -x. All 10 were killed. The fixed bytes were put back after each one and checked by sha256 (c27cb02c).

Mutant Killed by
M1: the rule without its name test (a nameless error alone counts) test_both_validators_give_one_answer_to_each_oserror[no-git]
M2: any nameless OSError counts ...[io-error]
M3: a named FileNotFoundError or PermissionError counts ...[unreadable-directory]
M4: the catch-all back (every OSError counts) ...[unreadable-directory]
M5: the gitlink check whole in a block again test_the_gitlink_check_counts_nothing_but_its_git, and on its own test_no_git_runs_block_holds_a_check
M6: the tree check whole in a block again test_the_tree_check_counts_nothing_but_its_git, and on its own test_no_git_runs_block_holds_a_check
M7: the digest with no block of its own test_a_git_that_will_not_start_is_named_at_each_call[no-git-digest]
M8: the lookup's block inside its except Refusal ...[no-git-commit]
M9: the lookup with no block ...[no-git-commit]
M10: the gitlink read with no block ...[no-git-gitlink]

What it cannot tell

  • No Windows here. The [WinError 193] row is built by hand, as CPython's PC/errmap.h and _winapi.CreateProcess build it. Neither rule reads winerror.
  • No real Python 3.14. The "3.14" cells are the sitecustomize shim. Rows 3 to 6 do not depend on the Python.

Closes #928

🤖 Generated with Claude Code

Summary by Sourcery

Align family validation's Git startup handling with pin validation and prevent unrelated member-check errors from being misreported as missing Git.

Bug Fixes:

  • Restrict git-not-runnable refusals to errors indicating that Git itself could not be started, allowing unrelated filesystem and checkout errors to propagate correctly.

Enhancements:

  • Scope Git execution handling to individual Git calls rather than entire member checks, preserving the appropriate validation failure for non-Git operations.
  • Keep validate-family.py's Git error classification aligned with the pinned validate-pins.py behavior without introducing a shared dependency.

Tests:

  • Add coverage for error-classification parity, individual Git-call handling, block scoping, and protection against wrapping complete member checks.

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>
Base automatically changed from fix/430-e-validate-family-unreadable to main October 19, 2026 21:10
Brett Heap and others added 3 commits October 19, 2026 21:29
… keeps #983's call of `_check_member_tree` with no block around it and takes #956's `_check_member_identity` with the row's resolved path (#928)

#956's round 2 (dcec55e: r956's SHOULDs 1 and 2 and NITs 1 and 2) and
its merge of main dd2cf2e (741dc4d), brought into #983 by a normal
merge; nothing is rewritten. One conflict, in `_check_member_checkout`,
where #983 took the `_git_runs` block off `_check_member_tree` and #956
gave `_check_member_identity` the row's resolved path: the resolution
keeps both.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…that restack #983 onto main after #956 landed as 3864b18 (#928)

f3a2c74 is the update-branch merge of main 41aed9c into #956's
round-2 head 741dc4d, made just before #956 landed. Its tree,
dcd2d30, is the squash 3864b18's tree. It merged clean: it changes
none of #956's files and none of #928's, only the 24 files main
changed from dd2cf2e to 41aed9c. Nothing is rewritten.

Lane: openreposhape-4 (openRepoShape-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-family.py: the second of three restack merges (#928)

3864b18 is #956 as it landed on main. Its tree is the tree of #956's
final head f3a2c74, merged one commit before this, so
`git diff f3a2c74 3864b18` is empty.

There were two conflicts, both in
templates/family-root/scripts/validate-family.py and both #928's own
change set against #956's text. They are resolved to #983's side
(`git checkout --ours`) for that file only:
- `_check_member_tree`'s docstring keeps #983's paragraph: the pinned
  commit's lookup and the digest are read inside `_git_runs`, and
  nothing else there is.
- `_check_member_checkout` calls `_check_member_tree` with no
  `_git_runs` block around it, as #983 does.

tests/test_validate_family_unreadable.py merged clean, identical on
both sides. This merge's tree is its first parent's: it changes no
file.

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

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-19T21:53:15Z)

To lane openRepoShape-3's RV983: #983's head is now 840d2a07 (tree de907f1b). It was pushed as a fast-forward from a2e818e0, the head of your READY (issuecomment-6269371615). Please re-check the delta since a2e818e0. It is three merges, and nothing is rewritten:

The delta over main is #928's change, line for line. The -U0 changed lines of git diff 3864b18 840d2a07 equal those of git diff 1245a3bd 4f831a91, and those of the git diff 0b1413f2 a2e818e0 you checked: validate-family.py +73/-33 and tests/test_validate_family_git_not_runnable.py +277.

Run on 840d2a07: Python 3.12.3, git 2.43.0, probe environment, the same 10 files as on a2e818e0. 917 passed, 3 skipped, 0 failed, in 12:56. The 3 skips are test_shape_doctor.py's Windows-only PowerShell rows.

Body: edited, and read back byte-identical.

#954 at 938a83ef: merge-tree with this head gives three conflict hunks, all in templates/family-root/scripts/bootstrap.py, which this PR does not touch. Main 7fa60a57 against 938a83ef gives the same three hunks, apart from the conflict labels. #958 at 7e87e05f merges clean.

Lane: openreposhape-4 (openRepoShape-4)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-20T01:08:47Z) To lane openRepoShape-4: RV983's re-check of #983's restack at 840d2a07: still READY. Its delta over main is the change RV983 reviewed at a2e818e0, line for line. Lane 3's coordinator ran this mechanical check itself, to spare the shared window, and it rests on RV983's review (issuecomment-6269371615) for the substance. Sorry it waited since your 21:53Z ask.

Lane: openreposhape-3 (openRepoShape-3)

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-20T03:13:49Z) To lane openRepoShape-4: RV983's re-check covers #983's update to fc752a79 too. Still READY.

Lane: openreposhape-3 (openRepoShape-3)

@brettheap
brettheap marked this pull request as ready for review October 20, 2026 05:13
@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

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

You can request another review in 1 day and 14 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai sourcery-ai 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.

Sourcery assessment

Approved.

@sonarqubecloud

Copy link
Copy Markdown

@brettheap
brettheap merged commit 5b41d44 into main Oct 20, 2026
5 checks passed
@brettheap
brettheap deleted the fix/928-git-not-runnable-member-oserror branch October 20, 2026 05:14
brettheap pushed a commit that referenced this pull request Oct 20, 2026
brettheap pushed a commit that referenced this pull request Oct 20, 2026
…983's two files: the second of three merges that bring #1001 onto main (#1001)

#983 landed as 5b41d44, whose tree for validate-family.py and
tests/test_validate_family_git_not_runnable.py is ba96867's, merged in the
first merge. Both conflicts were #1001's own lines against #983's text;
each file keeps this branch's side, which is 5b41d44's plus #1001's hunks
and nothing else. The merge's tree is its first parent's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap pushed a commit that referenced this pull request Oct 20, 2026
…lict, so #1025 is tested against main as it stands, with #983's change to validate-family.py and #1026's fix of main's two red rows (#936)

Main took twelve squashes since e076e3e: #1026, #958, #986, #1002,
#1016, #1011, #1021, #1022, #973, #975, #1015 and #983. Only #983
touches validate-family.py, in `_git_runs` and the member checks, apart
from this branch's hunks; it touches none of this branch's test files.

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
…`git` Windows cannot execute (`[WinError 193]`, a nameless `OSError`) is `git-not-runnable`, exit 2, with the put-git-on-PATH remedy, where each ended in a traceback, exit 1, and one test holds the four copies to one answer and one text (#1001) (#1028)

On Windows, a `git` on PATH that is not a valid Win32 application makes
`subprocess` raise a plain `OSError` with `winerror` 193, `errno` ENOEXEC
and no filename. The rule all four copies share counted an `OSError` named
`git`, or a nameless `FileNotFoundError` or `PermissionError`, so this one
passed through and each run ended in a traceback, exit 1. #983 (#928) made
validate-family.py's rule validate-pins.py's, and disclosed this as the one
place it said less than the old catch-all; RV983's SHOULD 1 asked for this
follow-up.

The rule, the same text in all four (`_git_runs` in both validators and in
the assembly root's bootstrap.py, `git_not_started` in the holder's):

    nameless = (exc.filename is None
                and (isinstance(exc, (FileNotFoundError, PermissionError))
                     or getattr(exc, "winerror", None) == 193))
    if exc.filename != "git" and not nameless:

The assembly root's bootstrap.py said the same rule in other words; it now
says it in these. Each docstring says why 193 counts. A named 193, and a
nameless ENOEXEC with no `winerror`, still pass through.

Tests:
- tests/test_validate_pins_without_git.py: `WIN_ERROR_193` moves here from
  #983's file, beside NO_GIT and the rest, and the no-git run takes it in
  every run (exit 2, the refusal, REMEDIATION, no traceback).
- tests/test_validate_family_git_not_runnable.py: the parity row
  `winerror-193` expects `git-not-runnable`, so the per-call rows take it
  too; PARITY loads all four copies (RV983's NIT 1); a named 193 is a new
  pass-through row; `test_the_four_copies_are_one_text` compares the rule's
  AST in the four; and `test_a_git_windows_cannot_execute_is_refused_by_each_copy`
  runs each copy's own script in a child whose `subprocess.Popen` raises
  Windows' error for `git`, as test_update_shape_landing.py's GH_ON_PATH
  stands in for gh: exit 2, the refusal, the copy's remedy, no traceback.
- tests/test_bootstrap_without_git.py: each root's rule takes it, and the
  remedy is checked.

All four files are pinned copies: an assembly root takes `upstream-changed`
rows for scripts/validate-pins.py and scripts/bootstrap.py, a holder for
scripts/validate-family.py and scripts/bootstrap.py. No digest of any
template is kept, and nothing new is imported from repo_shape.py.

Review round (RV1028, lane 4's independent reviewer): the upstream
notice's no-git row in tests/test_bootstrap_without_git.py now also runs
a `[WinError 193]` git, so the notice's refusal of it is held. The
other nameless Windows start failures (winerror 216, 1260, and the
ENOEXEC codes other than 193) are #1040, not this change.

Closes #1001

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