Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
32a5899
validate-family.py: a hand mount's text quotes both of git's refusals…
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
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
119 changes: 87 additions & 32 deletions templates/family-root/scripts/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@
no url for, nor where it read nothing from `.gitmodules`, both below,
since it fetched nothing then (#839).

Where every row's member is checked out and git's init in this
holder died all the same, at a gitlink this holder records with no
url or at a `.gitmodules` a merge left unmerged, git updated none of
them, so the run fails, saying where git stopped and what cures it,
as below, with no advice (#879). It said `family bootstrap ok`
there, beside git's exit 128, as a run with every member checked
out passes (#648), and gave the advice where git stopped at a
gitlink. This holder's records are the test, so an exit 128 they do
not show is read as before, the advice and `ok` where every member
is checked out: git stopped inside a member, at a gitlink in that
member's own index with no url, or at a gitlink of this holder's at
a commit no remote holds (`not our ref`) (lane 1's SHOULD 4 on
#889).

"every member and leg fetched" is said only where git exited 0 AND
every row's member is checked out (#648). A member left NOT CHECKED
OUT where no credential would check it out is said here instead, with
Expand Down Expand Up @@ -66,8 +80,8 @@
checks the member out whatever the clone chose. For the third and
the last, `validate-family.py --no-members` names a path two
sections give, or a gitlink in members/ that no row names; `git rm
--cached` takes out one outside members/, which the validator does
not read; and where the gitlink is a row's own, with one section,
--cached` takes out one outside members/, as every mode of the
validator says (#878); and where it is a row's own, with one section,
the line says to write its section back. Where git read the index's
or HEAD's file only up to a line it cannot parse, or up to a key it
takes no value for (a bare `url` line, which `git config --list`
Expand Down Expand Up @@ -95,9 +109,14 @@

(c) EXITS 0 unless a member's own command failed, or (a) left a member not
checked out that no credential would check out, or found a line or a
value in `.gitmodules` that git cannot read (#768). Any other member that
is not checked out is REPORTED and skipped, not a failure: it is the
state a missing credential leaves, and it is already reported in (a).
value in `.gitmodules` that git cannot read (#768), or found that,
with every member checked out, git's init in this holder died at a
gitlink this holder records with no url or at an unmerged
`.gitmodules` (#879); an exit 128 this holder's records do not show
is no failure here, as (a) says. Any
other member that is not checked out is REPORTED and skipped, not a
failure: it is the state a missing credential leaves, and it is
already reported in (a).
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.
Expand Down Expand Up @@ -144,6 +163,10 @@
NO_URL = "git has no url for that path"
EXITED_0 = "git exited 0 and checked nothing out there"
STOPPED = "git stopped at {}, a gitlink it has no url for"
#: What (a)'s line says where git updated no member, by whether `members:`
#: names any row: under "0 member(s)" it said no member was updated (lane
#: 1's NIT 5 on #889).
NONE_UPDATED = {True: "NO MEMBER UPDATED", False: "NOTHING UPDATED"}
#: The whys a `.gitmodules` git read only up to a line it cannot parse, or
#: up to a key with no value it stops at, may be the cause of, since what
#: it did not read is past that line (`Gitmodules.cut`, #856).
Expand Down Expand Up @@ -203,9 +226,13 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]:
"""(a) `git submodule update --init --recursive`, authenticated or not.

Where every member is checked out and git died on a value in
`.gitmodules`, or on a work tree file it cannot parse, so it updated
none of them, the one failure line it returns is for `.gitmodules`
(#768).
`.gitmodules`, or on a work tree file it cannot parse, or its init
died at a `.gitmodules` a merge left unmerged, so it updated none of
them, the one failure line it returns is for `.gitmodules` (#768,
#879); where its init in this holder died at a gitlink this holder
records with no url, it is for that gitlink (`updated_none`, #879).
Its line says no member was updated, or, where `rows` names none,
nothing was (lane 1's NIT 5 on #889).

Returns a failure line for each of `rows` whose member it leaves not
checked out where no credential would check it out (`unfetchable`), for
Expand Down Expand Up @@ -241,16 +268,19 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]:
+ ", ".join(f"{spelled(path)} ({reason(why, stopper, modules)})"
for _, path, why in left)
+ ". " + cures(left, stopper, rows, modules), file=sys.stderr)
elif modules.refused or modules.unreadable:
why = REFUSED if modules.refused else UNREADABLE
print(" NO MEMBER UPDATED, and not for want of a credential: "
+ reason(why, stopper, modules) + ". "
+ cures(left, stopper, rows, modules), file=sys.stderr)
return [f"{GITMODULES} (git cannot read it)"]
elif proc.returncode == 0:
return [f"{spelled(row.get('project') or '?')} (not checked out)"
for row, _, _ in left]
whole = updated_none(stopper, modules)
if whole is not None:
print(f" {NONE_UPDATED[bool(rows)]}, and not for want of a "
"credential: " + reason(whole, stopper, modules) + ". "
+ cures(left, stopper, rows, modules, (whole,)),
file=sys.stderr)
return [f"{spelled(stopper)} (git stopped at it)" if whole == STOPPED
else f"{GITMODULES} (git cannot read it)"]
if proc.returncode == 0:
print(" every member and leg fetched")
return [f"{spelled(row.get('project') or '?')} (not checked out)"
for row, _, _ in left]
return []


def fetch_incomplete(proc: subprocess.CompletedProcess, source: str,
Expand Down Expand Up @@ -317,9 +347,12 @@ def git_not_started(exc: OSError) -> Refusal | None:


def cures(left: list[tuple[dict, str, str]], stopper: str | None,
rows: list[dict], modules: Gitmodules) -> str:
rows: list[dict], modules: Gitmodules,
whole: tuple[str, ...] = ()) -> str:
"""What (a)'s line says cures the rows `left` names, one sentence for
each kind of cause among them (#648):
each kind of cause among them (#648), or, where it names none, what
cures `whole`, why git updated no member (`updated_none`, #879), one
reason or none:

- A `.gitmodules` a merge left unmerged in the index, which git reads
nothing from (`Gitmodules.unmerged`): the file resolved and added.
Expand All @@ -344,9 +377,9 @@ def cures(left: list[tuple[dict, str, str]], stopper: str | None,
`member-uninitialized` for the first row where every row's gitlink
is there, as with a stray or a hand mount, and `member-gitlink-absent`
where a row's is only in another case, which the first sentence
names. A gitlink outside `members/`, which no mode of the validator
asks about, is taken out of the index: no row names it, and with no
url no clone can check it out.
names. A gitlink outside `members/` is taken out of the index, as
every mode of the validator says (#878): no row names it, and with
no url no clone can check it out.
- A row git exited 0 over: the update that checks it out whatever the
clone chose. `--init` with the path registers a member a subset
clone left out, and `--checkout` overrides an `update = none`.
Expand All @@ -359,7 +392,7 @@ def cures(left: list[tuple[dict, str, str]], stopper: str | None,
for those: a section written back where the work tree holds no file
would be the only one in it, and `validate-family.py` reads HEAD's
file alone, not the index's."""
whys = {why for _, _, why in left}
whys = {why for _, _, why in left} | set(whole)
said = gitmodules_cures(whys, modules)
if modules.cut:
left = [entry for entry in left if entry[2] not in CUT_WHYS]
Expand Down Expand Up @@ -879,16 +912,17 @@ def unfetchable(root: Path, rows: list[dict], exited_0: bool
checked out is the missing credential's, and stays the skip it always
was. A path two rows give is said once. Where every row's member is
checked out and git did not exit 0, `.gitmodules` is read all the
same, for `fetch_members` to ask `refused` of."""
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)."""
left: dict[str, dict] = {}
for row in rows:
if not (member_path(root, row) / ".git").exists():
left.setdefault(row_path(row), row)
if not left and exited_0:
return [], None, Gitmodules(Counter(), {}, {})
modules = gitmodules_sections(root)
if not left:
return [], None, modules
config = submodule_config(root)
stopper = None if exited_0 else init_stop(root, modules, config)
found: list[tuple[dict, str, str]] = []
Expand All @@ -905,12 +939,9 @@ def row_reason(root: Path, path: str, modules: Gitmodules,
"""Why `unfetchable` says the member at `path`, not checked out, is
none a credential would check out (#648, #768), in the order it asks,
or None where it is the missing credential's."""
if modules.unmerged:
return UNMERGED
if modules.refused:
return REFUSED
if modules.unreadable:
return UNREADABLE
unread = file_reason(modules)
if unread is not None:
return unread
if recorded_gitlink(root, path) is None:
return NO_GITLINK
if not modules.given[path]:
Expand All @@ -922,6 +953,30 @@ def row_reason(root: Path, path: str, modules: Gitmodules,
return None if stopper is None else STOPPED


def file_reason(modules: Gitmodules) -> str | None:
"""Why git read nothing from `.gitmodules` (#648, #768), as (a)'s line
says it, or None where it read the file: a merge left it unmerged, it
gives a value git dies on, or it is a work tree file git cannot
parse."""
if modules.unmerged:
return UNMERGED
if modules.refused:
return REFUSED
return UNREADABLE if modules.unreadable else None


def updated_none(stopper: str | None, modules: Gitmodules) -> str | None:
"""Why git updated no member, where every row's member is checked out
and git did not exit 0, or None: what `file_reason` says, or `STOPPED`
where git's init in this holder died at a gitlink this holder records
with no url (`stopper`, `init_stop`), before it updated any (#879).
None where it asked for a credential, as a leg's fetch does.
`fetch_members` asks it only where `unfetchable` left no row, whose
line says why of each, so it asks nothing of the rows (lane 1's NIT 4
on #889: a check of them here decided nothing)."""
return file_reason(modules) or (None if stopper is None else STOPPED)


def members_of(root: Path) -> tuple[str, list[dict]]:
manifest_path = root / MANIFEST
if not manifest_path.is_file():
Expand Down
Loading
Loading