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 @@
Reference
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, "`), 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/ `, 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. ` 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. ` (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