Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
80 changes: 70 additions & 10 deletions scripts/family.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
import os
import re
import runpy
import shlex
import shutil
import stat
import subprocess
Expand Down Expand Up @@ -1133,11 +1134,22 @@
return entries


def _submodule_paths(root: Path) -> dict[str, str]:
"""`{name: path}` of every submodule the holder's `.gitmodules` names,
read from where `git submodule add` reads it: the file in the working
tree, or the index's copy where there is none, or HEAD's where the index
has none either."""
def _submodule_paths(
root: Path) -> dict[str, tuple[str, str | None, tuple[str, ...]]]:
"""`{name: (path, url, keys)}` of every submodule the holder's
`.gitmodules` names, read from where `git submodule add` reads it: the
file in the working tree, or the index's copy where there is none, or
HEAD's where the index has none either. `url` is None for a section that
gives no url; a section that gives no path is no submodule and is not
listed. `keys` is every key the section holds, path and url included,
once each, in the order git lists them.

THE ONE READER OF THE FILE, for both questions `_checked_mount_path`
asks of it: the path a NAME is given (`member-name-taken`, #242), and
the names a PATH is given, each with its url and its keys
(`member-path-registered`, #300). A second reader could read a second
copy of the file.
"""
if os.path.lexists(root / GITMODULES):
source = ["--file", GITMODULES]
else:
Expand All @@ -1152,17 +1164,18 @@
return {}
listed = git_out(["config", "-z", *source, "--list"], cwd=root,
binary=True)
found = {}
found: dict[str, dict[str, str]] = {}
for record in listed.split(b"\0"):
key, _, value = record.decode("utf-8",
"surrogateescape").partition("\n")
section, _, variable = key.rpartition(".")
if section.startswith("submodule.") and variable == "path":
found[section[len("submodule."):]] = value
return found
if section.startswith("submodule."):
found.setdefault(section[len("submodule."):], {})[variable] = value
return {name: (keys["path"], keys.get("url"), tuple(keys))
for name, keys in found.items() if "path" in keys}


def _checked_mount_path(root: Path, project: str, path: str) -> None:

Check failure on line 1178 in scripts/family.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 22 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaEuWi1wQHyfEi5KPuBZ&open=AaEuWi1wQHyfEi5KPuBZ&pullRequest=317
"""Every refusal about the MOUNT PATH, made before the member is read or
anything is mounted (#242).

Expand Down Expand Up @@ -1190,6 +1203,18 @@
the member under a new one (`members/IRRS1`) instead. Git before 2.51
rewrites the other submodule's section to point at the member. Both
are refused here, on every git.
- THE PATH UNDER ANOTHER NAME (#300), the mirror of the name: a section
that already gives the mount path to a submodule named something
else — a half-removed member, a hand edit, a merge that kept both
sides. This one git does not make at all, with `--force` or without
it: it writes the member's own section beside that one, so `add`
committed two sections for one path, `validate-family.py` passed
them, and a recursive clone followed the LATER of the two, the other
one's url as soon as an edit put it last. It is refused, naming the
section and its url, and left exactly as it is: it is somebody's
registration, and dropping or rewriting it as part of the add would
decide whose without asking. The path is compared exactly; git reads
`.gitmodules` that way too.
"""
if os.path.lexists(root / path):
raise Refusal(
Expand Down Expand Up @@ -1251,7 +1276,8 @@
"names a submodule, take that out (`git rm`); anything else it "
"names, a held `index.lock` say, is fixed where git says. Then "
"re-run.") from exc
other = _submodule_paths(root).get(path)
sections = _submodule_paths(root)
other = sections.get(path, (None, None))[0]
if other is not None and other != path:
raise Refusal(
"member-name-taken",
Expand All @@ -1263,6 +1289,40 @@
f"its section to point at {project} (git before 2.51), or mount "
f"{project} under another name this tool does not follow (git "
"2.51 and later, with the `--force` this tool mounts with).")
ghosts = [(name, url, keys) for name, (at, url, keys) in sections.items()
if at == path and name != path]
if ghosts:
named = " and ".join(
f"`{name}` ({'no url' if url is None else 'url ' + url})"
for name, url, _ in ghosts)
# BY KEY, NOT BY SECTION: `--remove-section` cannot address a
# section whose name holds `]`, nor one whose header a hand spelled
# `[Submodule "..."]` (git 2.43: `no such section`), while a key
# reaches every section `git config --list` names. EVERY KEY it
# holds, not its path and url alone: a `branch`, `shallow` or
# `update` left behind is inherited by the submodule a later `add`
# gives that name, which git writes into the same section. git
# drops the header with the last key.
removal = " and ".join(
f"`git config -f {GITMODULES} --unset-all "
f"{shlex.quote('submodule.' + name + '.' + key)}`"
for name, _, keys in ghosts for key in keys)
raise Refusal(
"member-path-registered",
f"{GITMODULES} already gives the path {path} to the submodule"
+ ("s" if len(ghosts) > 1 else "") + f" named {named}, and that "
f"is the path {project} is mounted at",
"Remediation: git would write the member's own section beside "
"it, 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 "
f"out by hand, in a POSIX shell — {removal}, then `git add "
f"{GITMODULES}` — or, if it was a member, with `family.py "
"remove`; then re-run."
+ ("" if os.path.lexists(root / GITMODULES) else
f" With no {GITMODULES} in the working tree, put back the "
f"copy git read first: `git checkout -- {GITMODULES}` (the "
f"index's), or `git checkout HEAD -- {GITMODULES}`."))


def _checked_add_target(family: Family, repository: str, project: str,
Expand Down
85 changes: 84 additions & 1 deletion templates/family-root/scripts/validate-family.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
and, across the rows, that no two record ONE `id`: that is one project
pinned twice, however each row spelled its repository (#206). This one
reads `family.yaml` alone, so `--no-members` asks it too.
- that no two sections of `.gitmodules` give ONE path (#300): a recursive
clone fetches that path from only one of them, so the other is a url
the next edit can make the one. It reads that file alone, so
`--no-members` asks it too.
- the COPIED shape files, each against its `sha256` row in
`contracts/shape-pin.yaml`. Editing a copy in place is drift and the exit
is to carry it upstream, never to update the digest.
Expand All @@ -52,6 +56,7 @@
from __future__ import annotations

import argparse
import os
import re
import shlex
import sys
Expand All @@ -67,6 +72,7 @@
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
QUALIFIED_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$")
MANIFEST = "family.yaml"
GITMODULES = ".gitmodules"
DEFAULT_MEMBERS_DIR = "members"

LOCKSTEP = (
Expand Down Expand Up @@ -570,13 +576,90 @@
f"name {first} there instead to keep {project}.")


def _check_gitmodules_paths(root: Path, report: Report) -> None:

Check failure on line 579 in templates/family-root/scripts/validate-family.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=opensoft_openRepoShape&issues=AaEue26I6Za53JnjuPC_&open=AaEue26I6Za53JnjuPC_&pullRequest=317
"""No two sections of `.gitmodules` give ONE path (#300).

`family.py add` committed a second section for a mount path that a
section under another name already gave — a half-removed member, a hand
edit, a merge that kept both sides — and this gate passed the result. A
recursive clone fetches the path from only one of them, the last in the
file, so the other is a url the next edit to the file can make the one
fetched. `add` refuses to write one now; this finds a holder that
already has one.

READ AS GIT READS IT, with `git config -z --file .gitmodules --list` as
`family.py` reads it, and no parser of this file's own: the working-tree
file, which in CI is HEAD's. A holder with no members has no
`.gitmodules`, and that is no finding; one git cannot read is a refusal,
because nothing could be cloned from it either. A section whose path no
row names is another question, and not asked here.
"""
if not os.path.lexists(root / GITMODULES):
return
try:
listed = git_out(["config", "-z", "--file", GITMODULES, "--list"],
cwd=root, binary=True)
except Refusal as exc:
raise Refusal(
"gitmodules-unreadable",
f"{GITMODULES} could not be read as git reads it: {exc.detail}",
"Remediation: git cannot read it either, so none of this "
"holder's submodules can be cloned from it as it stands. Mend "
f"the file by hand (`git config -f {GITMODULES} --list` says "
"where it breaks), then run this again.") from exc
except OSError as exc:
# NO GIT TO RUN is a question this run cannot answer, so a refusal
# (exit 2), never the traceback that exits 1, a finding's code:
# `--no-members` ran no git at all before this check.
raise Refusal(
"gitmodules-unreadable",
f"{GITMODULES} could not be read as git reads it: git could not "
f"be run ({exc.strerror or exc})",
"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.") from exc
sections: dict[str, dict[str, str]] = {}
for record in listed.split(b"\0"):
key, _, value = record.decode("utf-8",
"surrogateescape").partition("\n")
section, _, variable = key.rpartition(".")
if section.startswith("submodule.") and variable in ("path", "url"):
sections.setdefault(section[len("submodule."):],
{})[variable] = value
paths: dict[str, list[str]] = {}
for name, keys in sections.items():
if "path" in keys:
paths.setdefault(keys["path"], []).append(name)
for path, names in paths.items():
if len(names) < 2:
continue
listing = ", ".join(
f"`{name}` (" + (f"url {sections[name]['url']}"
if "url" in sections[name] else "no url") + ")"
for name in names)
report.finding(
"gitmodules-path-twice",
f"{GITMODULES} gives the path {path} to {len(names)} "
f"submodules, {listing}. A recursive clone fetches that path "
"from only one of them, the last in the file, so the order of "
"the sections decides which repository lands there. Keep the one "
"whose url is the member's (`family.py add` names it after its "
f"path, `{path}`) and take each other out by hand, `git config "
f"-f {GITMODULES} --unset-all submodule.<name>.<key>` for every "
"key that section holds, so a submodule later given its name "
f"inherits none of them, then commit {GITMODULES}.")


def _check_members(root: Path, members: list, no_members: bool,
report: Report) -> None:
"""Every row in `members:`, or the `--no-members` skip line.

Split from `main` for #137.
Split from `main` for #137. The two checks that read a file alone —
`family.yaml`'s ids (#206) and `.gitmodules`'s paths (#300) — come
first, so `--no-members` asks them too.
"""
_check_member_ids_unique(root, members, report)
_check_gitmodules_paths(root, report)
if no_members:
print(f" -- {len(members)} member(s) SKIPPED (--no-members): "
"their gitlinks, digests and project ids are not checked "
Expand Down
Loading
Loading