diff --git a/README.md b/README.md index 2dc61e44..4a4e78fa 100644 --- a/README.md +++ b/README.md @@ -952,7 +952,7 @@ at the right commit. **A family pins assembly roots, never legs**: the GitHub App or `SHAPE_LEGS_TOKEN` credential the workflow does, as a member is a private assembly root mounting two private legs — then runs each member's own `make bootstrap`; `make validate` runs the family validator, then each -member's. A member it cannot fetch is reported and skipped, never a failure. +member's. `make bootstrap` reports and skips one it lacks a credential for. The holder carries the same COPY pin an assembly root does, so `update-shape.py` re-syncs it the same way; it mirrors into `family.yaml` diff --git a/docs/handbook.html b/docs/handbook.html index 02c5188e..0c63875c 100644 --- a/docs/handbook.html +++ b/docs/handbook.html @@ -1282,7 +1282,7 @@

Adopting an existing repository

- +

Reference

Families: a holder for projects that ship separately

@@ -1375,8 +1375,8 @@

The InkRouter example

resolving the same GitHub App or SHAPE_LEGS_TOKEN credential the workflow does — and then runs each member’s own make bootstrap. make validate runs the family validator and then each member’s own - make validate. A member that could not be fetched is reported and skipped, never - a failure. The holder carries the same COPY pin an assembly root does, so + make validate. A member make bootstrap lacks a credential for is + reported and skipped, not a failure. The holder carries the same COPY pin an assembly root does, so update-shape.py re-syncs it the same way; it mirrors into family.yaml instead of project.yaml and must leave validate-family.py green.

diff --git a/templates/family-root/scripts/bootstrap.py b/templates/family-root/scripts/bootstrap.py index d9ddcc30..6dce353f 100755 --- a/templates/family-root/scripts/bootstrap.py +++ b/templates/family-root/scripts/bootstrap.py @@ -27,6 +27,36 @@ refused to do anything because one member is unreachable would be worse than one that says which. + "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 + why, and fails the run (c): this holder records no gitlink at the + row's path (a gitlink recorded only in another case, #363); no + section of `.gitmodules` gives that path (a holder whose HEAD lost the + file, #611); git has no url for that path, in the section it reads + for it or in its config (a section that lost its url, or a second + one for the path after the member's); git exited 0 and checked + nothing out there; or git stopped at another gitlink it has no url + for (a stray case variant, a member mounted by hand), since git's + init dies at the first such gitlink it asks about, before it checks + out any member. Which section and which url git reads is its own + parser's answer, not `git config`'s (`gitmodules_sections`), and + while a merge leaves `.gitmodules` unmerged in the index git reads + none at all: every such member is said to be that, and the cure is + the file resolved and added. Git exits 0 over the second where a + recursive clone ran first, and 128 (`No url found for submodule + path`) in a plain clone; a missing credential exits non-zero too, + so what the holder records is the test, never git's words. The line + says what cures each (`cures`): + `validate-family.py` names the commit for the first two; for the + fourth it prints the `git submodule update --init --checkout` that + 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, + the line says to write its section back. + (b) RUNS EACH MEMBER'S OWN `make bootstrap`. Each member is a whole project and knows how to bootstrap itself — its legs onto their tracking branches at their pinned commits, its own validators, its own @@ -35,9 +65,10 @@ `--members-make validate` runs `make validate` instead, which is what the family Makefile's `validate` target uses. - (c) EXITS 0 unless a member's own command failed. A 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). + (c) EXITS 0 unless a member's own command failed, or (a) left a member not + checked out that no credential would check out. 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). THE FAMILY CONFERS NOTHING and neither does this command. It fetches, it delegates, and it says what it could not reach. @@ -47,18 +78,35 @@ import argparse import os +import shlex import shutil import subprocess import sys +from collections import Counter from pathlib import Path +from typing import NamedTuple sys.path.insert(0, str(Path(__file__).resolve().parent)) from repo_shape import ( # noqa: E402 - Refusal, escape_unencodable_stdout, find_repo_root, load_yaml, spelled, + PYTHON, Refusal, escape_unencodable_stdout, find_repo_root, git_out, + load_yaml, recorded_gitlink, spelled, ) MANIFEST = "family.yaml" +GITMODULES = ".gitmodules" DEFAULT_MEMBERS_DIR = "members" +#: A submodule's entry in the index, as `ls-files -s` spells its mode. +GITLINK_MODE = b"160000" + +#: Why (a) left a row's member NOT CHECKED OUT where no credential would +#: check it out (#648), in the order `unfetchable` asks. `STOPPED` is +#: filled with the gitlink git's init died at (`init_stop`). +UNMERGED = f"git reads no {GITMODULES} while a merge leaves it unmerged" +NO_GITLINK = "no gitlink is recorded at that path" +NO_SECTION = f"no section of {GITMODULES} gives that path" +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" #: The two environment variables a leg/member credential may arrive in, in the #: order the `validate` workflow resolves them. An App-minted installation @@ -79,8 +127,13 @@ def credential() -> tuple[str, str]: return "none", "" -def fetch_members(root: Path) -> bool: - """(a) `git submodule update --init --recursive`, authenticated or not.""" +def fetch_members(root: Path, rows: list[dict]) -> list[str]: + """(a) `git submodule update --init --recursive`, authenticated or not. + + Returns a failure line for each of `rows` whose member it leaves not + checked out where no credential would check it out (`unfetchable`), for + the run to end FAILED on (#648); every member and leg is said to be + fetched only where there is none and git exited 0.""" source, token = credential() args = ["git"] if token: @@ -92,21 +145,285 @@ def fetch_members(root: Path) -> bool: sys.stdout.flush() proc = subprocess.run(args, cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace", check=False) - if proc.returncode == 0: + if proc.returncode != 0: + # The token can appear in git's own error text (it is in the URL it + # tried), so the output is scrubbed before it is printed. + message = (proc.stderr + proc.stdout).strip() + if token: + message = message.replace(token, "") + print(f" FETCH INCOMPLETE (git exit {proc.returncode}) using " + f"credential source '{source}':\n{message}", file=sys.stderr) + print(" If the members are PRIVATE, set SHAPE_LEGS_TOKEN (a " + "fine-grained PAT with contents:read on them) or export an " + "App-minted SHAPE_LEGS_APP_TOKEN, then re-run. What did arrive " + "is bootstrapped below.", file=sys.stderr) + left, stopper, modules = unfetchable(root, rows, proc.returncode == 0) + if left: + print(" NOT CHECKED OUT, and not for want of a credential: " + + ", ".join(f"{spelled(path)} (" + + (STOPPED.format(spelled(stopper)) + if why == STOPPED else why) + ")" + for _, path, why in left) + + ". " + cures(left, stopper, rows, modules), file=sys.stderr) + elif proc.returncode == 0: print(" every member and leg fetched") - return True - # The token can appear in git's own error text (it is in the URL it tried), - # so the output is scrubbed before it is printed. - message = (proc.stderr + proc.stdout).strip() - if token: - message = message.replace(token, "") - print(f" FETCH INCOMPLETE (git exit {proc.returncode}) using credential " - f"source '{source}':\n{message}", file=sys.stderr) - print(" If the members are PRIVATE, set SHAPE_LEGS_TOKEN (a fine-grained " - "PAT with contents:read on them) or export an App-minted " - "SHAPE_LEGS_APP_TOKEN, then re-run. What did arrive is bootstrapped " - "below.", file=sys.stderr) - return False + return [f"{spelled(row.get('project') or '?')} (not checked out)" + for row, _, _ in left] + + +def cures(left: list[tuple[dict, str, str]], stopper: str | None, + rows: list[dict], modules: Gitmodules) -> str: + """What (a)'s line says cures the rows `left` names, one sentence for + each kind of cause among them (#648): + + - A `.gitmodules` a merge left unmerged in the index, which git reads + nothing from (`Gitmodules.unmerged`): the file resolved and added. + - A gitlink or a section the holder lacks at the row's path: + `validate-family.py`, which names it and the commit that cures it, + `member-gitlink-absent` (with the move, for a gitlink recorded only + in another case, #363) or `member-gitmodules-absent`. + - A path git has no url for (`has_url`), a row's own or the gitlink git + stopped at. Where more than one section gives it, as a merge that + kept both sides leaves them, `validate-family.py --no-members`, whose + `gitmodules-path-twice` says which to keep and to take the others + out: a section written back would be one more. Otherwise a row's own + is its section written back. A gitlink in `members/` no row names is + `--no-members` too, whose `member-gitlink-unnamed` names it and the + commit that cures it: members checked, the validator refuses + `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. + - 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`.""" + whys = {why for _, _, why in left} + said = [] + if UNMERGED in whys: + said.append(f"Resolve the merge in {GITMODULES} and `git add " + f"{GITMODULES}`, then run this again.") + if whys & {NO_GITLINK, NO_SECTION}: + said.append(f"`{PYTHON} scripts/validate-family.py` names the cause " + "and the commit that cures it.") + unread = [path for _, path, why in left if why == NO_URL] + if STOPPED in whys and stopper not in {path for _, path, _ in left}: + unread.append(stopper) + named = {row_path(row) for row in rows} + twice = [path for path in unread if modules.given[path] > 1] + own = [path for path in unread if path in named and path not in twice] + if twice: + said.append(f"`{PYTHON} scripts/validate-family.py --no-members` " + f"names each path more than one section of {GITMODULES} " + "gives, and how to take the others out.") + if own: + said.append(f"Write {'the' if len(own) == 1 else 'each'} section of " + f"{GITMODULES} for {', '.join(map(spelled, own))} back " + "as `family.py add` writes one, with its path and url, " + f"and commit {GITMODULES}.") + for path in unread: + if path in named or path in twice: + continue + if path.split("/", 1)[0].casefold() == DEFAULT_MEMBERS_DIR: + said.append(f"`{PYTHON} scripts/validate-family.py --no-members` " + f"names each gitlink in {DEFAULT_MEMBERS_DIR}/ that " + "no row names, and the commit that cures it.") + else: + removal = shlex.join(["git", "rm", "--cached", "--", path]) + said.append(f"No row names {spelled(path)} and no clone can check " + f"it out: `{spelled(removal)}` takes its gitlink " + "out, then commit that.") + exited = [path for _, path, why in left if why == EXITED_0] + if exited: + update = shlex.join(["git", "submodule", "update", "--init", + "--checkout", "--recursive", "--", *exited]) + said.append(f"`{spelled(update)}` checks " + f"{'it' if len(exited) == 1 else 'them'} out.") + return " ".join(said) + + +class Gitmodules(NamedTuple): + """`.gitmodules` as (a) asks it (#648), read by `gitmodules_sections`. + + `given` counts the sections that give each path, every section kept, as + `validate-family.py` reads them for the findings (a)'s line names: + `member-gitmodules-absent` and `gitmodules-path-twice`. `read` is + `{path: name}`, the section git reads for each path, and `urls` is + `{name: url}`, the url git takes from each, as git's own parser has + them. `unmerged` says a merge left the file unmerged in the index, and + then git reads nothing from it, and the three are empty.""" + + given: Counter + read: dict[str, str] + urls: dict[str, str] + unmerged: bool = False + + +def gitmodules_sections(root: Path) -> Gitmodules: + """`.gitmodules` as `git submodule update` reads it: the work tree's + file, which a fresh clone takes from HEAD. Nothing where there is no + such file, or one git cannot read: git has no url for any submodule + then either. Nothing, and `unmerged`, where the index holds the file + only at a merge's conflict stages (`git ls-files -u`): git reads no + `.gitmodules` at all then, whatever the work tree holds + (`repo_read_gitmodules`), and its init dies at the first gitlink. + + `read` and `urls` keep git 2.43's own rules (submodule-config.c), which + drop what `git config --list` keeps. Entry by entry, in file order: + + - a section whose name is empty or has a `..` component, split on `/` + and on a backslash, is ignored whole (git warns `ignoring suspicious + submodule name`); + - a `path` or `url` that begins with `-` is dropped, and the value + before it stands (`may be interpreted as a command-line option`); + - where two sections give one path, the later in the file decides, as + a later value in one section replaces an earlier. A section that + gives a path again lets go of the one it gave, even where a later + section gave that one too: git then reads no section there.""" + nothing = Gitmodules(Counter(), {}, {}) + try: + stages = git_out(["ls-files", "-u", "-z", "--", GITMODULES], + cwd=root, binary=True) + except Refusal: + stages = b"" + if any(record.partition(b"\t")[2] == GITMODULES.encode() + for record in stages.split(b"\0")): + return nothing._replace(unmerged=True) + if not os.path.lexists(root / GITMODULES): + return nothing + try: + listed = git_out(["config", "-z", "--file", GITMODULES, "--list"], + cwd=root, binary=True) + except Refusal: + return nothing + sections: dict[str, dict[str, str]] = {} + read: dict[str, str] = {} + held: dict[str, str] = {} + urls: dict[str, str] = {} + for record in listed.split(b"\0"): + key, _, value = record.decode("utf-8", + "surrogateescape").partition("\n") + section, _, variable = key.rpartition(".") + if not section.startswith("submodule."): + continue + name = section[len("submodule."):] + sections.setdefault(name, {})[variable] = value + if (not name or ".." in name.replace("\\", "/").split("/") + or value.startswith("-")): + continue + if variable == "path": + if name in held: + read.pop(held[name], None) + held[name] = value + read[value] = name + elif variable == "url": + urls[name] = value + return Gitmodules(Counter(keys["path"] for keys in sections.values() + if "path" in keys), read, urls) + + +def submodule_config(root: Path) -> set[str]: + """Every `submodule.` key this repository's git config sets, in every + scope git reads, as git's init asks them: `submodule.active`, and + `submodule..url` as an earlier init writes it. None where git + lists none.""" + try: + config = git_out(["config", "-z", "--get-regexp", r"^submodule\."], + cwd=root, binary=True) + except Refusal: + return set() + return {record.decode("utf-8", "surrogateescape").partition("\n")[0] + for record in config.split(b"\0")} + + +def has_url(modules: Gitmodules, config: set[str], path: str) -> bool: + """Whether git's init finds a url for the gitlink at `path`: in the + section git reads for that path (`Gitmodules.read`), or in the git + config under that section's name, as an earlier init writes it. Where + git reads no section there it has none, whatever the config holds.""" + name = modules.read.get(path) + return name is not None and (name in modules.urls + or f"submodule.{name}.url" in config) + + +def init_stop(root: Path, modules: Gitmodules, config: set[str] + ) -> str | None: + """The gitlink `git submodule update --init` dies at, or None: the first + in the index, in index order, that its init step asks about and has no + url for (`No url found for submodule path`, exit 128), before it + checks out any member (#648). + + ASKED AS GIT 2.43 ASKS IT, of the index, `modules` and `config`, this + repository's git config. Every gitlink the index records is asked, but + where the config sets `submodule.active`, as a recursive clone sets it + (`.`), only those a section git reads gives a path: git counts no other + active. A gitlink git has no url for (`has_url`) stops it. So a stray + case variant or a member mounted by hand stops a plain clone's update, + and leaves a recursive clone's to the credential. Read nothing, it + answers None.""" + try: + index = git_out(["ls-files", "-s", "-z"], cwd=root, binary=True) + except Refusal: + return None + for record in index.split(b"\0"): + meta, _, entry = record.partition(b"\t") + if meta.split()[:1] != [GITLINK_MODE]: + continue + path = entry.decode("utf-8", "surrogateescape") + if path not in modules.read and "submodule.active" in config: + continue + if not has_url(modules, config, path): + return path + return None + + +def unfetchable(root: Path, rows: list[dict], exited_0: bool + ) -> tuple[list[tuple[dict, str, str]], str | None, + Gitmodules]: + """`(row, path, why)` for each row whose member (a) left NOT CHECKED OUT + where no credential would check it out (#648), in row order; the + gitlink git's init died at (`init_stop`), or None; and `.gitmodules` + as it was read, for `cures`. + + Asked of this holder's own records, never of git's words: any row at + all while a merge leaves `.gitmodules` unmerged in the index, since git + then reads no section and asks for no credential; a row at + whose path the holder records no gitlink (`recorded_gitlink`, + as `validate-family.py`'s `member-gitlink-absent` reads one), or whose + gitlink no section of `.gitmodules` gives a path (as its + `member-gitmodules-absent` reads HEAD's file, which is the work tree's + in a clone); one git has no url for (`has_url`), where no credential + helps either, whatever git exited; where git exited 0, any row at all, + since git then lacked nothing a credential gives; and where git's init + died at a gitlink it has no url for, any row at all, since git then + checked out no member and asked for no credential. Any other row not + checked out is the missing credential's, and stays the skip it always + was. A path two rows give is said once.""" + 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: + return [], None, Gitmodules(Counter(), {}, {}) + modules = gitmodules_sections(root) + config = submodule_config(root) + stopper = None if exited_0 else init_stop(root, modules, config) + found: list[tuple[dict, str, str]] = [] + for path, row in left.items(): + if modules.unmerged: + found.append((row, path, UNMERGED)) + elif recorded_gitlink(root, path) is None: + found.append((row, path, NO_GITLINK)) + elif not modules.given[path]: + found.append((row, path, NO_SECTION)) + elif not has_url(modules, config, path): + found.append((row, path, NO_URL)) + elif exited_0: + found.append((row, path, EXITED_0)) + elif stopper is not None: + found.append((row, path, STOPPED)) + return found, stopper, modules def members_of(root: Path) -> tuple[str, list[dict]]: @@ -128,9 +445,14 @@ def members_of(root: Path) -> tuple[str, list[dict]]: return name, rows +def row_path(row: dict) -> str: + """The path a row mounts its member at, as the holder spells it.""" + return str(row.get("path") + or f"{DEFAULT_MEMBERS_DIR}/{row.get('project')}") + + def member_path(root: Path, row: dict) -> Path: - return root / str(row.get("path") - or f"{DEFAULT_MEMBERS_DIR}/{row.get('project')}") + return root / row_path(row) def run_member_target(root: Path, row: dict, target: str) -> str | None: @@ -187,13 +509,13 @@ def main(argv: list[str] | None = None) -> int: f"{len(rows)} member(s)") print("\n(a) members and their legs") + failed: list[str] = [] if args.no_fetch: print(" skipped (--no-fetch)") else: - fetch_members(root) + failed += fetch_members(root, rows) print(f"\n(b) each member's own `make {spelled(args.members_make)}`") - failed: list[str] = [] if args.skip_members: print(" skipped (--skip-members)") elif not rows: diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 519b71fc..cee36ce5 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -43,7 +43,11 @@ path the row may not have is a fact about the member (#367). A row whose gitlink is recorded only at a case variant of its path is refused naming that variant, the index move that puts it back, and what to do - with a directory the variant leaves on disk (#611). + with a directory the variant leaves on disk (#611). The first is asked + of every row before the other two are asked of any (#648), so a later + row's gitlink refusal is never hidden behind an earlier row's + `member-uninitialized`, as a plain clone whose `git submodule update` + died at that gitlink left it. 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. @@ -59,7 +63,11 @@ them ever asked. Neither needs a member checked out, so `--no-members` asks them too. A path a row names in another case is still one of these, and what it says to do depends on which of the two is the member's own: - it names no command that would un-register the member. + it names no command that would un-register the member, and for a member + recorded only in the wrong case, where its row's path is its canonical + `members/` and its pin a 40-hex commit, it names the index move + that records it at that path, as the row's own refusal does members + checked (#648). - that a section of HEAD's `.gitmodules` gives the path of every row's gitlink, the `member-gitmodules-absent` finding (#611, #407's decision 3). A recursive clone reads HEAD's file, so a gitlink no section of it @@ -502,11 +510,12 @@ def _gitlink_in_another_case(path: str, commit: str, project: str, commit the row pins, reading no disk either. So the two lines work on every disk, wherever the checkout stands, and the commit after them records the member once, at its own path. `member-gitlink-unnamed` - prints no command for the same gitlink, since taking it out ALONE would - un-register the member; these take it out and record it together. - `--ignore-unmatch`, since a variant whose removal is staged and not yet - committed is still named here, from HEAD, and the plain `git rm` of it - failed (`pathspec ... did not match`). + prints the same move for the same gitlink under `--no-members` (#648), + and no command that takes it out ALONE, which would un-register the + member; these take it out and record it together. `--ignore-unmatch`, + since a variant whose removal is staged and not yet committed is still + named here, from HEAD, and the plain `git rm` of it failed (`pathspec + ... did not match`). 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` checks @@ -519,11 +528,6 @@ def _gitlink_in_another_case(path: str, commit: str, project: str, """ found = ", ".join(f"{variant} (at {gitlinks[variant]})" for variant in variants) - taken_out = " ".join(shlex.quote(variant) for variant in variants) - moved = " and ".join( - f"`mv -- {shlex.quote(variant)} " - f"{shlex.quote('../' + variant.replace('/', '-'))}`" - for variant in variants) return Refusal( "member-gitlink-absent", f"no gitlink (mode 160000) recorded at {path!r}. {project} is named " @@ -531,17 +535,31 @@ def _gitlink_in_another_case(path: str, commit: str, project: str, "case variant of that path, which macOS and Windows hold as one " f"directory with it: {found}. A recursive clone checks out nothing " f"at {path}.", - "Remediation: the member is recorded in the wrong case. Move its " - "gitlink to the row's path, at the commit the row pins, in the index " - "alone, which touches no file, with the two lines below, and commit " - f"that. Where nothing is checked out at {path}, as in a fresh clone, " - "`git submodule update --init --recursive` then checks the member " - "out there. On a case-sensitive disk, as Linux's usually is, a " - f"directory left at {' or '.join(variants)} stays there, untracked, " - "and family.py add refuses it as family-members-dir-variant: move " - f"each one that is there out of the holder, {moved}. On macOS and " - "Windows it is the row's own directory, so leave it. The two " - "lines:\n" + "Remediation: the member is recorded in the wrong case. " + + _wrong_case_move(path, commit, variants)) + + +def _wrong_case_move(path: str, commit: str, variants: list[str]) -> str: + """The index move for a member recorded only at `variants`, case + variants of its row's `path`, to `path` at the pinned `commit`, with + what it leaves on disk: what `_gitlink_in_another_case` says, and + `member-gitlink-unnamed` under `--no-members` (#648), in one text.""" + taken_out = " ".join(shlex.quote(variant) for variant in variants) + moved = " and ".join( + f"`mv -- {shlex.quote(variant)} " + f"{shlex.quote('../' + variant.replace('/', '-'))}`" + for variant in variants) + return ( + "Move its gitlink to the row's path, at the commit the row pins, in " + "the index alone, which touches no file, with the two lines below, " + f"and commit that. Where nothing is checked out at {path}, as in a " + "fresh clone, `git submodule update --init --recursive` then checks " + "the member out there. On a case-sensitive disk, as Linux's usually " + f"is, a directory left at {' or '.join(variants)} stays there, " + "untracked, and family.py add refuses it as " + "family-members-dir-variant: move each one that is there out of the " + f"holder, {moved}. On macOS and Windows it is the row's own " + "directory, so leave it. The two lines:\n" f" git rm -r --cached --ignore-unmatch -- {taken_out}\n" " git update-index --add --cacheinfo " f"{shlex.quote(f'160000,{commit},{path}')}") @@ -600,9 +618,24 @@ def _check_member_tree(member: Path, pin: dict, path: str, commit: str, report.note(f"{path}: tree digest recomputes ({recorded[:12]}…)") +class Gitlinked(NamedTuple): + """A member `_check_member` asked up to its gitlink, with what + `_check_member_checkout` reads its checkout by, and THE LOCKSTEP RULE + its digest finding ends at, which names this member (#610).""" + + row: dict + project: str + path: str + pin: dict + commit: str + rule: str + + def _check_member(root: Path, row: dict, seen: dict, report: Report, - gitlinks: dict[str, str], unregistered: set[str]) -> None: - """One member: the row itself, the gitlink, the digest, the identity. + gitlinks: dict[str, str]) -> Gitlinked | None: + """One member up to its checkout: the row itself and the gitlink. The + member, for `_check_member_checkout` to ask its digest and identity of, + or None where nothing more is asked of it. NOTHING IS READ THROUGH A PATH THE ROW MAY NOT HAVE (#367). A hand-edited `path:` is `member-path`, and this member's checks stop once its pin is @@ -616,18 +649,10 @@ def _check_member(root: Path, row: dict, seen: dict, report: Report, The git each check runs is run inside `_git_runs`, which names the check when there is no git to run (#368); the identity check reads a file and runs none. - - A MEMBER NO SECTION OF HEAD'S `.gitmodules` GIVES (`unregistered`, the - `member-gitmodules-absent` finding, #611) that is not checked out stops - after its gitlink. Refused `member-uninitialized`, it named `git - submodule update --init`, which exits 0 there and checks nothing out, - and the refusal dropped the finding that says why: the state a fresh - clone of a holder whose HEAD lost `.gitmodules` is in, and CI's. The - finding is made all the same, so the run still fails. """ project = _member_project_name(row, seen, report) if project is None: - return + return None path = _check_member_repository_and_path(row, project, report) @@ -635,10 +660,10 @@ def _check_member(root: Path, row: dict, seen: dict, report: Report, if not isinstance(pin, dict): report.finding("member-pin", f"{project}: `pin:` is {pin!r}, expected " "a mapping") - return + return None commit = _pin_commit(pin, project, report) if path is None: - return + return None # ---- the gitlink ------------------------------------------------------- # THE LOCKSTEP RULE each finding below ends at names this member (#610). @@ -646,15 +671,30 @@ def _check_member(root: Path, row: dict, seen: dict, report: Report, with _git_runs(f"the gitlink recorded at {path} could not be read"): _check_member_gitlink(root, path, commit, project, gitlinks, report, rule) + return Gitlinked(row, project, path, pin, commit, rule) + + +def _check_member_checkout(root: Path, asked: Gitlinked, report: Report, + unregistered: set[str]) -> None: + """One member's digest and identity, read in its checkout, asked once + every row's gitlink has been (`_check_members`, #648). - # ---- the digest and the identity -------------------------------------- - member = root / path - if path in unregistered and not (member / ".git").exists(): + A MEMBER NO SECTION OF HEAD'S `.gitmodules` GIVES (`unregistered`, the + `member-gitmodules-absent` finding, #611) that is not checked out is + asked nothing here. Refused `member-uninitialized`, it named `git + submodule update --init`, which exits 0 there and checks nothing out, + and the refusal dropped the finding that says why: the state a fresh + clone of a holder whose HEAD lost `.gitmodules` is in, and CI's. The + finding is made all the same, so the run still fails. + """ + member = root / asked.path + if asked.path in unregistered and not (member / ".git").exists(): return - with _git_runs(f"{project}'s pinned commit and its tree digest could not " - f"be read in {path}"): - _check_member_tree(member, pin, path, commit, project, report, rule) - _check_member_identity(member, row, project, report) + with _git_runs(f"{asked.project}'s pinned commit and its tree digest " + f"could not be read in {asked.path}"): + _check_member_tree(member, asked.pin, asked.path, asked.commit, + asked.project, report, asked.rule) + _check_member_identity(member, asked.row, asked.project, report) def _check_member_identity(member: Path, row: dict, project: str, @@ -1123,11 +1163,14 @@ def _member_gitlinks(root: Path) -> dict[str, str]: class RowPath(NamedTuple): """What a row names by the path it gives: `who` names the row in a - finding, and `canonical` is the one path its project may have, - `members/`, or None for a row with no `project:`.""" + finding, `canonical` is the one path its project may have, + `members/`, or None for a row with no `project:`, and `pin` is + the commit its `pin:` names, lowered, or None where that is no 40-hex + commit (#648).""" who: str canonical: str | None + pin: str | None = None def _row_paths(members: list) -> dict[str, RowPath]: @@ -1158,9 +1201,11 @@ def _row_paths(members: list) -> dict[str, RowPath]: path = canonical else: continue + pin = row.get("pin") + commit = str(pin.get("commit") or "") if isinstance(pin, dict) else "" named.setdefault(path, RowPath( f"the row for {project}" if project else "a row with no project:", - canonical)) + canonical, commit.lower() if COMMIT_RE.match(commit) else None)) return named @@ -1239,6 +1284,10 @@ def _variant_advice(kind: str, path: str, row_path: str, row: RowPath, is wrong, and where to put its path back; a `WRONG_CASE` says the member is recorded in the wrong case, and sends the operator to `git log -p -- family.yaml`, as an unnamed path that no row folds to does (`RESTORE_ROW`). + A `WRONG_CASE` gitlink whose row's path is its canonical one and whose + pin is a commit never comes here: `_check_gitlinks_named` prints the + index move for it, which records the member as it takes the gitlink out + (#648). """ if kind == ROW_EDITED: return (f" It is {path}, the one path {row.who} may have, and the row " @@ -1269,9 +1318,15 @@ def _check_gitlinks_named(root: Path, gitlinks: dict[str, str], still a finding, and says which of three it is (`_variant_case`). A second path to the member's own directory is taken out of the index alone with `git rm --cached`, which touches no file, and committed. The - member's own gitlink, which the ROW misspells, and the only record of a - member there is, in the wrong case, print no command: either could - un-register the member. Any other is taken out with a plain `git rm`, + member's own gitlink, which the ROW misspells, prints no command, since + taking it out could un-register the member. The only record of a member + there is, in the wrong case, prints the index move members mode's + `member-gitlink-absent` prints (`_wrong_case_move`, #648), which records + the member at its row's path as it takes the variants out, so + `--no-members`, the run CI falls back to when the members cannot be + checked out, names the cure too; where the row's path is not its + canonical one, or its pin no commit, it prints no command either. Any + other is taken out with a plain `git rm`, which refuses while its checkout holds changes, then mounted with `family.py add`: `add` cannot adopt an existing mount, and after `git rm --cached` alone it refuses the checkout left in the way @@ -1296,6 +1351,16 @@ def _check_gitlinks_named(root: Path, gitlinks: dict[str, str], if variant is not None: row_path, row = variant kind = _variant_case(path, row_path, row, holds) + if kind == WRONG_CASE and row_path == row.canonical and row.pin: + variants = sorted(other for other in holds + if other != row_path + and other.casefold() == row_path.casefold()) + report.finding( + "member-gitlink-unnamed", + f"{said}{_variant_said(row_path, row)} The member is " + "recorded in the wrong case. " + + _wrong_case_move(row_path, row.pin, variants)) + continue if kind != STRAY: report.finding( "member-gitlink-unnamed", @@ -1485,6 +1550,16 @@ def _check_members(root: Path, members: list, no_members: bool, `.gitmodules` gives twice (#300), then the gitlinks and sections no row names, which read the index and HEAD as well (#361), and the gitlinks no section of HEAD's `.gitmodules` gives (#611). + + THEN EVERY ROW'S GITLINK, AND ONLY THEN ANY ROW'S CHECKOUT (#648). Each + row used to be asked whole before the next was asked at all, so in a + plain clone of a holder whose later row's gitlink is recorded only in + another case, where `git submodule update --init --recursive` dies at + that gitlink (`No url found for submodule path`, exit 128) before it + checks out any member, an earlier row was refused + `member-uninitialized`, naming that same update, and the later row's + `member-gitlink-absent`, which names the move that cures both, was + never reached. """ _check_member_names_unique(members, report) _check_member_ids_unique(root, members, report) @@ -1502,12 +1577,17 @@ def _check_members(root: Path, members: list, no_members: bool, "here") return seen: dict[str, bool] = {} + gitlinked: list[Gitlinked] = [] for row in members: if not isinstance(row, dict): report.finding("member-row", f"a member is not a mapping: {row!r}") continue - _check_member(root, row, seen, report, gitlinks, unregistered) + asked = _check_member(root, row, seen, report, gitlinks) + if asked is not None: + gitlinked.append(asked) + for asked in gitlinked: + _check_member_checkout(root, asked, report, unregistered) if not members: report.note("no members yet: a family with none is empty, not " "wrong") diff --git a/tests/test_doctor_legs_members_next.py b/tests/test_doctor_legs_members_next.py index 8e84cf6e..92ca0ae2 100644 --- a/tests/test_doctor_legs_members_next.py +++ b/tests/test_doctor_legs_members_next.py @@ -1650,7 +1650,10 @@ def test_414_3_there_the_bootstrap_cures_nothing_and_the_git_line_cures_one( f"{module.quote_arg(tried / 'scripts' / 'bootstrap.py')} " f"--root {module.quote_arg(tried)}", tmp_path / "boot") output = bootstrap.stdout + bootstrap.stderr - assert bootstrap.returncode == 0, output + # It cures nothing, and since #648 it says so and fails: git stopped + # at Ghost, whose section gives no url, before it checked out either. + assert bootstrap.returncode == 1, output + assert "git stopped at members/Ghost" in output, output assert "No url found" in output, output assert not any((tried / "members" / FAMILY_MEMBER).iterdir()), output result = paste(row_of(standard, root, "members"), tmp_path / "elsewhere") diff --git a/tests/test_family_bootstrap_gate_order.py b/tests/test_family_bootstrap_gate_order.py new file mode 100644 index 00000000..3bd49d54 --- /dev/null +++ b/tests/test_family_bootstrap_gate_order.py @@ -0,0 +1,980 @@ +# SPDX-License-Identifier: Apache-2.0 +"""A holder's own bootstrap and gate after a clone, in every order and mode +(#648). + +THE ISSUE'S THREE LINES, each reproduced on #639's head before this change: + +- (a) `scripts/bootstrap.py` ended `family bootstrap ok`, exit 0, having + checked no member out. In a fresh recursive clone of a holder whose HEAD + lost `.gitmodules`, `git submodule update` exits 0, and it said "every + member and leg fetched", `NOT CHECKED OUT; skipped` for each member, then + ok. In a plain clone of that holder, or of #363's state (a member's + gitlink recorded only at `Members/

`), git dies `No url found for + submodule path` (exit 128), and it still ended ok. It fails now, saying + which members no credential would check out and why, and what cures + them. So it does where git's init dies at a gitlink it has no url for: + a stray case variant, a member mounted by hand, in `members/` or outside + it, a section that lost its url (rv648's S1, H1, H2 and U1), and, as + git's own parser reads `.gitmodules`, a second section for a member's + path after its own, a section under a name git ignores and a url git + drops (D1, Q1 and O1). So it does where git exits 0 over a member a + subset clone or `update = none` leaves out, whose line prints the update + that checks it out, unless git has no url for it (A1), and where a + merge leaves `.gitmodules` unmerged, which git then reads nothing from + (MM1). A missing credential is still the skip it was, after a recursive + clone too. +- (b) In a plain clone of #363's state followed by `git submodule update + --init --recursive`, as CI and `make bootstrap` run it, the update dies at + `Members/

` before it checks out any member, so where that row is not + the first, the gate refused `member-uninitialized` for an earlier row, + naming that same update, and never reached the variant's refusal. Every + row's gitlink is asked before any row's checkout now. +- (c) Under `--no-members`, the mode CI falls back to there, + `member-gitlink-unnamed` for that gitlink printed no command. It prints + the index move members mode prints now. + +THE SAME FAMILY, OFFLINE RULES AND STATES as +`test_family_head_lost_gitmodules.py` (#611), whose helpers are imported. +Every remedy is typed as printed. The case-variant states are index records +and commits, and no test makes two directories whose names differ only in +case; the holder's own are asked again with `core.ignorecase=true`. A +clone's checkout is git's: on a disk that folds case, `Members/

` IS the +row's directory, so the `mv` the text gives for a case-sensitive disk is +typed only where the disk holds the two apart, as the text says, and a +clone of a stray (`Members/

` beside `members/

`, rv648's S1) gets its +two empty mounts there as one directory, which no assertion reads. +""" + +from __future__ import annotations + +import itertools +import os +import re +import shlex +import subprocess +from pathlib import Path + +import pytest + +from conftest import clone_just_pushed, git, run_script +from repo_shape import PYTHON +from test_family import NAME, SPARE, family, validate # noqa: F401 +from test_family_add_refusals import holder, offline # noqa: F401 +from test_family_dry_run import isolated +from test_family_head_lost_gitmodules import ( + DISKS, GITMODULES, assert_passes_in_every_mode, commit_all, + committed_gitmodules, head_lost, on_disk, pinned, recorded_at, + variant_refused, +) +from test_family_member_path import MEMBER, OTHER, raw +from test_validate_family_unnamed_mounts import ( + GITLINK_SAID, VARIANT, WRONG_CASE, commands, findings, spare_commits, + stage_gitlink, +) + +#: The line `(a)` prints for the members it left NOT CHECKED OUT where no +#: credential would check them out, each with why, then what cures them; +#: the run then ends on `failed_on`. +NOT_FETCHED = (" NOT CHECKED OUT, and not for want of a credential: {left}. " + "{cure}") +NO_SECTION = "no section of .gitmodules gives that path" +NO_GITLINK = "no gitlink is recorded at that path" +NO_URL = "git has no url for that path" +UNMERGED = "git reads no .gitmodules while a merge leaves it unmerged" +EXITED_0 = "git exited 0 and checked nothing out there" +STOPPED = "git stopped at {path}, a gitlink it has no url for" + +#: What the line says cures each why: the validator for the first two; for +#: a path git has no url for, a row's own or the gitlink git stopped at, +#: the validator's `--no-members` where two sections give it, a row's own +#: section written back, `--no-members` for a gitlink in members/ no row +#: names and `git rm --cached` for one outside it; and for a member git +#: exited 0 over, the update that checks it out. +VALIDATOR_CURE = (f"`{PYTHON} scripts/validate-family.py` names the cause " + "and the commit that cures it.") +UNNAMED_CURE = (f"`{PYTHON} scripts/validate-family.py --no-members` names " + "each gitlink in members/ that no row names, and the commit " + "that cures it.") +SECTION_CURE = ("Write the section of .gitmodules for {path} back as " + "`family.py add` writes one, with its path and url, and " + "commit .gitmodules.") +TWICE_CURE = (f"`{PYTHON} scripts/validate-family.py --no-members` names " + "each path more than one section of .gitmodules gives, and how " + "to take the others out.") +OUTSIDE_CURE = ("No row names {path} and no clone can check it out: `git rm " + "--cached -- {path}` takes its gitlink out, then commit that.") +UNMERGED_CURE = ("Resolve the merge in .gitmodules and `git add .gitmodules`, " + "then run this again.") +CHECKOUT_CURE = ("`git submodule update --init --checkout --recursive -- " + "{path}` checks it out.") + +#: Each test pushes a branch of its own into the module's family remote. Not +#: `tmp_path.name`: a passed test's directory is removed under +#: `tmp_path_retention_policy=failed`, and the next test is given its number. +BRANCHES = itertools.count() + +#: What members mode's variant refusal says before the move it prints. +WRONG_CASE_LEAD = "Remediation: the member is recorded in the wrong case. " + +#: `bootstrap.py --skip-members`'s stdout after its first line, in a clone +#: where every member was checked out, and where not every one was: the +#: second says nothing of (a)'s fetch on stdout, and ends on stderr. +FETCHED = ["", "(a) members and their legs", " credential source: none", + " every member and leg fetched", "", + "(b) each member's own `make bootstrap`", + " skipped (--skip-members)", "", "family bootstrap ok", ""] +NOT_ALL_FETCHED = ["", "(a) members and their legs", + " credential source: none", "", + "(b) each member's own `make bootstrap`", + " skipped (--skip-members)", "", ""] + + +def with_config(env: dict, key: str, value: str) -> dict: + """`env` with `key` set to `value`, one more `GIT_CONFIG_*` entry after + the ones it already holds, for every git the run starts.""" + index = int(env.get("GIT_CONFIG_COUNT", "0")) + return {**env, "GIT_CONFIG_COUNT": str(index + 1), + f"GIT_CONFIG_KEY_{index}": key, f"GIT_CONFIG_VALUE_{index}": value} + + +def fetching(env: dict) -> dict: + """`env` under which `git submodule update` may clone the fixture's + local members: `protocol.file.allow`, as `test_family.py` gives the + holder's own bootstrap.""" + return with_config(env, "protocol.file.allow", "always") + + +def variant(holder: Path, env: dict, project: str = OTHER) -> str: + """#363's state: `project`'s gitlink recorded only at `Members/

`, + committed, its row and section still `members/

`. OTHER is the + second row, so a plain clone's update dies before the first row's + checkout. Returns the commit.""" + return recorded_at(holder, env, project, f"Members/{project}") + + +def cloned(holder: Path, env: dict, family, tmp_path: Path, + *, recurse: bool) -> Path: + """HEAD pushed to a branch of the family's own remote that no other + test pushes (`BRANCHES`), then a fresh clone of it, recursive or plain, + which must exit 0.""" + remote = family["remotes"] / f"{NAME}.git" + branch = f"w648/{next(BRANCHES)}" + raw(holder, env, "push", "-q", str(remote), f"HEAD:refs/heads/{branch}") + clone = tmp_path / "clone" + made = clone_just_pushed(remote, clone, branch, recurse=recurse) + assert made.returncode == 0, made.stdout + made.stderr + return clone + + +def bootstrap(root: Path, env: dict, *extra: str): + return run_script(root / "scripts" / "bootstrap.py", *extra, cwd=root, + env=env) + + +def not_fetched(*left: tuple[str, str], cure: str = VALIDATOR_CURE) -> str: + """`NOT_FETCHED` naming each `(path, why)`, then `cure`.""" + return NOT_FETCHED.format( + left=", ".join(f"{path} ({why})" for path, why in left), cure=cure) + + +def stray(holder: Path, env: dict, family) -> str: + """#361's STRAY: OTHER's gitlink recorded at `Members/

` too, at its + pin, beside its own, and committed; no section gives the second. Index + order puts it before every member's. Returns its path.""" + stage_gitlink(holder, f"Members/{OTHER}", pinned(holder, OTHER)) + commit_all(holder, env, "A stray case variant of OTHER's gitlink") + return f"Members/{OTHER}" + + +def hand_mounted(holder: Path, env: dict, family) -> str: + """SPARE's gitlink recorded at `members/` by hand, with no row + and no section, and committed. Returns its path.""" + stage_gitlink(holder, f"members/{SPARE}", spare_commits(family)[1]) + commit_all(holder, env, "A member mounted by hand") + return f"members/{SPARE}" + + +#: The two gitlinks no row names at which git's init dies in a plain clone +#: (rv648's S1 and H1). +UNNAMED_STOPS = {"stray": stray, "hand-mount": hand_mounted} + + +def url_edited(data: bytes, project: str, edit) -> bytes: + """`.gitmodules` bytes with the `url` line of `project`'s section put + through `edit`, the rest as `family.py add` wrote it. A checkout's copy + may end its lines CRLF.""" + section = re.compile(rb'(\[submodule "members/' + project.encode() + + rb'"\]\r?\n(?:\t[^\n]*\n)*?)(\turl = [^\n]*\n)') + edited, count = section.subn( + lambda found: found.group(1) + edit(found.group(2)), data) + assert count == 1, data + return edited + + +def without_url(data: bytes, project: str) -> bytes: + """`.gitmodules` bytes with the `url` line of `project`'s section taken + out (rv648's U1).""" + return url_edited(data, project, lambda line: b"") + + +def renamed(data: bytes, name: str) -> bytes: + """`.gitmodules` bytes with OTHER's section under the name `name`, its + keys as `family.py add` wrote them, escaped as git config escapes a + subsection name.""" + own = f'[submodule "members/{OTHER}"]'.encode() + assert data.count(own) == 1, data + escaped = name.replace("\\", "\\\\").replace('"', '\\"') + return data.replace(own, f'[submodule "{escaped}"]'.encode("utf-8")) + + +#: A second section, `dup`, that gives OTHER's path and no url, as a merge +#: that kept both sides of `.gitmodules` can leave one (rv648's D1 and D2). +DUPLICATE = f'[submodule "dup"]\n\tpath = members/{OTHER}\n'.encode() + + +def edited_clone(holder: Path, env: dict, family, tmp_path: Path, + edit) -> tuple[Path, bytes]: + """HEAD's `.gitmodules` put through `edit` by hand and committed, then a + plain clone of it (`cloned`). Returns the clone and the bytes `family.py + add` wrote, which writing the file back restores.""" + data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout + committed_gitmodules(holder, env, edit(data)) + return cloned(holder, env, family, tmp_path, recurse=False), data + + +def failed_on(*projects: str) -> str: + return "family bootstrap FAILED: " + ", ".join( + f"{project} (not checked out)" for project in projects) + + +def after_first_line(result) -> list[str]: + """Bootstrap's stdout after its first line, which names the holder and + its two members.""" + lines = result.stdout.split("\n") + assert lines[0].startswith(f"bootstrap: {NAME} ("), result.stdout + assert lines[0].endswith(" 2 member(s)"), result.stdout + return lines[1:] + + +def other_older(family) -> str: + """A commit of OTHER that is not the one the holder pins.""" + return git("rev-parse", "main~1", + cwd=family["remotes"] / f"{OTHER}.git").stdout.strip() + + +def type_line(root: Path, env: dict, line: str) -> None: + """One printed `git` line, typed as printed: split as a POSIX shell + splits it, run with no shell.""" + argv = shlex.split(line) + assert argv[0] == "git", line + raw(root, env, *argv[1:]) + + +def moved_out_where_apart(root: Path, remediation: str) -> None: + """Each backticked `mv` the text gives for a case-sensitive disk, typed + as printed where the disk holds its source apart from the row's own + directory, as the text says; on a disk that folds case it is the row's + directory, and the text says to leave it.""" + for move in re.findall(r"`(mv -- [^`]*)`", remediation): + argv = shlex.split(move) + source = root / argv[2] + own = root / "members" / argv[2].split("/", 1)[1] + if not source.exists() or (own.exists() + and os.path.samefile(source, own)): + continue + subprocess.run(argv, cwd=str(root), check=True) + assert not source.exists() + + +# --- (a) bootstrap ----------------------------------------------------------- + +#: `(set-up, flow, the members (a) names with why, the rows it fails on, +#: what it says cures them)`. In #363's plain clone git dies at the variant, +#: `Members/

`, before it checks out MEMBER, so MEMBER is named too, and +#: the variant is a gitlink no row names (rv648's M1). +UNFETCHED = { + "head-lost-recursive": ( + head_lost, True, + ((f"members/{MEMBER}", NO_SECTION), (f"members/{OTHER}", NO_SECTION)), + (MEMBER, OTHER), VALIDATOR_CURE), + "head-lost-plain": ( + head_lost, False, + ((f"members/{MEMBER}", NO_SECTION), (f"members/{OTHER}", NO_SECTION)), + (MEMBER, OTHER), VALIDATOR_CURE), + "variant-recursive": ( + variant, True, ((f"members/{OTHER}", NO_GITLINK),), (OTHER,), + VALIDATOR_CURE), + "variant-plain": ( + variant, False, + ((f"members/{MEMBER}", STOPPED.format(path=f"Members/{OTHER}")), + (f"members/{OTHER}", NO_GITLINK)), + (MEMBER, OTHER), f"{VALIDATOR_CURE} {UNNAMED_CURE}"), +} + + +@pytest.mark.parametrize("set_up, recurse, left, projects, cure", + UNFETCHED.values(), ids=UNFETCHED) +def test_bootstrap_fails_over_members_no_credential_would_check_out( + family, holder, offline, tmp_path, set_up, recurse, left, projects, + cure): + """THE ISSUE'S (a), both of rv611b's states, after a recursive clone and + after a plain one. Git exits 0 over a holder whose HEAD lost + `.gitmodules` where the clone was recursive, and 128 (`No url found for + submodule path`) where it was plain, and 128 over #363's state in a + plain clone: the run said ok each time. It names the members no + credential would check out, says why and what cures them, and fails; + it never says every member was fetched.""" + env = isolated(offline, tmp_path) + set_up(holder, env) + clone = cloned(holder, env, family, tmp_path, + recurse=recurse) + + result = bootstrap(clone, fetching(env), "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert after_first_line(result) == NOT_ALL_FETCHED + errors = result.stderr.splitlines() + assert errors[-2:] == [not_fetched(*left, cure=cure), + failed_on(*projects)], result.stderr + if not recurse: + assert errors[0].startswith(" FETCH INCOMPLETE (git exit 128) "), ( + result.stderr) + assert "No url found for submodule path" in result.stderr + + +def test_a_good_holder_bootstraps_as_it_did(family, holder, offline, + tmp_path): + """A GOOD HOLDER'S RUN IS KEPT: a fresh recursive clone checks every + member out, (a) says every member and leg was fetched, and the run ends + ok with nothing on stderr.""" + env = isolated(offline, tmp_path) + clone = cloned(holder, env, family, tmp_path, recurse=True) + + result = bootstrap(clone, fetching(env), "--skip-members") + + assert result.returncode == 0, result.stdout + result.stderr + assert after_first_line(result) == FETCHED + assert result.stderr == "" + + +def test_a_missing_credential_is_still_the_skip_it_was(family, holder, + offline, tmp_path): + """No misfire: a plain clone whose update git refuses for want of a + credential (no `protocol.file.allow` for the local members, as a + private member with no token is refused) records every gitlink at its + row's path, and `.gitmodules` gives each. A credential is the cure, so + the members are skipped, the run ends ok, and no line says otherwise.""" + env = isolated(offline, tmp_path) + clone = cloned(holder, env, family, tmp_path, + recurse=False) + + result = bootstrap(clone, env) + + assert result.returncode == 0, result.stdout + result.stderr + assert "FETCH INCOMPLETE" in result.stderr + assert "not for want of a credential" not in result.stderr + assert (" every member and leg fetched" + not in result.stdout.splitlines()), result.stdout + for project in (MEMBER, OTHER): + assert (f" [{project}] {project}: NOT CHECKED OUT; skipped. See the " + "fetch above.") in result.stdout.splitlines() + assert result.stdout.endswith("\nfamily bootstrap ok\n"), result.stdout + + +def skip_by_update_none(clone: Path, env: dict) -> dict: + """`submodule..update=none` for OTHER, set for every git the run + starts, as a `.gitmodules` committed with `update = none` sets it.""" + return with_config(env, f"submodule.members/{OTHER}.update", "none") + + +def skip_by_subset(clone: Path, env: dict) -> dict: + """`submodule.active` in the clone's own git config naming MEMBER alone, + as `git clone --recurse-submodules=members/

` stores it.""" + raw(clone, env, "config", "submodule.active", f"members/{MEMBER}") + return env + + +@pytest.mark.parametrize("skip", [skip_by_update_none, skip_by_subset], + ids=["update-none", "subset"]) +def test_an_update_that_exits_0_and_skips_a_member_fails(family, holder, + offline, tmp_path, + skip): + """Git exited 0, so it lacked nothing a credential gives, though the + holder's records give OTHER: an `update = none`, or a clone that chose + a subset of members, skips it (rv648's N1 and P1). (a) does not say + every member was fetched, and the run fails on OTHER alone, printing + the update that checks it out: `--init` with its path registers it, and + `--checkout` overrides the `none`. `git submodule update --init + --recursive`, `member-uninitialized`'s remedy, exits 0 there and checks + nothing out. Typed as printed, the line lets the same run end ok.""" + env = fetching(isolated(offline, tmp_path)) + clone = cloned(holder, env, family, tmp_path, + recurse=False) + env = skip(clone, env) + + result = bootstrap(clone, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert (clone / "members" / MEMBER / "project.yaml").is_file() + assert "every member and leg fetched" not in result.stdout + assert result.stderr.splitlines() == [ + not_fetched((f"members/{OTHER}", EXITED_0), + cure=CHECKOUT_CURE.format(path=f"members/{OTHER}")), + failed_on(OTHER)] + raw(clone, env, "submodule", "update", "--init", "--recursive") + assert not (clone / "members" / OTHER / ".git").exists(), ( + "premise: the validator's remedy checks nothing out here") + (line,) = re.findall(r"`(git submodule update [^`]*)`", result.stderr) + type_line(clone, env, line) + + again = bootstrap(clone, env, "--skip-members") + + assert again.returncode == 0, again.stdout + again.stderr + assert after_first_line(again) == FETCHED + assert again.stderr == "" + + +@pytest.mark.parametrize("set_up", UNNAMED_STOPS.values(), ids=UNNAMED_STOPS) +def test_a_gitlink_no_row_names_that_stops_a_plain_clones_update_fails( + family, holder, offline, tmp_path, set_up): + """rv648's S1 and H1. In a plain clone of a holder that records a stray + case variant of a member's gitlink, or a member mounted by hand, git's + init dies at that gitlink (`No url found for submodule path`, exit 128) + before it checks out any member, though every row's gitlink and section + are there: the run said ok. It names both rows, says where git stopped, + and names `--no-members`, which names that gitlink and the commit that + cures it. Typed as printed (all but the hand mount's `family.py add`, + which mounts it again) and committed, the same run checks both members + out.""" + env = fetching(isolated(offline, tmp_path)) + stopper = set_up(holder, env, family) + commit = raw(holder, env, "rev-parse", + f"HEAD:{stopper}").stdout.decode("ascii").strip() + clone = cloned(holder, env, family, tmp_path, recurse=False) + + result = bootstrap(clone, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + why = STOPPED.format(path=stopper) + assert result.stderr.splitlines()[-2:] == [ + not_fetched((f"members/{MEMBER}", why), (f"members/{OTHER}", why), + cure=UNNAMED_CURE), + failed_on(MEMBER, OTHER)], result.stderr + assert f"No url found for submodule path '{stopper}'" in result.stderr + fallback = validate(clone, "--no-members") + assert fallback.returncode == 1, fallback.stdout + fallback.stderr + (finding,) = findings(fallback) + assert finding.startswith(GITLINK_SAID.format(path=stopper, + commit=commit)), finding + for line in commands(finding): + if not line.startswith(f"{PYTHON} "): + type_line(clone, env, line) + commit_all(clone, env, "Take the gitlink out as printed") + + again = bootstrap(clone, env, "--skip-members") + + assert again.returncode == 0, again.stdout + again.stderr + assert after_first_line(again) == FETCHED + + +#: `(edit, what git warns)`: OTHER's section with no url (rv648's U1), or +#: one git's own parser drops, though `git config --list` keeps it: a name +#: that is empty or has a `..` component, split on `/` or a backslash +#: (Q1), and a url that begins with `-` (O1). +NO_URL_FOR_OTHER = { + "no-url": (lambda data: without_url(data, OTHER), ""), + "dotdot-name": (lambda data: renamed(data, f"../{OTHER}"), + f"ignoring suspicious submodule name: ../{OTHER}\n"), + "empty-name": (lambda data: renamed(data, ""), + "ignoring suspicious submodule name: \n"), + "backslash-name": (lambda data: renamed(data, f"{OTHER}\\..\\x"), + "ignoring suspicious submodule name: " + f"{OTHER}\\..\\x\n"), + "option-url": (lambda data: url_edited(data, OTHER, + lambda line: b"\turl = -nowhere\n"), + "which may be interpreted as a command-line option: " + "-nowhere\n"), +} + + +@pytest.mark.parametrize("edit, warned", NO_URL_FOR_OTHER.values(), + ids=NO_URL_FOR_OTHER) +def test_a_member_git_has_no_url_for_stops_the_update_and_is_written_back( + family, holder, offline, tmp_path, edit, warned): + """rv648's U1, Q1 and O1. OTHER's section lost its url, or is one git + reads no url from, committed: in a plain clone git's init dies at + OTHER's own gitlink, after MEMBER's and before it checks out either. + The run said ok in Q1 and O1, reading the section with `git config`, + which keeps what git drops, and no mode of the validator names any of + the three. It names both rows, OTHER for want of a url and MEMBER + stopped there, and says to write OTHER's section back; written back as + `family.py add` wrote it and committed, the same run checks both + members out.""" + env = fetching(isolated(offline, tmp_path)) + clone, data = edited_clone(holder, env, family, tmp_path, edit) + + result = bootstrap(clone, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert result.stderr.splitlines()[-2:] == [ + not_fetched((f"members/{MEMBER}", + STOPPED.format(path=f"members/{OTHER}")), + (f"members/{OTHER}", NO_URL), + cure=SECTION_CURE.format(path=f"members/{OTHER}")), + failed_on(MEMBER, OTHER)], result.stderr + assert (f"No url found for submodule path 'members/{OTHER}'" + in result.stderr) + assert warned in result.stderr + (clone / GITMODULES).write_bytes(data) + raw(clone, env, "add", "--", GITMODULES) + commit_all(clone, env, "OTHER's section written back") + + again = bootstrap(clone, env, "--skip-members") + + assert again.returncode == 0, again.stdout + again.stderr + assert after_first_line(again) == FETCHED + + +def test_a_second_section_git_reads_for_a_row_s_path_names_the_two( + family, holder, offline, tmp_path): + """rv648's D1. A second section, `dup`, gives OTHER's path and no url, + after OTHER's own: git reads the later of two sections for one path, so + in a plain clone its init dies at OTHER's gitlink, after MEMBER's and + before it checks out either. The run said ok, since one section gave + the path a url. It names both rows and `--no-members`, whose + `gitmodules-path-twice` names the two sections and says to take the + other out: a section written back would be a third. Taken out as that + says and committed, the same run checks both members out.""" + env = fetching(isolated(offline, tmp_path)) + clone, _ = edited_clone(holder, env, family, tmp_path, + lambda data: data + DUPLICATE) + + result = bootstrap(clone, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert result.stderr.splitlines()[-2:] == [ + not_fetched((f"members/{MEMBER}", + STOPPED.format(path=f"members/{OTHER}")), + (f"members/{OTHER}", NO_URL), cure=TWICE_CURE), + failed_on(MEMBER, OTHER)], result.stderr + assert (f"No url found for submodule path 'members/{OTHER}'" + in result.stderr) + fallback = validate(clone, "--no-members") + assert fallback.returncode == 1, fallback.stdout + fallback.stderr + (finding,) = [found for found in findings(fallback) + if found.startswith("FINDING gitmodules-path-twice: ")] + assert finding.startswith( + f"FINDING gitmodules-path-twice: .gitmodules gives the path " + f"members/{OTHER} to 2 submodules, `members/{OTHER}` (url "), finding + assert "`dup` (no url)" in finding, finding + assert "--unset-all submodule..` for every key" in finding + raw(clone, env, "config", "-f", GITMODULES, "--unset-all", + "submodule.dup.path") + raw(clone, env, "add", "--", GITMODULES) + commit_all(clone, env, "The second section taken out as printed") + + again = bootstrap(clone, env, "--skip-members") + + assert again.returncode == 0, again.stdout + again.stderr + assert after_first_line(again) == FETCHED + + +#: `.gitmodules` edits git's own parser reads past: rv648's D2, the second +#: section placed BEFORE OTHER's own, which git then reads; a name whose +#: dots are no `..` component; and a url that begins with `-` after OTHER's +#: own, which git drops, keeping the one before it. +READ_PAST = { + "duplicate-first": lambda data: DUPLICATE + data, + "dots-in-a-name": lambda data: renamed(data, f"..{OTHER}.."), + "option-url-after-a-url": lambda data: url_edited( + data, OTHER, lambda line: line + b"\turl = -x\n"), +} + + +@pytest.mark.parametrize("edit", READ_PAST.values(), ids=READ_PAST) +def test_what_git_reads_past_is_still_the_skip_and_a_true_ok( + family, holder, offline, tmp_path, edit): + """No misfire. Where git's parser still reads a url for OTHER, a plain + clone's update with no credential for the members fails on their fetch, + not in its init, so they are the credential's: skipped, and the run + ends ok. With the credential, the same update checks both members out, + exit 0, and the run is a good holder's.""" + env = isolated(offline, tmp_path) + clone, _ = edited_clone(holder, env, family, tmp_path, edit) + + skipped = bootstrap(clone, env, "--skip-members") + + assert skipped.returncode == 0, skipped.stdout + skipped.stderr + assert "FETCH INCOMPLETE" in skipped.stderr + assert "not for want of a credential" not in skipped.stderr + fetched = bootstrap(clone, fetching(env), "--skip-members") + assert fetched.returncode == 0, fetched.stdout + fetched.stderr + assert after_first_line(fetched) == FETCHED + assert fetched.stderr == "" + + +def test_a_subset_clone_of_a_member_git_has_no_url_for_is_written_back( + family, holder, offline, tmp_path): + """rv648's A1. OTHER's section lost its url, committed, and the clone + chose MEMBER alone: git exits 0, and the line printed the exit-0 skip's + update, which dies `No url found` when typed (exit 128) and checks + nothing out. Git has no url for OTHER, in its section or its config, + so the line says to write the section back. Written back and + committed, the run is the exit-0 skip, and the update it prints, typed + as printed, lets the same run end ok.""" + env = fetching(isolated(offline, tmp_path)) + clone, data = edited_clone(holder, env, family, tmp_path, + lambda data: without_url(data, OTHER)) + env = skip_by_subset(clone, env) + + result = bootstrap(clone, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert result.stderr.splitlines() == [ + not_fetched((f"members/{OTHER}", NO_URL), + cure=SECTION_CURE.format(path=f"members/{OTHER}")), + failed_on(OTHER)] + (clone / GITMODULES).write_bytes(data) + raw(clone, env, "add", "--", GITMODULES) + commit_all(clone, env, "OTHER's section written back") + skipped = bootstrap(clone, env, "--skip-members") + assert skipped.returncode == 1, skipped.stdout + skipped.stderr + assert skipped.stderr.splitlines() == [ + not_fetched((f"members/{OTHER}", EXITED_0), + cure=CHECKOUT_CURE.format(path=f"members/{OTHER}")), + failed_on(OTHER)] + (line,) = re.findall(r"`(git submodule update [^`]*)`", skipped.stderr) + type_line(clone, env, line) + + again = bootstrap(clone, env, "--skip-members") + + assert again.returncode == 0, again.stdout + again.stderr + assert after_first_line(again) == FETCHED + assert again.stderr == "" + + +def test_a_hand_mount_outside_members_is_taken_out_of_the_index( + family, holder, offline, tmp_path): + """rv648's H2. SPARE's gitlink recorded by hand at `vendor/`, + outside members/, with no row and no section, and committed: in a plain + clone git's init dies there before it checks out any member. No mode + of the validator reads a gitlink outside members/, and `--no-members` + says `family ok`, so the line names the `git rm --cached` that takes + the gitlink out. Typed as printed and committed, the same run checks + both members out, and the gate passes in every mode.""" + env = fetching(isolated(offline, tmp_path)) + stopper = f"vendor/{SPARE}" + stage_gitlink(holder, stopper, spare_commits(family)[1]) + commit_all(holder, env, "A gitlink mounted by hand outside members/") + clone = cloned(holder, env, family, tmp_path, recurse=False) + + result = bootstrap(clone, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + why = STOPPED.format(path=stopper) + assert result.stderr.splitlines()[-2:] == [ + not_fetched((f"members/{MEMBER}", why), (f"members/{OTHER}", why), + cure=OUTSIDE_CURE.format(path=stopper)), + failed_on(MEMBER, OTHER)], result.stderr + assert f"No url found for submodule path '{stopper}'" in result.stderr + fallback = validate(clone, "--no-members") + assert fallback.returncode == 0, "premise: it reads only members/" + (line,) = re.findall(r"`(git rm [^`]*)`", result.stderr) + type_line(clone, env, line) + commit_all(clone, env, "The gitlink taken out as printed") + + again = bootstrap(clone, env, "--skip-members") + + assert again.returncode == 0, again.stdout + again.stderr + assert after_first_line(again) == FETCHED + assert_passes_in_every_mode(clone, 2) + + +def with_branch(value: str): + """An edit giving OTHER's section a `branch` key of `value`, after its + url.""" + return lambda data: url_edited( + data, OTHER, lambda line: line + f"\tbranch = {value}\n".encode()) + + +def test_a_gitmodules_a_merge_left_unmerged_says_to_add_it( + family, holder, offline, tmp_path): + """rv648's MM1. In a plain clone, a merge conflicts in `.gitmodules` + (each side gave OTHER's section another `branch`), and the file is + resolved in the work tree but not added: the index holds it only at + the conflict's three stages. Git reads no `.gitmodules` at all then, + so its init dies at MEMBER's gitlink, the first, and the run said ok, + reading the work tree's file. It names both rows for that and says to + add the file once it is resolved; added, the same run checks both + members out.""" + env = fetching(isolated(offline, tmp_path)) + remote = family["remotes"] / f"{NAME}.git" + side = f"w648/{next(BRANCHES)}" + data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout + committed_gitmodules(holder, env, with_branch("dev")(data)) + raw(holder, env, "push", "-q", str(remote), f"HEAD:refs/heads/{side}") + raw(holder, env, "update-index", "-q", "--refresh", check=False) + raw(holder, env, "reset", "-q", "--keep", "HEAD~1") + clone, _ = edited_clone(holder, env, family, tmp_path, + with_branch("main")) + raw(clone, env, "fetch", "-q", "origin", side) + merged = raw(clone, env, "merge", "-q", "--no-edit", "FETCH_HEAD", + check=False) + assert merged.returncode == 1, merged.stdout + merged.stderr + stages = raw(clone, env, "ls-files", "-u", "--", GITMODULES).stdout + assert len(stages.splitlines()) == 3, "premise: .gitmodules is unmerged" + (clone / GITMODULES).write_bytes( + raw(clone, env, "show", f":2:{GITMODULES}").stdout) + + result = bootstrap(clone, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert result.stderr.splitlines()[-2:] == [ + not_fetched((f"members/{MEMBER}", UNMERGED), + (f"members/{OTHER}", UNMERGED), cure=UNMERGED_CURE), + failed_on(MEMBER, OTHER)], result.stderr + assert (f"No url found for submodule path 'members/{MEMBER}'" + in result.stderr) + raw(clone, env, "add", "--", GITMODULES) + + again = bootstrap(clone, env, "--skip-members") + + assert again.returncode == 0, again.stdout + again.stderr + assert after_first_line(again) == FETCHED + + +@pytest.mark.parametrize("set_up", UNNAMED_STOPS.values(), ids=UNNAMED_STOPS) +def test_a_recursive_clone_with_no_credential_is_still_the_skip( + family, holder, offline, tmp_path, set_up): + """No misfire. A recursive clone sets `submodule.active` (`.`), and + git's init then asks only the gitlinks a section gives a path, so the + stray or the hand mount stops nothing there, and git fails on the + members' own fetch, as a private member with no token fails. The + members are the credential's: skipped, and the run ends ok.""" + env = isolated(offline, tmp_path) + set_up(holder, env, family) + remote = family["remotes"] / f"{NAME}.git" + branch = f"w648/{next(BRANCHES)}" + raw(holder, env, "push", "-q", str(remote), f"HEAD:refs/heads/{branch}") + clone = tmp_path / "clone" + # The holder is fetched, as a person with access to it fetches it, and + # its members are not: `user` refuses the file transport to the clones + # git makes for submodules. + made = clone_just_pushed(remote, clone, branch, + config=["-c", "protocol.file.allow=user"]) + assert made.returncode != 0, "premise: the members were not fetched" + assert "transport 'file' not allowed" in made.stderr, made.stderr + + result = bootstrap(clone, env, "--skip-members") + + assert result.returncode == 0, result.stdout + result.stderr + assert "FETCH INCOMPLETE" in result.stderr + assert "not for want of a credential" not in result.stderr + + +def test_a_url_an_earlier_init_registered_is_still_the_skip(family, holder, + offline, + tmp_path): + """No misfire. `git submodule init` wrote each member's url into the + clone's git config, then the work tree's section for OTHER lost its + url: git's init reads the url it wrote and does not stop there, and git + fails on the fetch for want of a credential. The members are skipped and + the run ends ok.""" + env = isolated(offline, tmp_path) + clone = cloned(holder, env, family, tmp_path, recurse=False) + raw(clone, env, "submodule", "init") + data = (clone / GITMODULES).read_bytes() + (clone / GITMODULES).write_bytes(without_url(data, OTHER)) + + result = bootstrap(clone, env, "--skip-members") + + assert result.returncode == 0, result.stdout + result.stderr + assert "FETCH INCOMPLETE" in result.stderr + assert "not for want of a credential" not in result.stderr + + +def test_a_row_written_twice_is_named_once(family, holder, offline, + tmp_path): + """OTHER's row written twice in a holder whose HEAD lost `.gitmodules`: + one path, so one name in (a) and one failure.""" + env = isolated(offline, tmp_path) + head_lost(holder, env) + rows = (holder / "family.yaml").read_bytes() + start = rows.index(f" - project: {OTHER}\n".encode()) + (holder / "family.yaml").write_bytes(rows + rows[start:]) + raw(holder, env, "add", "--", "family.yaml") + commit_all(holder, env, "OTHER's row written twice") + clone = cloned(holder, env, family, tmp_path, recurse=True) + + result = bootstrap(clone, fetching(env), "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert result.stderr.splitlines() == [ + not_fetched((f"members/{MEMBER}", NO_SECTION), + (f"members/{OTHER}", NO_SECTION)), + failed_on(MEMBER, OTHER)] + + +# --- (a) and (b) together: the plain clone of #363's state, typed through --- + +def test_a_plain_clone_of_a_case_variant_is_named_and_mended_as_printed( + family, holder, offline, tmp_path): + """The round trip a person makes. In a plain clone of #363's state, with + the variant at the SECOND row, `make bootstrap`'s script fails naming + both rows, since git stopped at the variant before it checked out + MEMBER, and the validator; the validator, members checked, names the + variant and the move, where it refused `member-uninitialized` for the + first row before. Typed as printed in the clone, with the commit and + the `mv` where the disk holds the two apart, the same script checks + both members out and runs each one's own bootstrap, and the gate passes + in every mode.""" + env = fetching(isolated(offline, tmp_path)) + commit = variant(holder, env) + clone = cloned(holder, env, family, tmp_path, recurse=False) + + booted = bootstrap(clone, env) + + assert booted.returncode == 1, booted.stdout + booted.stderr + assert booted.stderr.splitlines()[-2:] == [ + not_fetched((f"members/{MEMBER}", + STOPPED.format(path=f"Members/{OTHER}")), + (f"members/{OTHER}", NO_GITLINK), + cure=f"{VALIDATOR_CURE} {UNNAMED_CURE}"), + failed_on(MEMBER, OTHER)] + gate = validate(clone) + assert gate.returncode == 2, gate.stdout + gate.stderr + lines = gate.stderr.splitlines() + assert lines == variant_refused(f"members/{OTHER}", commit, + [f"Members/{OTHER}"], + {f"Members/{OTHER}": commit}) + for line in lines[2:]: + type_line(clone, env, line) + commit_all(clone, env, "Move the gitlink as printed") + moved_out_where_apart(clone, lines[1]) + + again = bootstrap(clone, env) + + assert again.returncode == 0, again.stdout + again.stderr + assert " every member and leg fetched" in again.stdout.splitlines() + for project in (MEMBER, OTHER): + assert f" --- {project}: make bootstrap ---" in again.stdout + assert again.stdout.endswith("\nfamily bootstrap ok\n"), again.stdout + assert_passes_in_every_mode(clone, 2) + + +# --- (b) the gate asks every row's gitlink before any row's checkout -------- + +@pytest.mark.parametrize("project", [MEMBER, OTHER], ids=["first", "second"]) +def test_a_plain_clone_after_the_update_names_the_variant_in_any_row( + family, holder, offline, tmp_path, project): + """THE ISSUE'S (b), as CI and `make bootstrap` run it: a plain clone, + then `git submodule update --init --recursive`, which dies at + `Members/

` (exit 128) before it checks out either member. Members + checked and `--pins` alike, the gate refuses the variant with the move, + whichever row it is; for the second it refused `member-uninitialized` + for the first row, naming that same update. Under `--no-members`, CI's + fallback, the finding prints the same two lines (c); typed as printed in + the clone and committed, the update checks both out and the gate + passes.""" + env = fetching(isolated(offline, tmp_path)) + commit = variant(holder, env, project) + clone = cloned(holder, env, family, tmp_path, recurse=False) + update = raw(clone, env, "submodule", "update", "--init", "--recursive", + check=False) + assert update.returncode == 128, update.stderr + assert (f"No url found for submodule path 'Members/{project}'".encode() + in update.stderr) + expected = variant_refused(f"members/{project}", commit, + [f"Members/{project}"], + {f"Members/{project}": commit}) + + for extra in ((), ("--pins",)): + gate = validate(clone, *extra) + assert gate.returncode == 2, (extra, gate.stdout + gate.stderr) + assert gate.stderr.splitlines() == expected, extra + fallback = validate(clone, "--no-members") + assert fallback.returncode == 1, fallback.stdout + fallback.stderr + (finding,) = findings(fallback) + assert commands(finding) == [line.strip() for line in expected[2:]] + + for line in commands(finding): + type_line(clone, env, line) + commit_all(clone, env, "Move the gitlink as CI's gate printed") + moved_out_where_apart(clone, finding) + raw(clone, env, "submodule", "update", "--init", "--recursive") + assert_passes_in_every_mode(clone, 2) + + +# --- (c) `--no-members` prints the move members mode prints ----------------- + +def unnamed_moved(variant_path: str, at: str, pin: str, + variants: list[str]) -> str: + """`member-gitlink-unnamed` WHOLE for the gitlink at `variant_path` (at + `at`), OTHER recorded only at `variants`: what every unnamed gitlink + says, the row it is a variant of, and members mode's move to the pin, + word for word.""" + path = f"members/{OTHER}" + refused = variant_refused(path, pin, variants, + dict.fromkeys(variants, at)) + assert refused[1].startswith(WRONG_CASE_LEAD + "Move its gitlink "), ( + "premise: members mode's remedy text") + return "\n".join([ + GITLINK_SAID.format(path=variant_path, commit=at) + + VARIANT.format(row_path=path, project=OTHER) + + " The member is recorded in the wrong case. " + + refused[1][len(WRONG_CASE_LEAD):], *refused[2:]]) + + +@DISKS +@pytest.mark.parametrize("spellings", [ + ("Members/{p}",), ("MEMBERS/{p}", "Members/{p}")], ids=["one", "two"]) +def test_no_members_prints_the_move_members_mode_prints( + family, holder, offline, tmp_path, ignorecase, spellings): + """THE ISSUE'S (c). OTHER's only gitlink at a case variant (two, in the + second case), at a commit that is not its pin. Under `--no-members` each + variant's `member-gitlink-unnamed` printed no command; it prints the + move members mode prints now, to the commit the row PINS, every variant + taken out on one line. Typed as printed and committed, on either disk, + the holder passes in every mode.""" + env = on_disk(isolated(offline, tmp_path), ignorecase) + variants = sorted(spelling.format(p=OTHER) for spelling in spellings) + at = other_older(family) + pin = pinned(holder, OTHER) + assert pin != at, "premise: the variant is not at the pin" + raw(holder, env, "rm", "-q", "--cached", "--", f"members/{OTHER}") + for spelling in variants: + stage_gitlink(holder, spelling, at) + commit_all(holder, env, f"{OTHER} recorded in another case") + + result = validate(holder, "--no-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert findings(result) == [unnamed_moved(path, at, pin, variants) + for path in variants] + for line in commands(findings(result)[0]): + type_line(holder, env, line) + commit_all(holder, env, "Move the gitlink as printed") + assert_passes_in_every_mode(holder, 2) + + +def test_a_row_whose_pin_is_no_commit_still_prints_no_command( + holder, offline, tmp_path): + """The move records the gitlink at the commit the row pins, so a row + whose `pin.commit` is no 40-hex commit (members checked, the refusal + `member-pin-commit`) gets the text it had, and no command.""" + env = isolated(offline, tmp_path) + commit = variant(holder, env) + rows = (holder / "family.yaml").read_bytes() + line = f' commit: "{commit}"\n'.encode() + assert rows.count(line) == 1, "fixture drift: OTHER's pin line" + (holder / "family.yaml").write_bytes( + rows.replace(line, f' commit: "{commit[:12]}"\n'.encode())) + + result = validate(holder, "--no-members") + + assert result.returncode == 1, result.stdout + result.stderr + (finding,) = findings(result) + assert finding == GITLINK_SAID.format( + path=f"Members/{OTHER}", commit=commit) + WRONG_CASE.format( + row_path=f"members/{OTHER}", project=OTHER, what="gitlink") + assert "git rm" not in result.stderr, result.stderr diff --git a/tests/test_validate_family_unnamed_mounts.py b/tests/test_validate_family_unnamed_mounts.py index 0d2f4798..f9257bca 100644 --- a/tests/test_validate_family_unnamed_mounts.py +++ b/tests/test_validate_family_unnamed_mounts.py @@ -28,7 +28,9 @@ the wrong case: the same command, run in either, takes the member's own record out of the index, after which `--no-members`, the run a private family's CI falls back to, says `family ok` over a member that is no longer -there. Those two print no command. +there. Those two print no such command: the first none, and the second, +since #648, the index move members mode prints, which records the member +at its row's path as it takes the variant out. When a hand mount's gitlink says to `git rm` it, its section finding says that the `git rm` takes the section out too and prints no `--unset-all` line: run @@ -513,14 +515,13 @@ def test_a_row_hand_edited_into_a_case_variant_of_its_mount_is_the_edit( @MODES -def test_a_member_recorded_only_in_another_case_says_so_and_prints_no_command( +def test_a_member_recorded_only_in_another_case_says_so_and_prints_the_move( holder, extra): """STATE (ii), REVIEW SHOULD-1: IRSS's row is canonical, and the only gitlink the holder records for it is at `members/irss`. `git rm --cached` of that took the member's only record out, and `--no-members` then said `family ok` over a holder that records one gitlink fewer than it has rows. - It says the member is recorded in the wrong case instead, and sends the - operator to git's history, as every other unnamed gitlink does. + It says the member is recorded in the wrong case instead. Members checked, the row's own gitlink is read at its exact path, finds none and is the refusal `member-gitlink-absent`, which prints no finding, @@ -529,26 +530,31 @@ def test_a_member_recorded_only_in_another_case_says_so_and_prints_no_command( there. What no run may do is print a command that takes the gitlink out ALONE: since #611 the refusal names the variant and prints the move that takes it out of the index and records it at the row's path together - (`tests/test_family_head_lost_gitmodules.py` types it).""" + (`tests/test_family_head_lost_gitmodules.py` types it), and since #648 + the finding prints the same two lines + (`tests/test_family_bootstrap_gate_order.py` types them).""" named = record_in_another_case(holder, "IRSS", "members/irss") result = validate(holder, *extra) assert "--unset-all" not in result.stderr + result.stdout + move = [ + " git rm -r --cached --ignore-unmatch -- members/irss", + f" git update-index --add --cacheinfo 160000,{named},members/IRSS"] if result.returncode == 2: assert extra != ("--no-members",), result.stderr assert result.stderr.startswith( "REFUSED member-gitlink-absent: no gitlink (mode 160000) " "recorded at 'members/IRSS'."), result.stderr - assert result.stderr.splitlines()[-2:] == [ - " git rm -r --cached --ignore-unmatch -- members/irss", - " git update-index --add --cacheinfo " - f"160000,{named},members/IRSS"], result.stderr + assert result.stderr.splitlines()[-2:] == move, result.stderr return - assert "git rm" not in result.stderr + result.stdout assert result.returncode == 1, result.stderr + result.stdout (gitlink,) = findings(result) - assert gitlink == GITLINK_SAID.format( - path="members/irss", commit=named) + WRONG_CASE.format( - row_path="members/IRSS", project="IRSS", what="gitlink") + said = GITLINK_SAID.format(path="members/irss", commit=named) + ( + VARIANT.format(row_path="members/IRSS", project="IRSS")) + assert gitlink.startswith( + said + " The member is recorded in the wrong case. Move its gitlink " + "to the row's path, at the commit the row pins, in the index " + "alone, "), gitlink + assert gitlink.splitlines()[1:] == move, gitlink @MODES