Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c2f2884
validate-family.py and family.py: a row's gitlink that no section of …
Oct 15, 2026
6f938ed
tests/test_family_head_lost_gitmodules.py: the rows `member-gitmodule…
Oct 15, 2026
6aace42
family.py and validate-family.py: `remove` refuses a holder with no `…
Oct 16, 2026
08b6245
Merge main 95780dd0 (#637, #607) into fix/611-validate-family-head-gi…
Oct 16, 2026
d660ec5
bootstrap.py and validate-family.py: a member no credential would che…
Oct 16, 2026
852c5c4
Merge #639's head 08b6245b (main 95780dd0, #637, #607) into fix/648-b…
Oct 16, 2026
159a08f
Merge branch 'main' into fix/611-validate-family-head-gitmodules
brettheap Oct 16, 2026
431aaf3
Merge #639's final head 159a08f0 (main 24769920 merged into it) into …
Oct 16, 2026
8b39cd1
Merge main c6122e8e, #639's squash, into fix/648-bootstrap-and-gate-o…
Oct 16, 2026
bad6c00
Merge main 2fac9790 into fix/648-bootstrap-and-gate-order: #634's per…
Oct 16, 2026
1a69505
bootstrap.py: a plain clone's update that dies at a gitlink no sectio…
Oct 16, 2026
45c512e
tests/test_doctor_legs_members_next.py: the bootstrap that cures noth…
Oct 16, 2026
23e1105
bootstrap.py: `.gitmodules` is read as git's own parser reads it, a m…
Oct 17, 2026
f74f640
bootstrap.py: a `.gitmodules` a merge left unmerged in the index is i…
Oct 17, 2026
5afb24b
Merge branch 'main' into fix/648-bootstrap-and-gate-order
brettheap Oct 17, 2026
fa69ead
bootstrap.py and validate-family.py: a `.gitmodules` value git dies o…
Oct 17, 2026
3c6a859
Merge #682's final head 5afb24b9 (landed as dc7d3fb)
Oct 17, 2026
c12b5f8
Merge main dc7d3fbb.. after #682 landed as dc7d3fb
Oct 17, 2026
e1379a9
bootstrap.py, validate-family.py and tests/test_family_gitmodules_val…
Oct 17, 2026
e32ed55
Merge origin/main (`b9b70692`) into fix/768-gitmodules-values: #820 (…
Oct 18, 2026
233a334
tests/test_family_gitmodules_values.py: a staged `git rm --cached` be…
Oct 18, 2026
20f74cb
the family root's bootstrap.py and validate-family.py: the credential…
Oct 18, 2026
77f8a6c
validate-family.py: `_check_gitlinks_registered` is handed `refused` …
Oct 18, 2026
a7f6c56
Merge #819's squash 1eff1fd after it landed, no conflicts
Oct 18, 2026
c3f8fa5
validate-family.py: `gitmodules-path-unnamed` takes the gitlink at a …
Oct 18, 2026
ad351e4
Merge branch 'main' into fix/839-holder-bootstrap-texts
brettheap Oct 18, 2026
fd0f8d9
Merge #876's final head ad351e4f (a7f6c569 and main def45de), before …
Oct 18, 2026
7d19507
Merge #876's squash 41baf92, ours for its files (its tree is F's)
Oct 18, 2026
445b1e5
validate-family.py and bootstrap.py: the holder's remedies, typed as …
Oct 18, 2026
9677ef9
Merge branch 'main' into fix/878-unnamed-gitlink-remedy
brettheap Oct 18, 2026
3965c5b
Merge #882's final head 9677ef91 (7d195075 refreshed over main 33c512…
Oct 18, 2026
e396411
Merge #882's squash 8148a38, ours for its files (its tree is F's)
Oct 18, 2026
8ea0c44
Merge main c9ca9a45 (#877's squash, after #769 and #845), bootstrap.p…
Oct 18, 2026
3b15de0
validate-family.py and bootstrap.py: a staged removal's section writt…
Oct 18, 2026
29083a5
validate-family.py: a staged removal writes no section into a `.gitmo…
Oct 18, 2026
528adbf
the family root's siblings.py and bootstrap.py: a member's path this …
Oct 18, 2026
32a5899
validate-family.py: a hand mount's text quotes both of git's refusals…
Oct 18, 2026
3c5f2ea
the family root's bootstrap.py: step (a)'s `member-unreadable` comes …
Oct 18, 2026
d81b870
Merge branch 'main' into fix/879-holder-remedies-compose
brettheap Oct 18, 2026
b41f722
Merge branch 'main' into fix/879-holder-remedies-compose
brettheap Oct 18, 2026
a35e8c0
Merge #889's final head b41f722b into fix/430-f-siblings-bootstrap-un…
Oct 18, 2026
8651841
Merge #889's squash 702cc23d into fix/430-f-siblings-bootstrap-unread…
Oct 18, 2026
2341b68
Merge main cf66102f into fix/430-f-siblings-bootstrap-unreadable afte…
Oct 19, 2026
6e756fb
Merge branch 'main' into fix/430-f-siblings-bootstrap-unreadable
brettheap Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 195 additions & 26 deletions templates/family-root/scripts/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@
Where (a) cannot start git, the run STOPS there, exit 2, refused
`git-not-runnable` as `validate-family.py` refuses it (#694): each
member's own bootstrap runs git too, so (b) would only say it again.
And where (a) or (b) cannot ask whether a row's member is checked
out, or whether it has a `Makefile`, because the operating system
refused the `stat` of its `.git` or its `Makefile` (a `members/` this
user cannot search), the run STOPS there, exit 2, refused
`member-unreadable`, naming the path and the cure (#430). On Python
3.14 that member read as NOT CHECKED OUT, or as having no Makefile,
and was skipped, and the run said `family bootstrap ok`; before it,
the run ended in a `PermissionError` traceback. A `family.yaml` whose
`stat` is refused is `family-manifest-unreadable`, never
`family-manifest-missing`.

THE FAMILY CONFERS NOTHING and neither does this command. It fetches, it
delegates, and it says what it could not reach.
Expand All @@ -132,13 +142,16 @@
from __future__ import annotations

import argparse
import errno
import os
import re
import shlex
import shutil
import stat
import subprocess
import sys
from collections import Counter
from collections.abc import Callable
from pathlib import Path
from typing import NamedTuple

Expand Down Expand Up @@ -216,6 +229,102 @@
#: HTTPS one for the duration of a single command.
REWRITE_TARGETS = ("https://github.com/", "git@github.com:")

#: The `OSError`s that `Path.is_file()` and `Path.exists()` answered False to
#: on Python 3.9 to 3.13 (pathlib's `_ignore_error`), because each means there
#: is nothing at the name to open: no such file, a part of the path that is
#: not a directory, a bad descriptor, a symlink loop, and Windows' "not
#: ready", "invalid name" and "cannot resolve". Every other one was raised.
_ABSENT_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP)
_ABSENT_WINERRORS = (21, 123, 1921)

#: What a path the operating system refused for ANOTHER reason than
#: permission is told: an I/O error, a name longer than the filesystem
#: takes, a file replaced while this ran. No `chmod` puts one right.
#: `validate-pins.py`'s words for the same refusal (#712, #430).
OTHER_REASON = (
"Where the operating system gave another reason, put a regular file this "
"user can open back in its place, or take away one nobody wants, and make "
"sure nothing is changing the directory while this runs. ")


def _is_a(path: Path, kind: Callable[[int], bool]) -> bool:
"""Whether `path` is there and of `kind` (`stat.S_ISREG`, `_anything`),
raising the `OSError` of a `stat` the operating system REFUSED (#430).

NOT `Path.is_file()` OR `Path.exists()`, whose answer depends on the
Python. Up to 3.13 they raised a refused `stat` (`PermissionError`), and
`make bootstrap` ended in that traceback; from 3.14 they answer False to
ANY `OSError`. On 3.14 a member mounted under a `members/` this user
cannot search was NOT CHECKED OUT, skipped, beside the advice to set a
credential, and one whose `Makefile` could not be reached had none, and
the run said `family bootstrap ok` over members nothing ran in. Asked
here, every Python answers as 3.9 to 3.13 did, and a refused `stat` is a
refusal that names the path (`unreadable`). A name the system cannot
take at all (a NUL in a row's `path:`) is no file, as `exists()`
answered it. This file's own copy, as each pinned copy keeps one (#370,
#610): `bump-leg.py` has the same.
"""
try:
mode = path.stat().st_mode
except OSError as exc:
if (exc.errno in _ABSENT_ERRNOS
or getattr(exc, "winerror", None) in _ABSENT_WINERRORS):
return False
raise
except ValueError:
return False
return kind(mode)


def _anything(mode: int) -> bool:
"""Any kind of file at all: what `Path.exists()` asks of a `.git`,
which is a file in a mounted member and a folder in a clone (#430)."""
return stat.S_IFMT(mode) != 0


def unreadable(code: str, named: str, exc: OSError, what: str) -> Refusal:
"""`code`'s refusal of a path this command must ask about and the
operating system will not let it: its `stat` refused (`_is_a`), as in a
folder this user cannot search (#430). The line is `validate-pins.py`'s
`_cannot_read`, `<named>: cannot be read (<the system's reason>)`, with
`named` relative to the holder and spelled; `what` says what was not
done, and why the question matters. THE ADVICE FOLLOWS THE CAUSE, in
`validate-pins.py`'s `_unreadable_file`'s words, word for word, since
every path this command asks about is inside the holder: a permission
(`PermissionError`, which is `EACCES` and `EPERM`) is put right by the
mode or the owner, and any other cause is `OTHER_REASON`, which is not
told to `chmod`."""
advice = (
"Make what this user may not open readable by this user: `chmod u+r` "
"on a file, `chmod u+rx` on a directory (and on every directory above "
"it inside the root), or fix who owns it. "
if isinstance(exc, PermissionError) else OTHER_REASON)
return Refusal(
code,
f"{named}: cannot be read ({exc.strerror or type(exc).__name__})",
f"Remediation: {what} A path this command cannot read is a question "
f"it cannot answer, never a pass. {advice}Then run the same command "
"again.")


def checked_out(root: Path, row: dict) -> bool:
"""Whether the row's member is checked out: its mount holds a `.git`,
asked as `_is_a` asks it, or the `member-unreadable` refusal naming that
`.git` where its `stat` was refused (#430). (a) asks it of every row
(`unfetchable`) and (b) before it runs anything in the member
(`run_member_target`); on Python 3.14 the answer was "not checked out",
and the member was skipped."""
git_dir = member_path(root, row) / ".git"
try:
return _is_a(git_dir, _anything)
except OSError as exc:
raise unreadable(
"member-unreadable", spelled(f"{row_path(row)}/.git"), exc,
"Each row's mount is asked whether git checked its member out "
"before anything is run in it, and this one could not be asked, "
"so the run stopped there: no member's command was run after "
"it.") from exc


def credential() -> tuple[str, str]:
"""`(source, token)`; `("none", "")` when the environment carries none."""
Expand Down Expand Up @@ -244,7 +353,9 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]:
fetched only where there is none and git exited 0. The advice to set a
credential is printed only where git asked for one
(`credential_advice`, #839). Raises the `git-not-runnable` refusal
where git cannot be started (#694)."""
where git cannot be started (#694), and `member-unreadable` where a
row's mount cannot be asked about, after git's own words (`unfetched`,
#430)."""
source, token = credential()
args = ["git"]
if token:
Expand All @@ -263,7 +374,7 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]:
if refusal is None:
raise
raise refusal from exc
left, stopper, modules = unfetchable(root, rows, proc.returncode == 0)
left, stopper, modules = unfetched(root, rows, proc, source, token)
if proc.returncode != 0:
fetch_incomplete(proc, source, token,
credential_advice(bool(left), stopper, modules))
Expand All @@ -287,6 +398,24 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]:
return []


def unfetched(root: Path, rows: list[dict],
proc: subprocess.CompletedProcess, source: str, token: str
) -> tuple[list[tuple[dict, str, str]], str | None,
Gitmodules]:
"""`unfetchable`, asked after git's update `proc` (#430). Where it
refuses a mount it cannot ask about (`member-unreadable`) and git did
not exit 0, git's own words for the update are printed first
(`fetch_incomplete`), with no advice: what git said of that mount is
the same fact, the refusal after it names the cure, and no credential
is it. It refused before they were printed, and they were lost."""
try:
return unfetchable(root, rows, proc.returncode == 0)
except Refusal:
if proc.returncode != 0:
fetch_incomplete(proc, source, token, "")
raise


def fetch_incomplete(proc: subprocess.CompletedProcess, source: str,
token: str, advice: str) -> None:
"""(a)'s report of a `git submodule update` that exited non-zero: git's
Expand Down Expand Up @@ -919,10 +1048,11 @@ def unfetchable(root: Path, rows: list[dict], exited_0: bool
same, and git's init asked where it stopped, for `fetch_members` to
ask why git updated none (`updated_none`): this returned before
`init_stop` there, so a run whose update git's init stopped said
`family bootstrap ok` (#879)."""
`family bootstrap ok` (#879). A row whose mount cannot be asked is
refused, `member-unreadable` (`checked_out`, #430)."""
left: dict[str, dict] = {}
for row in rows:
if not (member_path(root, row) / ".git").exists():
if not checked_out(root, row):
left.setdefault(row_path(row), row)
if not left and exited_0:
return [], None, Gitmodules(Counter(), {}, {})
Expand Down Expand Up @@ -982,8 +1112,21 @@ def updated_none(stopper: str | None, modules: Gitmodules) -> str | None:


def members_of(root: Path) -> tuple[str, list[dict]]:
"""`(name:, the member rows)` out of `family.yaml`, or a refusal.

A `family.yaml` whose `stat` the operating system refused is
`family-manifest-unreadable`, naming it and the cure (#430), never
`family-manifest-missing`, which Python 3.14 said of it, whose remedy
sends a person to the wrong kind of root."""
manifest_path = root / MANIFEST
if not manifest_path.is_file():
try:
present = _is_a(manifest_path, stat.S_ISREG)
except OSError as exc:
raise unreadable(
"family-manifest-unreadable", MANIFEST, exc,
f"{MANIFEST} declares this holder's members, and which they are "
"cannot be told without it.") from exc
if not present:
raise Refusal(
"family-manifest-missing",
f"{manifest_path} does not exist, so there is no declaration of "
Expand Down Expand Up @@ -1035,11 +1178,20 @@ def run_member_target(root: Path, row: dict, target: str) -> str | None:
project = spelled(row.get("project") or "?")
path = member_path(root, row)
named = spelled(target)
if not (path / ".git").exists():
if not checked_out(root, row):
print(f" [{project}] {spelled(path.name)}: NOT CHECKED OUT; skipped. "
"See the fetch above.")
return None
if not (path / "Makefile").is_file():
try:
has_makefile = _is_a(path / "Makefile", stat.S_ISREG)
except OSError as exc:
# One it cannot reach is no "no Makefile" (#430), which Python 3.14
# said of it, and `family bootstrap ok` after.
raise unreadable(
"member-unreadable", spelled(f"{row_path(row)}/Makefile"), exc,
f"`make {named}` was not run in {project}, nor in any member "
"after it.") from exc
if not has_makefile:
print(f" [{project}] no Makefile; skipped. A member without one is "
"not misconfigured — it simply has nothing for this command to "
"call.")
Expand Down Expand Up @@ -1148,6 +1300,29 @@ def said(self, line: bytes) -> None:
self.code = int(code)


def run_members(root: Path, rows: list[dict], target: str,
skip: bool) -> list[str]:
"""(b) Each member's own `make <target>`, in row order: the failure lines
(`run_member_target`), or none where `skip` (`--skip-members`) or no row
is declared. Raises `member-unreadable` at the first member whose mount
or `Makefile` cannot be asked about (#430), and no member after it is
run: `main` refuses the run there, exit 2."""
print(f"\n(b) each member's own `make {spelled(target)}`")
if skip:
print(" skipped (--skip-members)")
return []
if not rows:
print(" no members declared. A family with none is empty, not wrong: "
"`family.py add` is what puts one here.")
return []
failed = []
for row in rows:
failure = run_member_target(root, row, target)
if failure:
failed.append(failure)
return failed


def run_make(make: str, target: str, cwd: Path) -> tuple[int, int | None]:
"""(b) `make <target>` in a member, as `(make's exit code, the exit code
of the member's command it names)`, the second None where make names
Expand Down Expand Up @@ -1316,26 +1491,20 @@ def main(argv: list[str] | None = None) -> int:

print("\n(a) members and their legs")
failed: list[str] = []
if args.no_fetch:
print(" skipped (--no-fetch)")
else:
try:
try:
if args.no_fetch:
print(" skipped (--no-fetch)")
else:
failed += fetch_members(root, rows)
except Refusal as exc:
print(str(exc), file=sys.stderr)
return 2

print(f"\n(b) each member's own `make {spelled(args.members_make)}`")
if args.skip_members:
print(" skipped (--skip-members)")
elif not rows:
print(" no members declared. A family with none is empty, not wrong: "
"`family.py add` is what puts one here.")
else:
for row in rows:
failure = run_member_target(root, row, args.members_make)
if failure:
failed.append(failure)
failed += run_members(root, rows, args.members_make,
args.skip_members)
except Refusal as exc:
# `git-not-runnable` from (a) (#694), or `member-unreadable` from
# (a) or (b) (#430). Stdout first, so a log of both streams reads
# in the order the run went.
sys.stdout.flush()
print(str(exc), file=sys.stderr)
return 2

print()
if failed:
Expand Down
Loading
Loading