Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
37e8f1e
family.py: `add` refuses a `--member` whose owner or project does not…
brettheap Oct 12, 2026
8bcc57c
family.py: `add --dry-run` makes every refusal `add` makes before it …
brettheap Oct 12, 2026
423fb25
validate-family.py: a gitlink in `members/` or a `.gitmodules` sectio…
brettheap Oct 12, 2026
de628fe
tests/test_family_dry_run.py: `add --dry-run` is held to the `id:` a …
brettheap Oct 12, 2026
2fbce81
Merge branch 'main' into fix/family-member-re
brettheap Oct 12, 2026
5d56e75
family.py: `bump` and `remove` put the holder back when a hook, a loc…
brettheap Oct 12, 2026
1f86188
Merge branch 'main' into fix/family-member-re
brettheap Oct 12, 2026
6000de5
Merge branch 'fix/family-member-re' (#399) into fix/family-add-dry-ru…
brettheap Oct 12, 2026
c113175
Merge remote-tracking branch 'origin/main' (45e01f4) into fix/family-…
brettheap Oct 12, 2026
1d82455
tests/test_family_dry_run.py: `add --dry-run` is held to `member-dupl…
brettheap Oct 12, 2026
7062a47
Merge branch 'main' into fix/family-add-dry-run
brettheap Oct 12, 2026
209f8b3
Merge branch 'fix/family-add-dry-run' (#400, 7062a47) into fix/family…
Oct 12, 2026
369af2e
validate-family.py: a gitlink or a `.gitmodules` section at a case va…
Oct 12, 2026
7cca975
family.py: `remove`'s rollback checks the member out again only from …
Oct 12, 2026
e83cc21
Merge branch 'main' into fix/validate-family-unnamed-mounts
brettheap Oct 12, 2026
0737aa0
Merge origin/main (dd8d0ea, with #400's squash a92e60b) into fix/fami…
Oct 12, 2026
14431df
Merge branch 'fix/validate-family-unnamed-mounts' (#406, e83cc21) int…
Oct 12, 2026
39e27ff
family.py: `_offline_git` sets `GIT_ALLOW_PROTOCOL` to the empty stri…
Oct 12, 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
1,332 changes: 1,209 additions & 123 deletions scripts/family.py

Large diffs are not rendered by default.

44 changes: 39 additions & 5 deletions scripts/repo_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,9 +868,18 @@ def load_yaml(path: Path) -> Any:
# ---------------------------------------------------------------------------


def git_out(args: list[str], cwd: Path, binary: bool = False) -> Any:
def git_out(args: list[str], cwd: Path, binary: bool = False,
env: dict[str, str] | None = None) -> Any:
"""`git <args>` in `cwd`, its stdout, or a `git-failed` refusal.

`env` is the whole environment git runs in, None for this process's
own. A git that cannot be STARTED (none on PATH) raises `OSError` from
here, never a refusal: whether that is a question the caller cannot
answer is the caller's to say (#368).
"""
proc = subprocess.run(
["git", *args], cwd=str(cwd), capture_output=True, check=False
["git", *args], cwd=str(cwd), capture_output=True, check=False,
env=env,
)
if proc.returncode != 0:
raise Refusal(
Expand Down Expand Up @@ -1030,11 +1039,29 @@ def file_sha256(path: Path) -> str:
#: `<mode> SP <oid> SP <stage> TAB <path>` and `ls-tree` emits
#: `<mode> SP <type> SP <oid> TAB <path>` — the same shape with a DIFFERENT
#: third column, so the two cannot share one index.
#:
#: BOTH LISTINGS TAKE THE PATH LITERALLY (#367), with the global
#: `--literal-pathspecs`. A pathspec is a glob to `ls-files`, so a row whose
#: hand-edited `path:` was `members/*` read the first member's gitlink and
#: answered with it, a neighbour's commit, for every caller. Not
#: `:(literal)` magic on the path, which a caller's own exported
#: `GIT_LITERAL_PATHSPECS=1` switches off, and then it matches nothing.
GITLINK_LISTINGS = (
(["ls-files", "-s", "--"], 1, 2),
(["ls-tree", "HEAD", "--"], 2, None),
(["--literal-pathspecs", "ls-files", "-s", "--"], 1, 2),
(["--literal-pathspecs", "ls-tree", "HEAD", "--"], 2, None),
)

#: The two pathspec settings a caller's environment can carry that git
#: refuses BESIDE `--literal-pathspecs` ("global 'literal' pathspec setting
#: is incompatible with all other global pathspec settings", exit 128). Both
#: listings would then fail, and `recorded_gitlink` reads a failed listing as
#: "no gitlink here", so an operator who exports either would see every gate
#: refuse a gitlink that is there. They are left out of those two git calls
#: alone. `GIT_NOGLOB_PATHSPECS` stays: git takes it beside the literal
#: setting, and with it the path is literal anyway.
PATHSPEC_ENV_REFUSED_BESIDE_LITERAL = ("GIT_GLOB_PATHSPECS",
"GIT_ICASE_PATHSPECS")


def recorded_gitlink(repo: Path, path: str) -> str | None:
"""The commit the SUPERPROJECT records for the submodule at `path`.
Expand All @@ -1049,10 +1076,17 @@ def recorded_gitlink(repo: Path, path: str) -> str | None:
bump and passes a stale pin. HEAD remains the fallback, for a path the
commit records and the index does not hold at stage 0 — one the index
never had, and one a conflicted merge holds only at stages 1, 2 and 3.

`path` IS TAKEN LITERALLY (#367): `members/*` is the entry of that name,
which no index holds, and never every gitlink a glob would match. A git
that cannot be started raises `OSError` through this, never "no
gitlink": only a listing git RAN and refused is read as nothing (#368).
"""
env = {name: value for name, value in os.environ.items()
if name.upper() not in PATHSPEC_ENV_REFUSED_BESIDE_LITERAL}
for args, oid_at, stage_at in GITLINK_LISTINGS:
try:
out = git_out([*args, path], cwd=repo)
out = git_out([*args, path], cwd=repo, env=env)
except Refusal:
continue
for line in out.splitlines():
Expand Down
510 changes: 461 additions & 49 deletions templates/family-root/scripts/validate-family.py

Large diffs are not rendered by default.

34 changes: 24 additions & 10 deletions tests/test_family_add_refusals.py
Original file line number Diff line number Diff line change
Expand Up @@ -1588,8 +1588,7 @@ def ghost_remediation(removal: str, off_disk: bool = False) -> str:
"and a recursive clone of the holder follows whichever section for "
"that path comes last in the file. A section somebody wrote is not "
"this tool's to drop or rewrite, so take it out by hand, in a POSIX "
f"shell — {removal}, then `git add .gitmodules` — or, if it was a "
"member, with `family.py remove`; then re-run."
f"shell — {removal}, then `git add .gitmodules` — and re-run."
+ (" With no .gitmodules in the working tree, put back the copy git "
"read first: `git checkout -- .gitmodules` (the index's), or `git "
"checkout HEAD -- .gitmodules`." if off_disk else ""))
Expand Down Expand Up @@ -1734,7 +1733,9 @@ def test_a_section_that_is_not_another_name_at_the_mount_path_is_added_past(
member's own name for its own path — what `remove` leaves behind when a
ghost came after the member's section (#300) — which git rewrites in
place. Each commits ONE section for the mount path, with the member's
url, and the holder passes its own gate."""
url. The holder passes its own gate, except where the other section's
path is one no row names, which is now a finding of its own (#361) and
the only one: the add did not make it, and did not make it worse."""
if other is not None:
path, name = other
gitmodules_with(holder, (f'[submodule "{name}"]\n\tpath = {path}\n'
Expand All @@ -1751,7 +1752,16 @@ def test_a_section_that_is_not_another_name_at_the_mount_path_is_added_past(
f"submodule.members/{SPARE}.url",
cwd=holder).stdout.strip() == url
assert git("status", "--porcelain", cwd=holder).stdout == ""
assert validate(holder).returncode == 0
checked = validate(holder)
if other is None or other[0] == f"members/{SPARE}":
assert checked.returncode == 0, checked.stderr + checked.stdout
else:
assert checked.returncode == 1, checked.stderr + checked.stdout
assert [line.split(":", 1)[0] for line in checked.stderr.splitlines()
if line.startswith("FINDING")] == [
"FINDING gitmodules-path-unnamed"], checked.stderr
assert (f".gitmodules gives the path {other[0]} to the submodule "
f'named "{other[1]}"') in checked.stderr
offline.assert_left_nothing()


Expand Down Expand Up @@ -1884,26 +1894,30 @@ def test_a_validator_with_no_git_to_run_refuses_instead_of_crashing(
cannot answer — a refusal, exit 2 — never the traceback that exits 1, a
finding's code. The holder's own validator is run IN THIS PROCESS with
its one git call failing as a missing program does, so no PATH is
changed for anything else."""
changed for anything else. The code is `git-not-runnable`, the one the
whole validator gives a missing git (#368,
`tests/test_validate_family_without_git.py`), and no longer
`gitmodules-unreadable`, which means a file git ran and could not
read."""
monkeypatch.setattr(sys, "path", list(sys.path))
spec = importlib.util.spec_from_file_location(
"holder_validate_family", holder / "scripts" / "validate-family.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

def no_git(args, cwd, binary=False):
def no_git(args, cwd, binary=False, env=None):
raise FileNotFoundError(2, "No such file or directory", "git")

monkeypatch.setattr(module, "git_out", no_git)
code = module.main(["--root", str(holder), "--no-members"])
out, err = capsys.readouterr()
assert code == 2, out + err
assert err.splitlines() == [
"REFUSED gitmodules-unreadable: .gitmodules could not be read as git "
"REFUSED git-not-runnable: .gitmodules could not be read as git "
"reads it: git could not be run (No such file or directory)",
"Remediation: put git on PATH and run this again; this check reads "
"the file the way git does, and nothing could fetch this holder's "
"submodules without git either."]
"Remediation: put git on PATH and run this again. This check reads "
"the holder the way git does, and nothing could clone it or fetch "
"its members without git either."]


def test_every_key_of_a_ghost_goes_so_a_later_member_of_its_name_inherits_none(
Expand Down
Loading
Loading