diff --git a/templates/family-root/scripts/bootstrap.py b/templates/family-root/scripts/bootstrap.py index c9db04cf..70e30d6f 100755 --- a/templates/family-root/scripts/bootstrap.py +++ b/templates/family-root/scripts/bootstrap.py @@ -30,6 +30,20 @@ no url for, nor where it read nothing from `.gitmodules`, both below, since it fetched nothing then (#839). + Where every row's member is checked out and git's init in this + holder died all the same, at a gitlink this holder records with no + url or at a `.gitmodules` a merge left unmerged, git updated none of + them, so the run fails, saying where git stopped and what cures it, + as below, with no advice (#879). It said `family bootstrap ok` + there, beside git's exit 128, as a run with every member checked + out passes (#648), and gave the advice where git stopped at a + gitlink. This holder's records are the test, so an exit 128 they do + not show is read as before, the advice and `ok` where every member + is checked out: git stopped inside a member, at a gitlink in that + member's own index with no url, or at a gitlink of this holder's at + a commit no remote holds (`not our ref`) (lane 1's SHOULD 4 on + #889). + "every member and leg fetched" is said only where git exited 0 AND every row's member is checked out (#648). A member left NOT CHECKED OUT where no credential would check it out is said here instead, with @@ -66,8 +80,8 @@ checks the member out whatever the clone chose. For the third and the last, `validate-family.py --no-members` names a path two sections give, or a gitlink in members/ that no row names; `git rm - --cached` takes out one outside members/, which the validator does - not read; and where the gitlink is a row's own, with one section, + --cached` takes out one outside members/, as every mode of the + validator says (#878); and where it is a row's own, with one section, the line says to write its section back. Where git read the index's or HEAD's file only up to a line it cannot parse, or up to a key it takes no value for (a bare `url` line, which `git config --list` @@ -95,9 +109,14 @@ (c) EXITS 0 unless a member's own command failed, or (a) left a member not checked out that no credential would check out, or found a line or a - value in `.gitmodules` that git cannot read (#768). Any other member that - is not checked out is REPORTED and skipped, not a failure: it is the - state a missing credential leaves, and it is already reported in (a). + value in `.gitmodules` that git cannot read (#768), or found that, + with every member checked out, git's init in this holder died at a + gitlink this holder records with no url or at an unmerged + `.gitmodules` (#879); an exit 128 this holder's records do not show + is no failure here, as (a) says. Any + other member that is not checked out is REPORTED and skipped, not a + failure: it is the state a missing credential leaves, and it is + already reported in (a). Where (a) cannot start git, the run STOPS there, exit 2, refused `git-not-runnable` as `validate-family.py` refuses it (#694): each member's own bootstrap runs git too, so (b) would only say it again. @@ -144,6 +163,10 @@ NO_URL = "git has no url for that path" EXITED_0 = "git exited 0 and checked nothing out there" STOPPED = "git stopped at {}, a gitlink it has no url for" +#: What (a)'s line says where git updated no member, by whether `members:` +#: names any row: under "0 member(s)" it said no member was updated (lane +#: 1's NIT 5 on #889). +NONE_UPDATED = {True: "NO MEMBER UPDATED", False: "NOTHING UPDATED"} #: The whys a `.gitmodules` git read only up to a line it cannot parse, or #: up to a key with no value it stops at, may be the cause of, since what #: it did not read is past that line (`Gitmodules.cut`, #856). @@ -203,9 +226,13 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]: """(a) `git submodule update --init --recursive`, authenticated or not. Where every member is checked out and git died on a value in - `.gitmodules`, or on a work tree file it cannot parse, so it updated - none of them, the one failure line it returns is for `.gitmodules` - (#768). + `.gitmodules`, or on a work tree file it cannot parse, or its init + died at a `.gitmodules` a merge left unmerged, so it updated none of + them, the one failure line it returns is for `.gitmodules` (#768, + #879); where its init in this holder died at a gitlink this holder + records with no url, it is for that gitlink (`updated_none`, #879). + Its line says no member was updated, or, where `rows` names none, + nothing was (lane 1's NIT 5 on #889). Returns a failure line for each of `rows` whose member it leaves not checked out where no credential would check it out (`unfetchable`), for @@ -241,16 +268,19 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]: + ", ".join(f"{spelled(path)} ({reason(why, stopper, modules)})" for _, path, why in left) + ". " + cures(left, stopper, rows, modules), file=sys.stderr) - elif modules.refused or modules.unreadable: - why = REFUSED if modules.refused else UNREADABLE - print(" NO MEMBER UPDATED, and not for want of a credential: " - + reason(why, stopper, modules) + ". " - + cures(left, stopper, rows, modules), file=sys.stderr) - return [f"{GITMODULES} (git cannot read it)"] - elif proc.returncode == 0: + return [f"{spelled(row.get('project') or '?')} (not checked out)" + for row, _, _ in left] + whole = updated_none(stopper, modules) + if whole is not None: + print(f" {NONE_UPDATED[bool(rows)]}, and not for want of a " + "credential: " + reason(whole, stopper, modules) + ". " + + cures(left, stopper, rows, modules, (whole,)), + file=sys.stderr) + return [f"{spelled(stopper)} (git stopped at it)" if whole == STOPPED + else f"{GITMODULES} (git cannot read it)"] + if proc.returncode == 0: print(" every member and leg fetched") - return [f"{spelled(row.get('project') or '?')} (not checked out)" - for row, _, _ in left] + return [] def fetch_incomplete(proc: subprocess.CompletedProcess, source: str, @@ -317,9 +347,12 @@ def git_not_started(exc: OSError) -> Refusal | None: def cures(left: list[tuple[dict, str, str]], stopper: str | None, - rows: list[dict], modules: Gitmodules) -> str: + rows: list[dict], modules: Gitmodules, + whole: tuple[str, ...] = ()) -> str: """What (a)'s line says cures the rows `left` names, one sentence for - each kind of cause among them (#648): + each kind of cause among them (#648), or, where it names none, what + cures `whole`, why git updated no member (`updated_none`, #879), one + reason or none: - A `.gitmodules` a merge left unmerged in the index, which git reads nothing from (`Gitmodules.unmerged`): the file resolved and added. @@ -344,9 +377,9 @@ def cures(left: list[tuple[dict, str, str]], stopper: str | None, `member-uninitialized` for the first row where every row's gitlink is there, as with a stray or a hand mount, and `member-gitlink-absent` where a row's is only in another case, which the first sentence - names. A gitlink outside `members/`, which no mode of the validator - asks about, is taken out of the index: no row names it, and with no - url no clone can check it out. + names. A gitlink outside `members/` is taken out of the index, as + every mode of the validator says (#878): no row names it, and with + no url no clone can check it out. - A row git exited 0 over: the update that checks it out whatever the clone chose. `--init` with the path registers a member a subset clone left out, and `--checkout` overrides an `update = none`. @@ -359,7 +392,7 @@ def cures(left: list[tuple[dict, str, str]], stopper: str | None, for those: a section written back where the work tree holds no file would be the only one in it, and `validate-family.py` reads HEAD's file alone, not the index's.""" - whys = {why for _, _, why in left} + whys = {why for _, _, why in left} | set(whole) said = gitmodules_cures(whys, modules) if modules.cut: left = [entry for entry in left if entry[2] not in CUT_WHYS] @@ -879,7 +912,10 @@ def unfetchable(root: Path, rows: list[dict], exited_0: bool checked out is the missing credential's, and stays the skip it always was. A path two rows give is said once. Where every row's member is checked out and git did not exit 0, `.gitmodules` is read all the - same, for `fetch_members` to ask `refused` of.""" + same, and git's init asked where it stopped, for `fetch_members` to + ask why git updated none (`updated_none`): this returned before + `init_stop` there, so a run whose update git's init stopped said + `family bootstrap ok` (#879).""" left: dict[str, dict] = {} for row in rows: if not (member_path(root, row) / ".git").exists(): @@ -887,8 +923,6 @@ def unfetchable(root: Path, rows: list[dict], exited_0: bool if not left and exited_0: return [], None, Gitmodules(Counter(), {}, {}) modules = gitmodules_sections(root) - if not left: - return [], None, modules config = submodule_config(root) stopper = None if exited_0 else init_stop(root, modules, config) found: list[tuple[dict, str, str]] = [] @@ -905,12 +939,9 @@ def row_reason(root: Path, path: str, modules: Gitmodules, """Why `unfetchable` says the member at `path`, not checked out, is none a credential would check out (#648, #768), in the order it asks, or None where it is the missing credential's.""" - if modules.unmerged: - return UNMERGED - if modules.refused: - return REFUSED - if modules.unreadable: - return UNREADABLE + unread = file_reason(modules) + if unread is not None: + return unread if recorded_gitlink(root, path) is None: return NO_GITLINK if not modules.given[path]: @@ -922,6 +953,30 @@ def row_reason(root: Path, path: str, modules: Gitmodules, return None if stopper is None else STOPPED +def file_reason(modules: Gitmodules) -> str | None: + """Why git read nothing from `.gitmodules` (#648, #768), as (a)'s line + says it, or None where it read the file: a merge left it unmerged, it + gives a value git dies on, or it is a work tree file git cannot + parse.""" + if modules.unmerged: + return UNMERGED + if modules.refused: + return REFUSED + return UNREADABLE if modules.unreadable else None + + +def updated_none(stopper: str | None, modules: Gitmodules) -> str | None: + """Why git updated no member, where every row's member is checked out + and git did not exit 0, or None: what `file_reason` says, or `STOPPED` + where git's init in this holder died at a gitlink this holder records + with no url (`stopper`, `init_stop`), before it updated any (#879). + None where it asked for a credential, as a leg's fetch does. + `fetch_members` asks it only where `unfetchable` left no row, whose + line says why of each, so it asks nothing of the rows (lane 1's NIT 4 + on #889: a check of them here decided nothing).""" + return file_reason(modules) or (None if stopper is None else STOPPED) + + def members_of(root: Path) -> tuple[str, list[dict]]: manifest_path = root / MANIFEST if not manifest_path.is_file(): diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index ace9c3ec..cd5dd1a5 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -125,6 +125,19 @@ `contracts/shape-pin.yaml`. Editing a copy in place is drift and the exit is to carry it upstream, never to update the digest. +EVERY REMEDY IS TYPED AS PRINTED, IN THE ORDER PRINTED (#879). Where the +work tree's `.gitmodules` is gone from disk, the first remedy that writes +it puts it back first, from where git reads it, but for a +`gitmodules-value-refused` finding, which puts back HEAD's, whose value +its lines take out (#768), and no later one puts it back over what the +lines between wrote: a staged removal's finding after one that writes +the file, or says to commit it, writes its section back key by key and +stages the file, on disk or not. A `git rm` of a gitlink comes after +`git add -- .gitmodules` where lines printed before it leave that file +with changes not staged; and a checkout `git mv` moved off a row's path, +whose `.git` file names that member's store, is named, with its `git -C` +status, before the update that points the store at the row's path. + EXIT CODES 0 the manifest is valid, every member is in lockstep, every copy matches 1 a FINDING: drift a reviewer can act on @@ -261,6 +274,17 @@ def __init__(self) -> None: self.notes: list[str] = [] #: Each finding's code, in step with `findings` (#539). self.codes: list[str] = [] + #: Where the work tree's `.gitmodules` is gone from disk, git's name + #: for the file an earlier finding's line put back there, + #: `:.gitmodules` or `HEAD:.gitmodules`, so that no later finding + #: puts it back over what the lines between wrote (#879); empty + #: until one has. + self.put_back = "" + #: Whether a finding printed before says to write the work tree's + #: `.gitmodules`, or to commit it as it stands, so that no later + #: finding puts HEAD's file back over that (`_section_back`, lane + #: 1's MUST 1 and SHOULD 1 on #889). + self.wrote = False def finding(self, code: str, detail: str) -> None: self.findings.append(f"FINDING {code}: {detail}") @@ -548,16 +572,26 @@ def _gitlink_put_back(root: Path, path: str, commit: str, as `member-gitmodules-absent` writes one (`_section_lines`); and a member that is to go has its row taken out by hand. Either is committed, and a member put back is checked out as - `member-uninitialized` says. + `member-uninitialized` says. Where the work tree's `.gitmodules` is + gone from disk, the section's lines come after the one that puts the + file back from where git reads it (`_file_put_back`): `git config -f` + made a file of that one section, which staged and committed dropped + every other (#879). """ head = _head_gitmodules(root) sections = head[1] if head is not None else {} given = {keys["path"] for keys in sections.values() if "path" in keys} lines = ("" if path in given else _section_lines(path, repository, sections)) + put_back, whose = _file_put_back(root, None) if lines else ("", "") written = (", and its .gitmodules section back by hand, in a POSIX " - "shell, as family.py add writes one, with the lines after it, " - "then stage .gitmodules" if lines else "") + "shell, as family.py add writes one, with the lines after it" + + (f", the first of which puts {GITMODULES}, gone from the " + f"work tree, back from {whose}, so that every other " + "section stays" if put_back else "") + + ", then stage .gitmodules" if lines else "") + if put_back: + lines = f"\n {put_back}{lines}" return ( "Remediation: family.py add refuses the row, which is there " "(member-already-present), and family.py remove cannot take the " @@ -629,7 +663,14 @@ def _gitlink_in_another_case(root: Path, path: str, commit: str, "Remediation: the member is recorded in the wrong case. " + _wrong_case_move(path, commit, variants, _move_context(root, path, variants, repository, - gitlinks))) + gitlinks, None))) + + +#: What the `-f` sentence says is on disk at a gitlink's path (`_held_as`): +#: a checkout, as #839 said, or what is there, which is none (rv882's NIT 2 +#: on #882). +CHECKOUT = "a checkout" +NO_CHECKOUT = "what is on disk there, which is no checkout" class MoveContext(NamedTuple): @@ -639,21 +680,29 @@ class MoveContext(NamedTuple): or under the row's path; and `section`, the lines that make `.gitmodules` give the row's path, with what they do, or empty where a section of HEAD's already gives it. `forced`, the variants whose `git - rm --cached` takes `-f` (`_forced`, #839).""" + rm --cached` takes `-f` (`_forced`, #839), and `forced_over`, what is + on disk at them, as the `-f` sentence says it (`_forced_over`, rv882's + NIT 2 on #882, and lane 1's NIT 3 on #889). `stage`, whether `git add + -- .gitmodules` comes first (`_stage_first`, #879).""" sparse: tuple[str, ...] = () in_the_way: tuple[str, ...] = () section: str = "" section_said: str = "" forced: tuple[str, ...] = () + stage: bool = False + forced_over: str = CHECKOUT def _move_context(root: Path, path: str, variants: list[str], - repository, gitlinks: dict[str, str]) -> MoveContext: + repository, gitlinks: dict[str, str], + report: Report | None) -> MoveContext: """What the index and HEAD's `.gitmodules` hold around the move of a member recorded only at `variants` to its row's `path` (#762), and which variants the move's `git rm --cached` takes out only with `-f` - (`_forced`, by `gitlinks`, `_member_gitlinks`, #839). + (`_forced`, by `gitlinks`, `_member_gitlinks`, #839). `report` holds + the findings printed before the move, or is None for members mode's + refusal, which is printed alone (#879). `--sparse` (the issue's (f)): a variant a sparse checkout keeps off disk (`git ls-files -t`'s `S`, as `family.py`'s `_skip_worktree` reads @@ -684,7 +733,47 @@ def _move_context(root: Path, path: str, variants: list[str], first variant's where two have one; and where none gives a variant's either, the section is written as `family.py add` writes one (`_section_lines`). Then `git add` stages the file. + + WHERE THE WORK TREE'S `.gitmodules` IS GONE FROM DISK, the section's + lines come after the one that puts it back from where git reads it, + unless a finding printed before the move did (`_file_put_back`): `git + config -f` made a file of that one key, which the move's `git add` + staged, so the commit dropped every other section, and the put-back of + a `member-gitmodules-url-absent` after it restored that staged file + (#879's C1, rv876's P5b and P5e). + + `git add -- .gitmodules` COMES FIRST where a value finding's lines, + printed before it, may leave that file with changes not staged, or it + holds some as it stands (`_stage_first`): git takes no gitlink out of + the index then, and the move's `git rm` exited 128 (#879's C3). """ + sparse, in_the_way = _index_around(root, path, variants) + section, said = _move_section(root, path, variants, repository, report) + edited = report is not None and "gitmodules-value-refused" in report.codes + forced = _forced_variants(root, variants, gitlinks) + return MoveContext(sparse, in_the_way, section, said, forced, + _stage_first(root, edited), _forced_over(root, forced)) + + +def _forced_over(root: Path, forced: tuple[str, ...]) -> str: + """What the `-f` sentence says is on disk at the variants `forced` + (`_held_as`): `CHECKOUT` or `NO_CHECKOUT` where every one holds the + same, and each by its path where they differ, since one said for all + called a checkout no checkout (lane 1's NIT 3 on #889).""" + held = {variant: _held_as(root, variant) for variant in forced} + if len(set(held.values())) < 2: + return next(iter(held.values()), CHECKOUT) + return ", and over ".join( + f"a checkout at {variant}" if what == CHECKOUT + else f"what is on disk at {variant}, which is no checkout" + for variant, what in held.items()) + + +def _index_around(root: Path, path: str, variants: list[str] + ) -> tuple[tuple[str, ...], tuple[str, ...]]: + """`(the variants a sparse checkout keeps off disk, every entry the + index holds at or under path but those a merge left unmerged)`, from + `git ls-files -t`, for `_move_context` (#762's (f) and (g)).""" with _git_runs(f"the index entries at and around {path} could not be " "listed"): listed = git_out(["ls-files", "-t", "-z"], cwd=root, binary=True) @@ -696,29 +785,47 @@ def _move_context(root: Path, path: str, variants: list[str], sparse.add(name) if tag != b"M" and (name == path or name.startswith(path + "/")): in_the_way[name] = None + return tuple(sorted(sparse)), tuple(in_the_way) + + +def _move_section(root: Path, path: str, variants: list[str], repository, + report: Report | None) -> tuple[str, str]: + """`(the lines that make .gitmodules give path, what they do)` for the + wrong-case move (`_move_context`, #762's (e)), or two empty strings + where a section of HEAD's gives it already: the section of a variant's + path given the row's, or one written as `family.py add` writes it, + after the line that puts the file back where it is gone from the work + tree and no finding before put it back (`_file_put_back`, #879's C1), + then `git add`. Where `report` holds the findings printed before it, + the write is recorded there (`Report.wrote`), so no finding after it + puts HEAD's file back over it (lane 1's MUST 1 on #889, its P5).""" head = _head_gitmodules(root) sections = head[1] if head is not None else {} given = {keys["path"]: name for name, keys in sections.items() if "path" in keys} - section = said = "" - if path not in given: - named = next((given[variant] for variant in variants - if variant in given), None) - if named is not None: - section = (f"\n git config -f {GITMODULES} " - f"{shlex.quote(f'submodule.{named}.path')} " - f"{shlex.quote(path)}") - said = (f"give {path} back to the section of HEAD's " - f"{GITMODULES} that gives the variant's path, as git mv " - "leaves it") - else: - section = _section_lines(path, repository, sections) - said = (f"write its section as family.py add writes one, since " - f"no section of HEAD's {GITMODULES} gives {path} or the " - "variant's path") - section += f"\n git add -- {GITMODULES}" - return MoveContext(tuple(sorted(sparse)), tuple(in_the_way), section, - said, _forced_variants(root, variants, gitlinks)) + if path in given: + return "", "" + named = next((given[variant] for variant in variants + if variant in given), None) + if named is not None: + section = (f"\n git config -f {GITMODULES} " + f"{shlex.quote(f'submodule.{named}.path')} " + f"{shlex.quote(path)}") + said = (f"give {path} back to the section of HEAD's {GITMODULES} " + "that gives the variant's path, as git mv leaves it") + else: + section = _section_lines(path, repository, sections) + said = (f"write its section as family.py add writes one, since no " + f"section of HEAD's {GITMODULES} gives {path} or the " + "variant's path") + put_back, whose = _file_put_back(root, report) + if put_back: + section = f"\n {put_back}{section}" + said = (f"put {GITMODULES}, gone from the work tree, back from " + f"{whose}, so that every other section stays, then {said}") + if report is not None: + report.wrote = True + return f"{section}\n git add -- {GITMODULES}", said def _forced_variants(root: Path, variants: list[str], @@ -734,23 +841,78 @@ def _forced(root: Path, path: str, commit: str) -> str | None: `_member_gitlinks` reads at `commit`, out of the index as it is, else what HEAD records at `path`, its commit or `""` for nothing, where that takes `-f` (#839): the index stages the gitlink at a commit HEAD does - not record there, and a checkout is on disk at it, a `.git` entry. Git - refuses it then wherever that checkout has moved on or holds a file it - does not track (`the following file has staged content different from - both the file and the HEAD`, exit 1), measured on git 2.43 after `git - mv members/

Members/

`, staged, and a commit or an untracked file - in the checkout it moved (w761's candidate 2 on #828), and `-f`, with - `--cached`, touches no file. A gitlink HEAD alone records, at a staged - removal or a conflict, is read at HEAD's commit and takes none.""" - if not os.path.lexists(root / path / ".git"): + not record there, and something is on disk at it (`_on_disk`). Git + refuses it then wherever a checkout there has moved on or holds a file + it does not track (`the following file has staged content different + from both the file and the HEAD`, exit 1), measured on git 2.43 after + `git mv members/

Members/

`, staged, and a commit or an untracked + file in the checkout it moved (w761's candidate 2 on #828), and `-f`, + with `--cached`, touches no file. It refuses it too over a directory + that holds files and no `.git`, a file or a symbolic link, and takes it + over an empty directory or nothing (git 2.43, rv882's NIT 2 on #882). + A gitlink HEAD alone records, at a staged removal or a conflict, is + read at HEAD's commit and takes none.""" + if not _on_disk(root, path): return None + head = _head_records(root, path) + return None if head == commit else head + + +def _head_records(root: Path, path: str) -> str: + """The commit HEAD records at `path`, lower case, or `""` for nothing + (`_forced`, `_staged_over`).""" with _git_runs(f"the gitlink HEAD records at {path} could not be read"): try: - head = git_out(["rev-parse", "--verify", "--quiet", + return git_out(["rev-parse", "--verify", "--quiet", f"HEAD:{path}"], cwd=root).lower() except Refusal: - head = "" - return None if head == commit else head + return "" + + +def _staged_over(root: Path, path: str, commit: str) -> bool: + """Whether a plain `git rm -- ` refuses the gitlink at `path`, + which `_member_gitlinks` reads at `commit`, as staged (`STAGED_AT`): + the index stages it at a commit HEAD does not record there, and + anything is on disk at it, an empty directory too, which `git rm + --cached` takes with no `-f` (`_forced`). Measured on git 2.43: with + nothing on disk it took one staged and not committed; it refused one, + exit 1, saying `the following file has changes staged in the index` + where what is on disk matches what is staged, an empty directory, the + clean checkout `git mv members/

members/

2` moved, or a checkout + moved to `HEAD~1` and `git add`-ed, where HEAD records another commit, + and `... has staged content different from both the file and the HEAD` + where it differs too, a commit or an untracked file in the checkout + `git mv` moved, a directory holding a file, or a file (lane 1's SHOULD + on #889, round 2, and its NITs 1 and 2, round 3).""" + return (os.path.lexists(root / path) + and _head_records(root, path) != commit) + + +def _on_disk(root: Path, path: str) -> bool: + """Whether anything but an empty directory is on disk at `path`, where + a staged gitlink's `git rm --cached` takes `-f` (`_forced`): git 2.43 + refused it, with no `-f`, over a checkout, a directory holding a file + and no `.git` (rv882's NIT 2 on #882), a file and a symbolic link, and + took it over an empty directory and over nothing. A directory this user + cannot list is taken to hold something: `-f`, with `--cached`, touches + no file.""" + where = root / path + if not os.path.lexists(where): + return False + if where.is_symlink() or not where.is_dir(): + return True + try: + with os.scandir(where) as entries: + return any(True for _ in entries) + except OSError: + return True + + +def _held_as(root: Path, path: str) -> str: + """What the `-f` sentence says is on disk at `path` (`_forced_said`): + `CHECKOUT` where a `.git` entry is there, else `NO_CHECKOUT`.""" + return (CHECKOUT if os.path.lexists(root / path / ".git") + else NO_CHECKOUT) def _wrong_case_move(path: str, commit: str, variants: list[str], @@ -786,26 +948,31 @@ def _wrong_case_move(path: str, commit: str, variants: list[str], is kept). THE `git rm` TAKES `-f` where the index stages a variant at a commit - HEAD does not record there and a checkout is on disk at it - (`MoveContext.forced`, `_forced`, #839): git refuses it there wherever - that checkout has moved on or holds a file it does not track, and the - text says what `-f` does (`FORCED_SAID`). + HEAD does not record there and anything but an empty directory is on + disk at it (`MoveContext.forced`, `_forced`, `_on_disk`, #839, rv882's + NIT 2 on #882): git refuses it there wherever a checkout there has + moved on or holds a file it does not track, and over a directory that + holds files and no `.git`, a file or a symbolic link. The text says + what `-f` does (`FORCED_SAID`) and what is on disk there, each + variant's where they differ (`MoveContext.forced_over`, lane 1's NIT 3 + on #889). It comes after `git add -- .gitmodules` where + `MoveContext.stage` (`_stage_first`, #879's C3). """ taken_out = " ".join(shlex.quote(variant) for variant in variants) moved = " and ".join(_guarded_move(variant, path) for variant in variants) - status = " or ".join(f"`git -C {shlex.quote(variant)} status`" - for variant in variants) - unpushed = " or ".join(f"`git -C {shlex.quote(variant)} log --oneline " - "HEAD --branches --not --remotes`" - for variant in variants) + status = _statuses(variants) + unpushed = _unpushed(variants) flag = (" --sparse" if context.sparse else "") + ( " -f" if context.forced else "") + lead = (STAGE_SAID if context.stage else "") + "move" + lead = (f"Then {lead}" if context.in_the_way + else f"{lead[0].upper()}{lead[1:]}") return ( _entries_aside(path, context.in_the_way) - + ("Then move" if context.in_the_way else "Move") - + " its gitlink to the row's path, at the commit the row pins, in " - "the index alone, which touches no file, with the " - + ("first two lines below" if context.section + + f"{lead} its gitlink to the row's path, at the commit the row " + "pins, in the index alone, which touches no file, with the " + + ("next two lines" if context.stage + else "first two lines below" if context.section else "two lines below") + _first_line_flags(context) + (f"; the lines after them {context.section_said}, and stage " @@ -838,8 +1005,9 @@ def _wrong_case_move(path: str, commit: str, variants: list[str], "checkout git mv moved to a variant names the member's own store, " f"which that update points at {path}, so {status} reads {path} " "after it. The " - + ("lines" if context.section else "two lines") + ":\n" - f" git rm -r --cached{flag} --ignore-unmatch -- {taken_out}\n" + + ("lines" if context.section or context.stage else "two lines") + + ":\n" + (f" {STAGE_LINE}\n" if context.stage else "") + + f" git rm -r --cached{flag} --ignore-unmatch -- {taken_out}\n" " git update-index --add --cacheinfo " f"{shlex.quote(f'160000,{commit},{path}')}{context.section}") @@ -892,7 +1060,8 @@ def _first_line_flags(context: MoveContext) -> str: if context.forced: said.append(f"-f, since the index stages " f"{' and '.join(context.forced)} at a commit HEAD does " - f"not record there, over a checkout: {FORCED_SAID}") + f"not record there, over {context.forced_over}: " + f"{FORCED_SAID}") return f" (the first with {', and with '.join(said)})" if said else "" @@ -1520,7 +1689,11 @@ def _check_gitmodules_values(root: Path, members: list, `https://github.com/` and the row's `repository:` (`_written_back`). Where HEAD's file alone gives the value, the work tree's is what to commit, and where the work tree has no `.gitmodules`, the first such - finding puts HEAD's back first.""" + finding puts HEAD's back first, whose value its lines take out, and + not the index's, which git reads there: a fix staged in the index's + file is lost (#768, lane 1's NIT 1 on #889). Every one of these is + recorded as a write (`Report.wrote`): a staged removal's finding after + it then puts no file back over the fix (lane 1's SHOULD 1).""" present = os.path.lexists(root / GITMODULES) here = _submodule_entries(_gitmodules_listed(root)) head = _head_listed(root) @@ -1532,6 +1705,7 @@ def _check_gitmodules_values(root: Path, members: list, and isinstance(row.get("project"), str) and row["project"]} put_back = present for name, key in dict.fromkeys([*in_work, *in_head]): + report.wrote = True said = _value_said(name, key, in_work, in_head) if present and (name, key) not in in_work: report.finding("gitmodules-value-refused", @@ -1542,6 +1716,8 @@ def _check_gitmodules_values(root: Path, members: list, report.finding("gitmodules-value-refused", _value_taken_out( said, f"submodule.{name}.{key}", back, put_back=put_back, committed=(name, key) in in_head)) + if not put_back: + report.put_back = f"HEAD:{GITMODULES}" put_back = True return bool(in_work), bool(in_work or in_head) @@ -1724,6 +1900,23 @@ def _gitmodules_put_back(root: Path) -> tuple[str, str, str]: return "", "", "" +def _file_put_back(root: Path, report: Report | None) -> tuple[str, str]: + """`(the line that puts the work tree's .gitmodules back, whose file it + puts back)` for a remedy whose lines write that file with `git config + -f` where it is gone from disk (`_gitmodules_put_back`), or two empty + strings: where the work tree holds one, and where a finding printed + before it put it back (`Report.put_back`), so ONE PUT-BACK SERVES EVERY + FINDING and none puts it back over what the lines between wrote + (#879). `report` holds the findings printed before it, and records the + put-back; None for a refusal, printed alone, which always says it.""" + if report is not None and report.put_back: + return "", "" + spec, line, whose = _gitmodules_put_back(root) + if report is not None: + report.put_back = spec + return line, whose + + def _check_gitmodules_paths(sections: dict[str, dict[str, str]], report: Report) -> None: """No two sections of `.gitmodules` give ONE path (#300). @@ -1736,7 +1929,9 @@ def _check_gitmodules_paths(sections: dict[str, dict[str, str]], fetched. `add` refuses to write one now; this finds a holder that already has one. `sections` is `_gitmodules_sections`'s reading. A section whose path no row names is `_check_gitmodules_named`'s question - (#361). + (#361). Its by-hand edit is recorded as a write (`Report.wrote`), so a + staged removal's finding after it puts no file back over the edit + (lane 1's SHOULD 1 on #889). """ paths: dict[str, list[str]] = {} for name, keys in sections.items(): @@ -1760,6 +1955,7 @@ def _check_gitmodules_paths(sections: dict[str, dict[str, str]], f"-f {GITMODULES} --unset-all submodule..` for every " "key that section holds, so a submodule later given its name " f"inherits none of them, then commit {GITMODULES}.") + report.wrote = True def _in_members(path: str) -> bool: @@ -2038,63 +2234,105 @@ def _check_gitlinks_named(root: Path, gitlinks: dict[str, str], typed again failed: it says to commit the removal instead and prints no `git rm` (#681). A hand mount's keeps the branch and the `add`. + EACH `git rm` COMES AFTER `git add -- .gitmodules` where a value + finding's lines, printed before it, may leave that file with changes + not staged, or it holds some as it stands (`_stage_first`): git takes + no gitlink out of the index then, `--cached` and `-f` or not, and the + stray's, the hand mount's and the move's `git rm` each exited 128 + (#879's C3). The move puts back a `.gitmodules` gone from the work + tree before it writes one, unless a finding before it did + (`_move_context`, #879's C1). + A gitlink outside `members/` is no member's mount, and no row can name it: `_check_gitlinks_elsewhere` and `_check_gitmodules_named` ask about it (#878). + + The gitlinks in `members/` no row names are listed once, before the + loop, and each one's remedy is `_variant_remedy`'s or + `_mounted_by_hand`'s, so this stays within S3776's limit (rv882's + SHOULD 3 on #882). + + A hand mount's plain `git rm` takes its section out of the work tree's + `.gitmodules`, as the move's lines write one (`_move_section`): each is + recorded as a write (`Report.wrote`), so a staged removal's finding + after it puts no file back over it (lane 1's SHOULD 1 on #889). """ - holds = set(gitlinks) - head = _head_gitmodules(root) if set(gitlinks) - set(named) else None + unnamed = [path for path in sorted(gitlinks) + if path not in named and _in_members(path)] + if not unnamed: + return + head = _head_gitmodules(root) fetched = ({keys["path"] for keys in head[1].values() if "path" in keys and "url" in keys} if head is not None else set()) - for path, commit in sorted(gitlinks.items()): - if path in named or not _in_members(path): - continue - said = (f"{path} is a gitlink at {commit} that no row of members: " - + ("names, so a recursive clone of this holder fetches it " - "and nothing here pins, digests or identifies it." - if path in fetched else - f"names and no section of HEAD's {GITMODULES} gives a " - "url, so a recursive clone of this holder checks out " - "nothing at it, in a plain clone git submodule update " - "--init stops there (No url found for submodule path, " - "exit 128) before it checks out any member, and nothing " - "here pins, digests or identifies it.")) + stage = head is not None and _stage_first( + root, "gitmodules-value-refused" in report.codes) + for path in unnamed: + said = _gitlink_unnamed_said(path, gitlinks[path], path in fetched) variant = _case_variant(path, named) 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, - _move_context(root, row_path, - variants, - row.repository, - gitlinks))) - continue - if kind != STRAY: - report.finding( - "member-gitlink-unnamed", - said + _variant_advice(kind, path, row_path, row, - "gitlink")) - continue report.finding( "member-gitlink-unnamed", - f"{said}{_variant_said(row_path, row)} " - + _second_path_out(root, path, commit, path in removed)) + said + _variant_remedy(root, path, variant, gitlinks, + path in removed, stage, report)) continue - parts = path.split("/") - project = parts[1] if len(parts) == 2 and parts[1] else None report.finding( "member-gitlink-unnamed", - f"{said} " + _mounted_by_hand(root, path, project, - path in removed)) + f"{said} " + _mounted_by_hand( + root, path, _hand_project(path), path in removed, stage, + _staged_over(root, path, gitlinks[path]))) + report.wrote = report.wrote or path not in removed + + +def _gitlink_unnamed_said(path: str, commit: str, fetched: bool) -> str: + """What `member-gitlink-unnamed` says first of the gitlink at `path`, + at `commit`, by whether a section of HEAD's `.gitmodules` gives it a + url (`fetched`): a recursive clone fetches it, or no clone checks it + out and a plain clone's update stops there (#762).""" + return (f"{path} is a gitlink at {commit} that no row of members: " + + ("names, so a recursive clone of this holder fetches it and " + "nothing here pins, digests or identifies it." if fetched else + f"names and no section of HEAD's {GITMODULES} gives a url, so " + "a recursive clone of this holder checks out nothing at it, " + "in a plain clone git submodule update --init stops there (No " + "url found for submodule path, exit 128) before it checks out " + "any member, and nothing here pins, digests or identifies " + "it.")) + + +def _hand_project(path: str) -> str | None: + """The project a hand mount at `members/

` would be mounted as, `

`, + or None for a path deeper or shallower than that.""" + parts = path.split("/") + return parts[1] if len(parts) == 2 and parts[1] else None + + +def _variant_remedy(root: Path, path: str, variant: tuple[str, RowPath], + gitlinks: dict[str, str], staged: bool, stage: bool, + report: Report) -> str: + """What `member-gitlink-unnamed` says, after what the gitlink at `path` + is, of one a row names in another case (`variant`, `_case_variant`): + the wrong-case move where it is the only record of the member + (`_wrong_case_move`), the second path's `git rm --cached` where it is + a STRAY (`_second_path_out`, after `git add -- .gitmodules` where + `stage`, and the commit alone where its removal is `staged`), and + `_variant_advice`'s words for the others, with no command.""" + row_path, row = variant + kind = _variant_case(path, row_path, row, set(gitlinks)) + if kind == WRONG_CASE and row_path == row.canonical and row.pin: + variants = sorted(other for other in gitlinks + if other != row_path + and other.casefold() == row_path.casefold()) + return (f"{_variant_said(row_path, row)} The member is recorded in " + "the wrong case. " + + _wrong_case_move(row_path, row.pin, variants, + _move_context(root, row_path, variants, + row.repository, gitlinks, + report))) + if kind != STRAY: + return _variant_advice(kind, path, row_path, row, "gitlink") + return (f"{_variant_said(row_path, row)} " + + _second_path_out(root, path, gitlinks[path], staged, stage)) #: What a gitlink whose removal is staged and not committed says in place of @@ -2119,8 +2357,9 @@ def _second_path_out(root: Path, path: str, commit: str, (w761's candidate 2 on #828, measured on the gate's state). `gitlink-unnamed` says the same of a gitlink outside `members/` that no - section gives (#878), after `git add -- .gitmodules` where - `stage_first` (`_stage_first`).""" + section gives (#878). Both come after `git add -- .gitmodules` where + `stage_first` (`_stage_first`): after a value finding's lines the + stray's plain `git rm --cached` exited 128 (#879's C3).""" if staged: return f"{STAGED_REMOVAL} Commit that removal." head = _forced(root, path, commit) @@ -2128,7 +2367,7 @@ def _second_path_out(root: Path, path: str, commit: str, else "Take") + " it out of the index alone, which touches no file" + _forced_said(head, "the second line" if stage_first - else "the line") + else "the line", _held_as(root, path)) + ":\n " + (f"{STAGE_LINE}\n " if stage_first else "") + _cached_removal(path, head)) @@ -2137,11 +2376,16 @@ def _second_path_out(root: Path, path: str, commit: str, #: after where `.gitmodules` may hold changes not staged (`_stage_first`, #: #878), and what the text says of it, before what it says of the `git rm`. STAGE_LINE = f"git add -- {GITMODULES}" -STAGE_SAID = (f"stage {GITMODULES} with the first line, since git rm refuses " - "to take any gitlink out of the index, -f or not, while " - f"{GITMODULES} holds changes not staged (please stage your " - f"changes to {GITMODULES} or stash them to proceed, exit 128), " - "as lines printed above may leave it or as it stands, then ") +STAGE_WHY = ("since git rm refuses to take any gitlink out of the index, -f " + f"or not, while {GITMODULES} holds changes not staged (please " + f"stage your changes to {GITMODULES} or stash them to proceed, " + "exit 128), as lines printed above may leave it or as it stands") +#: Said of `git add -- .gitmodules` beside why: it stages every other +#: change in the file too, which the commit after it then holds (rv882's +#: NIT 3 on #882). +STAGE_TOO = "which stages every other change in it too" +STAGE_SAID = (f"stage {GITMODULES} with the first line, {STAGE_TOO}, " + f"{STAGE_WHY}, then ") def _stage_first(root: Path, edited: bool) -> bool: @@ -2180,19 +2424,21 @@ def _stage_first(root: Path, edited: bool) -> bool: return git_out(["hash-object", "--", GITMODULES], cwd=root) != staged -def _forced_said(head: str | None, line: str) -> str: +def _forced_said(head: str | None, line: str, + held: str = CHECKOUT) -> str: """What the text says after "take it out of the index alone, which touches no file" of the `git rm --cached` it prints as `line`, where `_forced` answered `head` (#839): that the removal is committed, and - where the line takes `-f`, why, and what `-f` does (`FORCED_SAID`), - and that nothing is left to commit where HEAD records nothing.""" + where the line takes `-f`, why, over what is on disk there (`held`, + `_held_as`), and what `-f` does (`FORCED_SAID`), and that nothing is + left to commit where HEAD records nothing.""" if head is None: return ", and commit that" return ((", and commit that. The index stages it at a commit HEAD does " "not record there" if head else ": HEAD records nothing there, so nothing of it is left to " "commit. The index stages it") - + f" over a checkout, so {line} takes -f: {FORCED_SAID}") + + f" over {held}, so {line} takes -f: {FORCED_SAID}") def _cached_removal(path: str, head: str | None) -> str: @@ -2235,13 +2481,18 @@ def _check_gitlinks_elsewhere(root: Path, gitlinks: dict[str, str], `git add -- .gitmodules` COMES FIRST where a `gitmodules-value-refused` finding, printed before this one, may leave the file with changes not staged, or it holds some as it stands (`_stage_first`): git takes no - gitlink out of the index then, and the `git rm` exited 128.""" + gitlink out of the index then, and the `git rm` exited 128. + + WHERE THE WORK TREE'S `.gitmodules` IS GONE FROM DISK, the paths are + read from the file git reads in its place (`_given_as_git_reads`): + read from the work tree, none was given, and this said "no section of + .gitmodules gives" of a gitlink the index's and HEAD's gave, whose + plain clone checked it out (rv882's SHOULD 2 on #882).""" unnamed = [path for path in sorted(gitlinks) if path not in named and not _in_members(path)] if not unnamed: return - given = {keys["path"] for keys in _gitmodules_sections(root).values() - if "path" in keys} + given = _given_as_git_reads(root) stage_first = _stage_first(root, "gitmodules-value-refused" in report.codes) for path in unnamed: @@ -2260,14 +2511,47 @@ def _check_gitlinks_elsewhere(root: Path, gitlinks: dict[str, str], stage_first)) +def _given_as_git_reads(root: Path) -> set[str]: + """Every path a section of `.gitmodules` gives, as git's submodule + commands read the file: the work tree's where it is on disk, else the + one git reads in its place (`_gitmodules_put_back`, the index's, then + HEAD's), else none (rv882's SHOULD 2 on #882).""" + if os.path.lexists(root / GITMODULES): + return {keys["path"] for keys in _gitmodules_sections(root).values() + if "path" in keys} + spec = _gitmodules_put_back(root)[0] + return _blob_paths(root, spec) if spec else set() + + +#: What a hand mount's text says its `git rm` meets where the index stages +#: the gitlink at a commit HEAD does not record there (`_mounted_by_hand`, +#: lane 1's SHOULD on #889, round 2): git's two refusals, each exit 1, the +#: second where what is on disk differs from what is staged too, as git +#: 2.43 gave them (`_staged_over`; lane 1's NIT 1 on #889, round 3). +STAGED_AT = (", and while the index stages it at a commit HEAD does not " + "record there, as it does here (the following file has changes " + "staged in the index, or, where what is on disk differs too, " + "has staged content different from both the file and the HEAD, " + "exit 1),") + + def _mounted_by_hand(root: Path, path: str, project: str | None, - staged: bool) -> str: + staged: bool, stage_first: bool = False, + staged_at: bool = False) -> str: """What a gitlink no row names, in no case, says to do after what it is: put its row back, or take a hand mount out, commit that, and mount it with `family.py add`, on a branch (`_check_gitlinks_named`). Where its removal is staged already (#681), the same with no `git rm`, and the checkout `git rm --cached` leaves at the path, which `family.py add` - refuses (`member-path-taken`), deleted by hand first (rv681's S1).""" + refuses (`member-path-taken`), deleted by hand first (rv681's S1). + Its `git rm` comes after `git add -- .gitmodules` where `stage_first` + (`_stage_first`): after a value finding's lines it exited 128 (#879's + C3). Where that `git rm` refuses the gitlink as staged (`staged_at`, + as `_staged_over` reads it), as after `git mv members/

+ members/

2`, staged, the text says so (`STAGED_AT`), where it said + only that the line refuses while the checkout holds changes (lane 1's + SHOULD on #889, round 2). No line here takes such a gitlink out: + `-f` would take the checkout's changes with it.""" member = (PROJECT_PLACEHOLDER if project is None else shlex.quote(project)) add = (f" {PYTHON} scripts/family.py add --family-root " @@ -2288,20 +2572,27 @@ def _mounted_by_hand(root: Path, path: str, project: str | None, "one commit, and names the object store git rm leaves under " f".git/modules if one is still there. {BRANCH_LEFT}\n" f" {_switch_line(root, 'add', project)}\n{add}") + staging, rm_at, add_at = ((f"stage {GITMODULES} with the second, " + f"{STAGE_TOO}, {STAGE_WHY}, ", "third", + "fourth") + if stage_first else ("", "second", "third")) return ( f"{RESTORE_ROW} If it was mounted by hand, make a branch " "with the first command below, since family.py add refuses the " - "holder's tracking branch, take it out with the second, which " - f"refuses while its checkout holds changes and takes its " - f"{GITMODULES} section out too, and commit that removal, since " + f"holder's tracking branch, {staging}take it out with the " + f"{rm_at}, which refuses while its checkout holds changes" + + (STAGED_AT if staged_at else "") + + f" and takes its {GITMODULES} section out too, and commit that " + "removal, since " "family.py add refuses a path under another spelling of members/ " "that HEAD holds and the index no longer does, then mount it " - "with the third, from a checkout of openRepoShape: family.py add " - "writes its row and its gitlink in one commit, and names the " + f"with the {add_at}, from a checkout of openRepoShape: family.py " + "add writes its row and its gitlink in one commit, and names the " "object store git rm leaves under .git/modules if one is still " f"there. {BRANCH_LEFT}\n" f" {_switch_line(root, 'add', project)}\n" - f" git rm -- {shlex.quote(path)}\n{add}") + + (f" {STAGE_LINE}\n" if stage_first else "") + + f" git rm -- {shlex.quote(path)}\n{add}") def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], @@ -2345,7 +2636,15 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], again fails. A gitlink whose removal is staged prints none, as above. After a finding whose lines write `.gitmodules`, a value's or an earlier section's (`edited`), the `git rm` comes after `git add -- - .gitmodules`, which it needs (`_stage_first`). + .gitmodules`, which it needs (`_stage_first`). Each finding that + prints lines is recorded as a write (`Report.wrote`), so a staged + removal's finding after it puts no file back over them (lane 1's + SHOULD 1 on #889). + + What a section in another case says is `_variant_advice`'s, and the + rest of each remedy is `_section_out`'s, after `_gitlink_out_first`'s + `git rm` where it prints one, so this stays within S3776's limit + (rv882's SHOULD 3 on #882). """ holds = {keys["path"] for keys in sections.values() if "path" in keys} given = Counter(keys["path"] for keys in sections.values() @@ -2356,61 +2655,71 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], path = keys.get("path") if path is None or path in named: continue - url = f"url {keys['url']}" if "url" in keys else "no url" - said = (f'{GITMODULES} gives the path {path} to the submodule named ' - f'"{name}" ({url}), and no row of members: names that path, ' - "so nothing pins what it registers, and the next family.py " - "add of its name or its path meets it.") + said = _section_unnamed_said(name, keys) variant = _case_variant(path, named) - lead = "Take" if variant is not None: - row_path, row = variant - kind = _variant_case(path, row_path, row, holds) + kind = _variant_case(path, variant[0], variant[1], holds) if kind != STRAY: - report.finding( - "gitmodules-path-unnamed", - said + _variant_advice(kind, path, row_path, row, - "section")) + report.finding("gitmodules-path-unnamed", + said + _variant_advice(kind, path, *variant, + "section")) continue - said += _variant_said(row_path, row) + said += _variant_said(*variant) + _section_out( + name, keys, "Take", f"then commit {GITMODULES}") + elif (path in gitlinks and path not in removed and given[path] == 1 + and _in_members(path)): + report.finding( + "gitmodules-path-unnamed", + f"{said}{SECTION_ROW_BACK} Otherwise the git rm printed above " + f"for the gitlink at {path} takes this section out too, so no " + "command is printed here.") + continue else: - said += (" If its row was deleted or its path: edited by hand, " - "put the row back as git last had it.") - lead = "Otherwise take" - if (path in gitlinks and path not in removed and given[path] == 1 - and _in_members(path)): - report.finding( - "gitmodules-path-unnamed", - f"{said} Otherwise the git rm printed above for the " - f"gitlink at {path} takes this section out too, so no " - "command is printed here.") - continue - first = (_gitlink_out_first(root, name, path, gitlinks, removed, - taken, edited) - if variant is None else None) - then, line = f"then commit {GITMODULES}", "" - if first is not None: - first_said, line, then = first - said += first_said - lead = "Then, with the lines after it, take" - removal = "".join( - f"\n git config -f {GITMODULES} --unset-all " - f"{shlex.quote(f'submodule.{name}.{key}')}" for key in keys) - report.finding( - "gitmodules-path-unnamed", - f"{said} {lead} the section out by hand, in a POSIX shell, every " - "key it holds, so a submodule later given its name inherits none " - f"of them, {then}:{line}{removal}") - edited = True - - -def _gitlink_out_first(root: Path, name: str, path: str, + said += SECTION_ROW_BACK + _gitlink_out_first( + root, name, keys, gitlinks, removed, taken, edited) + report.finding("gitmodules-path-unnamed", said) + edited = report.wrote = True + + +#: What `gitmodules-path-unnamed` says of a section no row names, in no +#: case, before what it says to do. +SECTION_ROW_BACK = (" If its row was deleted or its path: edited by hand, put " + "the row back as git last had it.") + + +def _section_unnamed_said(name: str, keys: dict[str, str]) -> str: + """What `gitmodules-path-unnamed` says first of the section `name`, + which gives `keys["path"]` and no row names (#361).""" + url = f"url {keys['url']}" if "url" in keys else "no url" + return (f'{GITMODULES} gives the path {keys["path"]} to the submodule ' + f'named "{name}" ({url}), and no row of members: names that ' + "path, so nothing pins what it registers, and the next " + "family.py add of its name or its path meets it.") + + +def _section_out(name: str, keys: dict[str, str], lead: str, then: str, + line: str = "") -> str: + """The end of `gitmodules-path-unnamed`'s text, which takes the section + `name` out by key (#300), every key it holds (`keys`), after `lead`, + ending `then`, with `line`, a `git rm` printed first, before those + lines.""" + removal = "".join( + f"\n git config -f {GITMODULES} --unset-all " + f"{shlex.quote(f'submodule.{name}.{key}')}" for key in keys) + return (f" {lead} the section out by hand, in a POSIX shell, every key " + "it holds, so a submodule later given its name inherits none of " + f"them, {then}:{line}{removal}") + + +def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], gitlinks: dict[str, str], removed: frozenset[str], - taken: set[str], - edited: bool) -> tuple[str, str, str] | None: - """What `gitmodules-path-unnamed` adds for the section `name`, whose - `path` is a gitlink outside `members/` (#878), `(what it says, the - line it prints first, how it ends)`, or None where it adds nothing. + taken: set[str], edited: bool) -> str: + """The rest of `gitmodules-path-unnamed`'s text for the section `name`, + no row naming its path in any case, after what it says of the row: + where that path (`keys["path"]`) is a gitlink outside `members/` + (#878), the `git rm --cached` that takes the gitlink out first, then + the section's own lines (`_section_out`), and elsewhere those lines + alone, after "Otherwise". THE GITLINK GOES FIRST, out of the index alone, with `git rm --cached`, which touches no file and leaves the section, and the commit, as @@ -2432,15 +2741,17 @@ def _gitlink_out_first(root: Path, name: str, path: str, or where it holds some as it stands (`_stage_first`): git takes no gitlink out of the index then, and the `git rm` exited 128. - None for a path in `members/`, whose gitlink has a finding of its own - (`member-gitlink-unnamed`), for one that is no gitlink, and one + No `git rm` for a path in `members/`, whose gitlink has a finding of + its own (`member-gitlink-unnamed`), for one that is no gitlink, and one whose removal is staged (`removed`), where a `git rm` fails (`pathspec ... did not match`), and for one an earlier section's finding took out - (`taken`): the `git rm` typed again fails there too. A path it adds to - is added to `taken`.""" + (`taken`): the `git rm` typed again fails there too. A path it prints + one for is added to `taken`.""" + path = keys["path"] if (path not in gitlinks or path in removed or _in_members(path) or path in taken): - return None + return _section_out(name, keys, "Otherwise take", + f"then commit {GITMODULES}") taken.add(path) head = _forced(root, path, gitlinks[path]) in_head = _head_gitmodules(root) @@ -2455,9 +2766,11 @@ def _gitlink_out_first(root: Path, name: str, path: str, "(No url found for submodule path, exit 128) before it checks out " "any member: " + (STAGE_SAID if stage else "") + f"take it out of the index alone, which touches no file, with " - f"{nth}" + _forced_said(head, nth) + ".", - (f"\n {STAGE_LINE}" if stage else "") - + f"\n {_cached_removal(path, head)}", then) + f"{nth}" + + _forced_said(head, nth, _held_as(root, path)) + "." + + _section_out(name, keys, "Then, with the lines after it, take", + then, (f"\n {STAGE_LINE}" if stage else "") + + f"\n {_cached_removal(path, head)}")) def _section_lines(path: str, repository, @@ -2528,7 +2841,13 @@ def _check_gitlinks_registered(root: Path, members: list, `member-gitmodules-absent`'s lines, staged and committed, dropped every other section (rv820's review, comment 6237354577 on #839), and a second `member-gitmodules-url-absent`'s put the file back over the url - the first had written (#839). + the first had written (#839). Nor where the wrong-case move printed + before them put it back (`Report.put_back`, #879's C1), whose write the + url-absent finding's put-back undid; the one these print is recorded + there, for `member-gitlink-removal-staged` after them (#879's C2), and + so is each write, on disk or not (`Report.wrote`): that finding's + `git checkout HEAD -- .gitmodules` took a section these wrote out + again (lane 1's SHOULD 1 on #889). """ head = _head_gitmodules(root) if head is None: @@ -2536,26 +2855,27 @@ def _check_gitlinks_registered(root: Path, members: list, held, sections = head given = Counter(keys["path"] for keys in sections.values() if "path" in keys) - back = ("", "") if refused else _gitmodules_put_back(root)[1:] + spec, line, whose = (("", "", "") if refused or report.put_back + else _gitmodules_put_back(root)) + back = (line, whose) unregistered: set[str] = set() - for row in members: - project = row.get("project") if isinstance(row, dict) else None - if not isinstance(project, str) or not project: - continue - path = f"{DEFAULT_MEMBERS_DIR}/{project}" - if (str(row.get("path") or path) != path or path in unregistered - or path not in gitlinks): + for row, _, path in _rows_at_their_paths(members): + if path not in gitlinks: continue if path in given: if given[path] == 1 and _url_absent(root, path, gitlinks[path], row, sections, report, back): unregistered.add(path) + report.put_back = report.put_back or spec + report.wrote = True back = ("", "") continue unregistered.add(path) report.finding("member-gitmodules-absent", _absent_said( path, gitlinks[path], held, _section_lines(path, row.get("repository"), sections), back)) + report.put_back = report.put_back or spec + report.wrote = True back = ("", "") return unregistered @@ -2646,75 +2966,288 @@ def _check_removals_staged(root: Path, members: list, BOTH WAYS OUT ARE TYPED (rv762's probes, git 2.43): to stay, `git reset -q -- ` puts the gitlink back as HEAD records it; where a plain `git rm` also took its section out of the work tree's `.gitmodules` - (`worktree`), `git checkout HEAD -- .gitmodules` puts that back, and + (`worktree`), the lines `_section_back` gives put that back, and where it took the checkout, `git submodule update --init --recursive -- ` checks it out again, since that update dies `No url found` while the section is gone. To go, the same, then `family.py remove`, which takes the row, the gitlink and the section out in one commit and refuses a work tree's `.gitmodules` that is not the index's (`gitmodules-unstaged`) and a staged removal alike, on a branch, as the - duplicate-id remedy names it. + duplicate-id remedy names it. HEAD's `.gitmodules` is read once: the + rows whose removal is staged and whose path it gives, `wanted`, for + the first put-back of a file gone from disk (lane 1's MUST 1 on #889), + and every key it gives a value git dies on, `refused` + (`_values_refused`), which a section written back key by key leaves + out. The checkouts it names are `_moved_checkouts`'s. """ if not removed: return set() - head = _head_gitmodules(root) - in_head = ({keys["path"] for keys in head[1].values() if "path" in keys} - if head is not None else set()) - in_worktree = {keys["path"] for keys in worktree.values() - if "path" in keys} - staged: set[str] = set() + head = _head_listed(root) + listed = head[1] if head is not None else b"" + sections = _sections_listed(listed) + given = {keys["path"] for keys in sections.values() if "path" in keys} + rows = [(row, project, path) + for row, project, path in _rows_at_their_paths(members) + if path in removed] + wanted = {path for _, _, path in rows if path in given} + refused = set(_values_refused(root, _submodule_entries(listed))) + for row, project, path in rows: + lines = _put_back_lines(root, path, _section_back( + root, path, sections, worktree, report, (wanted, refused))) + report.finding("member-gitlink-removal-staged", _removal_staged_said( + root, row, project, path, gitlinks[path], lines, + _moved_checkouts(root, path, gitlinks, sections))) + return {path for _, _, path in rows} + + +def _rows_at_their_paths(members: list) -> Iterator[tuple[dict, str, str]]: + """`(row, project, path)` for each row of `members:` whose project is a + name and whose `path:` is its own, `members/`, or none, once + for each path: the rows `_check_gitlinks_registered` and + `_check_removals_staged` ask, which `_check_member` reads at that path. + A row whose `path:` is another is `member-path`'s (#611, #762).""" + seen: set[str] = set() for row in members: project = row.get("project") if isinstance(row, dict) else None if not isinstance(project, str) or not project: continue path = f"{DEFAULT_MEMBERS_DIR}/{project}" - if (str(row.get("path") or path) != path or path in staged - or path not in removed): - continue - staged.add(path) - lines = _put_back_lines(root, path, path in in_head - and path not in in_worktree) - report.finding("member-gitlink-removal-staged", _removal_staged_said( - root, row, project, path, gitlinks[path], lines)) - return staged + if str(row.get("path") or path) == path and path not in seen: + seen.add(path) + yield row, project, path + + +def _moved_checkouts(root: Path, path: str, gitlinks: dict[str, str], + sections: dict[str, dict[str, str]] + ) -> tuple[list[str], list[str]]: + """`(moved, clones)`, what `member-gitlink-removal-staged` names + (`_moved_said`) of the row's gitlink at `path`, where no checkout is + there, else two empty lists. + + `moved`: each other gitlink the index holds whose `.git` FILE names + the member's own store (`_store`, `_names_store`), the checkout `git + mv` moved, whose store the update the finding prints last points back + at `path` (#879's C4). + Any path, not a case variant alone: after `git mv members/

+ renamed-1`, typed as printed, `renamed-1` was left untracked, named + by no finding, with a file only it held unseen (lane 1's SHOULD 3 on + #889). + + `clones`: each case variant of `path` the index holds a gitlink at + whose `.git` is a DIRECTORY, a clone of its own, which holds its + commits there and nowhere else. It was said to be the checkout `git + mv` moved, to delete by hand, which lost a commit only it held (lane + 1's SHOULD 2 on #889).""" + if (root / path / ".git").exists(): + return [], [] + store = _store(root, path, sections) + others = [other for other in sorted(gitlinks) if other != path] + moved = [other for other in others + if _names_store(root / other / ".git", store)] + clones = [other for other in others + if other.casefold() == path.casefold() + and (root / other / ".git").is_dir()] + return moved, clones + + +def _store(root: Path, path: str, + sections: dict[str, dict[str, str]]) -> str: + """Where this holder keeps the store of the submodule at `path`, as git + does, `modules/` in its git directory, resolved and with its case + normalised for `_names_store`, or nothing where git cannot name that + directory: `` the section of HEAD's `.gitmodules` (`sections`) + that gives the path, the last, as git reads it, else the path, as `git + submodule add` names one (lane 1's SHOULD 3 on #889).""" + names = [name for name, keys in sections.items() + if keys.get("path") == path] + with _git_runs("this holder's git directory could not be read"): + try: + common = git_out(["rev-parse", "--git-common-dir"], cwd=root, + binary=True) + except Refusal: + return "" + common_dir = root / common.decode("utf-8", "surrogateescape").strip() + return os.path.normcase(os.path.realpath( + common_dir / "modules" / (names[-1] if names else path))) + + +def _names_store(gitfile: Path, store: str) -> bool: + """Whether `gitfile` is a `.git` FILE that names `store` (`_store`), read + as git reads one: `gitdir: `, trailing blanks stripped, the path + relative to the file's directory unless it is absolute.""" + if not store or not gitfile.is_file(): + return False + try: + text = gitfile.read_bytes().decode("utf-8", "surrogateescape") + except OSError: + return False + text = text.rstrip() + if not text.startswith("gitdir: "): + return False + named = os.path.join(gitfile.parent, text[len("gitdir: "):]) + return os.path.normcase(os.path.realpath(named)) == store + + +#: What `member-gitlink-removal-staged` says its `git checkout HEAD -- +#: .gitmodules` puts back (#762). +SECTION_TAKEN = (f"the {GITMODULES} section git rm took out, putting that " + "file back as HEAD has it and dropping any other change to " + "it") + + +def _section_back(root: Path, path: str, + sections: dict[str, dict[str, str]], + worktree: dict[str, dict[str, str]], + report: Report, + asked: tuple[set[str], set[tuple[str, str]]] + ) -> list[tuple[list[str], str]]: + """The lines `member-gitlink-removal-staged` prints for `.gitmodules`, + with what they put back, where HEAD's (`sections`) gives `path` (#762): + none where the work tree's (`worktree`) gives it, nor where the file a + finding before put back in place of one gone from disk does + (`Report.put_back`), and else `git checkout HEAD -- .gitmodules`, which + puts back the section a plain `git rm` took out. + + NOTHING IS PUT BACK OVER WHAT A FINDING PRINTED BEFORE WROTE, or where + it put the file back (`Report.wrote`, `Report.put_back`): the section + HEAD's gives is written in key by key, as HEAD has it, but for a key + it gives a value git dies on (`asked`'s second, which + `gitmodules-value-refused` takes out), and the file staged + (`_keys_back`). Where the work tree's file is gone, only a put-back + makes a file to write into: the first finding that writes one puts it + back and records that (#879's C1), so `Report.put_back` alone is asked + there. A hand mount's `git rm`, counted as a write, writes nothing + into a file that is gone, and the key-by-key lines then made a file of + one section, which the commit recorded (lane 1's MUST 2 on #889). The + `git checkout HEAD` took + out again a section a finding before had written, where the work + tree's file was gone (#879's C2, rv876's P5d) and where it was on disk + (lane 1's SHOULD 1 on #889); and written in alone, the section stayed + out of the index's file, which the commit records, so the commit + dropped it (lane 1's MUST 1, its P5). + + WHERE THE WORK TREE'S FILE IS GONE FROM DISK and no finding before put + it back, this one does, and records that: from where git reads it + (`_gitmodules_put_back`) where that file gives every path `asked`'s + first holds, each row whose removal is staged and whose path HEAD's + gives, so that + a deletion of `.gitmodules` staged beside the removal is undone too, + with nothing left to commit, and else from HEAD (`SECTION_TAKEN`). Put + back from the index's, which a plain `git rm` of a second row had + taken that row's section out of, the second row's finding wrote it in + key by key and the commit dropped it (lane 1's MUST 1, its P4).""" + given = {keys["path"] for keys in sections.values() if "path" in keys} + if path not in given: + return [] + on_disk = os.path.lexists(root / GITMODULES) + if on_disk and any(keys.get("path") == path for keys in worktree.values()): + return [] + if report.put_back and path in _blob_paths(root, report.put_back): + return [] + wanted, refused = asked + if report.put_back or (on_disk and report.wrote): + report.wrote = True + return _keys_back(path, sections, on_disk, refused) + if on_disk: + return [([f"git checkout HEAD -- {GITMODULES}"], SECTION_TAKEN)] + spec, line, whose = _gitmodules_put_back(root) + if wanted <= _blob_paths(root, spec): + report.put_back = spec + return [([line], f"the {GITMODULES} gone from the work tree, from " + f"{whose}")] + report.put_back = f"HEAD:{GITMODULES}" + return [([f"git checkout HEAD -- {GITMODULES}"], SECTION_TAKEN)] + + +def _keys_back(path: str, sections: dict[str, dict[str, str]], + on_disk: bool, refused: set[tuple[str, str]] + ) -> list[tuple[list[str], str]]: + """`_section_back`'s lines after a finding that wrote the work tree's + `.gitmodules` or put it back: the section of HEAD's (`sections`) that + gives `path`, the last, as git reads it, written in key by key as HEAD + has it, into the work tree's file as the lines above leave it + (`on_disk`) or the file a line above put back, but for a key HEAD's + gives a value git dies on (`refused`), which written back would be + `gitmodules-value-refused` again; then `git add -- .gitmodules` + (`STAGE_LINE`): the commit records the index's file, and a section + written into the work tree's alone stayed out of it (lane 1's MUST 1 + on #889).""" + name = [name for name, keys in sections.items() + if keys.get("path") == path][-1] + into = ("in the work tree's file, as the lines above leave it" if on_disk + else "in the file a line above put back") + return [([f"git config -f {GITMODULES} " + f"{shlex.quote(f'submodule.{name}.{key}')} " + f"{shlex.quote(value)}" + for key, value in sections[name].items() + if (name, key) not in refused], + f"the {GITMODULES} section git rm took out, key by key as HEAD " + f"has it, {into}"), + ([STAGE_LINE], f"that section in the index, staging {GITMODULES} " + f"whole, {STAGE_TOO}")] + + +def _blob_paths(root: Path, spec: str) -> set[str]: + """Every path a section of the `.gitmodules` git names `spec` gives + (`:.gitmodules`, the index's, or `HEAD:.gitmodules`), read as + `_gitmodules_sections` reads the work tree's; none where git cannot + read it (#879).""" + with _git_runs(f"{spec} could not be read as git reads it"): + try: + listed = git_out(["config", "-z", "--blob", spec, "--list"], + cwd=root, binary=True) + except Refusal: + return set() + return {keys["path"] for keys in _sections_listed(listed).values() + if "path" in keys} def _put_back_lines(root: Path, path: str, - section_taken: bool) -> list[tuple[str, str]]: + section: list[tuple[list[str], str]] + ) -> list[tuple[list[str], str]]: """The lines `member-gitlink-removal-staged` prints that keep the member - at `path`, each with what it puts back (#762): its gitlink; the section - a plain `git rm` took out of the work tree's `.gitmodules`, where - `section_taken` (HEAD's file gives the path and the work tree's does - not); and its checkout, where none is there.""" - lines = [(f"git reset -q -- {shlex.quote(path)}", "its gitlink")] - if section_taken: - lines.append((f"git checkout HEAD -- {GITMODULES}", - f"the {GITMODULES} section git rm took out, " - "putting that file back as HEAD has it and " - "dropping any other change to it")) + at `path`, each group with what it puts back (#762): its gitlink; + `section`, the lines for `.gitmodules` (`_section_back`, #879); and + its checkout, where none is there.""" + lines = [([f"git reset -q -- {shlex.quote(path)}"], "its gitlink"), + *section] if not (root / path / ".git").exists(): - lines.append(("git submodule update --init --recursive -- " - f"{shlex.quote(path)}", "its checkout")) + lines.append((["git submodule update --init --recursive -- " + f"{shlex.quote(path)}"], "its checkout")) return lines +#: What `member-gitlink-removal-staged` says after its lines where they +#: stage `.gitmodules` (`_keys_back`), in place of "and nothing is left to +#: commit": the file holds what the findings above write (lane 1's MUST 1 +#: on #889). +STAGED_BACK = (f": commit {GITMODULES} then, with what the lines above " + "write in it, and nothing else is left to commit.") + + def _removal_staged_said(root: Path, row: dict, project: str, path: str, - commit: str, lines: list[tuple[str, str]]) -> str: + commit: str, lines: list[tuple[list[str], str]], + found: tuple[list[str], list[str]]) -> str: """`member-gitlink-removal-staged`'s text for the row naming `project`, whose gitlink at `path` is at `commit` in HEAD, with `lines` - (`_put_back_lines`) below it (#762).""" + (`_put_back_lines`) below it (#762), and what it says of the checkouts + `_moved_checkouts` `found` (`_moved_said`, #879's C4). Where the lines + stage `.gitmodules`, it says to commit that (`STAGED_BACK`).""" member_id = str(row.get("id") or project) remove = (f"{PYTHON} scripts/family.py remove --family-root " f"{shlex.quote(str(root))} --member {shlex.quote(project)}") - commands = "\n ".join(line for line, _ in lines) + commands = "\n ".join(line for group, _ in lines for line in group) + staged = any(group == [STAGE_LINE] for group, _ in lines) return ( f"{path} is a gitlink at {commit} that a row of members: names and " "HEAD records, and the index holds nothing at that path: its removal " "is staged and not committed, so the next commit un-registers the " "member under its row, and this gate then refuses " "member-gitlink-absent. If the member is to stay, " - + _put_back_said([said for _, said in lines]) - + ", and nothing is left to commit. If it is to go, put it back the " + + _put_back_said(lines) + + (STAGED_BACK if staged else ", and nothing is left to commit.") + + _moved_said(path, *found) + + " If it is to go, put it back the " "same way, then take it out with family.py remove, from a checkout " "of openRepoShape, which takes its row, its gitlink and its section " "out in one commit, on a branch other than the holder's tracking " @@ -2723,15 +3256,95 @@ def _removal_staged_said(root: Path, row: dict, project: str, path: str, f"`{remove}`. {BRANCH_LEFT}\n {commands}") -def _put_back_said(put: list[str]) -> str: - """What the lines `member-gitlink-removal-staged` prints put back, one - to a line, in their order: the gitlink first (#762).""" - if len(put) == 1: - return f"the line below puts {put[0]} back as HEAD records it" - ordinals = ["the first", "the second", "the third"] +def _moved_said(path: str, moved: list[str], clones: list[str]) -> str: + """What `member-gitlink-removal-staged` says, before its last line, the + update, of each checkout of the member `git mv` moved off `path` + (`moved`) and each clone of its own at a case variant of it + (`clones`), or nothing (`_moved_checkouts`). + + THE CHECKOUT `git mv` MOVED (#879's C4): after the lines were typed as + printed it stayed there, untracked and named nowhere, so `family.py + add` refused one at a case variant (`family-members-dir-variant`), and + after the update `git -C status` read `path`, so a file only + it held went unseen. Said as `family.py add`'s closing sentences say + it (#828, #865), and of one outside `members/` too (lane 1's SHOULD 3 + on #889). + + A CLONE OF ITS OWN, whose `.git` is a directory, holds its commits + there and nowhere else: said as the wrong-case move says it + (`_wrong_case_move`), to move it out whole, not to delete it (lane 1's + SHOULD 2 on #889).""" + said = "" + if moved: + refused = (", and family.py add refuses it as " + "family-members-dir-variant" + if any(other.casefold() == path.casefold() + for other in moved) else "") + status = _statuses(moved) + said += ( + f" The checkout git mv moved to {' or '.join(moved)} stays " + f"there, untracked{refused}: its .git file names the member's " + "store under this holder's .git/modules/ by a path relative to " + "it, and its commits are kept there whatever becomes of the " + "checkout, so only what is not committed is in the checkout " + f"alone, which {status} shows. Delete it by hand once nothing " + "only it holds is needed, before the last line: that update " + f"points the store at {path}, after which {status} reads {path} " + "instead.") + if clones: + said += ( + f" The clone at {' or '.join(clones)}, whose .git is a " + "directory, stays there, untracked, and family.py add refuses it " + "as family-members-dir-variant: it holds its commits there and " + f"nowhere else, and {_unpushed(clones)} lists the ones not " + "pushed, so move it out of the holder whole, by hand, rather " + "than delete it.") + return said + + +def _statuses(paths: list[str]) -> str: + """`git -C status`, backticked, for each of `paths`, joined by + `or`: what shows what only a checkout holds (#681, #879).""" + return " or ".join(f"`git -C {shlex.quote(path)} status`" + for path in paths) + + +def _unpushed(paths: list[str]) -> str: + """`git -C log --oneline HEAD --branches --not --remotes`, + backticked, for each of `paths`, joined by `or`: what lists the + commits a clone of its own holds that no remote does (rv681's S2, + #879).""" + return " or ".join(f"`git -C {shlex.quote(path)} log --oneline HEAD " + "--branches --not --remotes`" for path in paths) + + +#: How `_put_back_said` counts the lines below a finding. +ORDINALS = ("first", "second", "third", "fourth", "fifth", "sixth", + "seventh", "eighth", "ninth", "tenth") + + +def _put_back_said(groups: list[tuple[list[str], str]]) -> str: + """What the lines `member-gitlink-removal-staged` prints put back, in + their order, the gitlink first (#762): each group of lines + (`_put_back_lines`) with what it puts back, `the second` for one line + and `the second to the fourth` for more (#879).""" + if len(groups) == 1 and len(groups[0][0]) == 1: + return f"the line below puts {groups[0][1]} back as HEAD records it" + said, at = [], 0 + for group, what in groups: + span = f"the {_ordinal(at)}" + if len(group) > 1: + span += f" to the {_ordinal(at + len(group) - 1)}" + said.append(f"{span} {what}") + at += len(group) return ("the lines below put the member back as HEAD has it: " - + ", ".join(f"{ordinals[at]} {said}" - for at, said in enumerate(put))) + + ", ".join(said)) + + +def _ordinal(at: int) -> str: + """`first` for the line at 0, and so on (`ORDINALS`); `th` past the + tenth (#879).""" + return ORDINALS[at] if at < len(ORDINALS) else f"{at + 1}th" def _init_stop_said(root: Path, sections: dict[str, dict[str, str]], diff --git a/tests/test_family_holder_remedies_compose.py b/tests/test_family_holder_remedies_compose.py new file mode 100644 index 00000000..5cf8899b --- /dev/null +++ b/tests/test_family_holder_remedies_compose.py @@ -0,0 +1,1525 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The family holder's pinned `scripts/validate-family.py` and +`scripts/bootstrap.py`: remedies that undid each other or refused when +typed as printed, a checkout `git mv` moved that nothing named, and `ok` +beside git's exit 128 (#879). + +THE ISSUE'S FIVE LINES, each reproduced on #876's head (a7f6c569) before +this change, and the states beside them found with it: + +- C1. Where the work tree's `.gitmodules` is gone, the wrong-case move's + `git config -f .gitmodules submodule..path ` made a file + of that one key, which its `git add` staged: committed, HEAD lost every + other section (rv876's P5e), and under `--no-members` a later + `member-gitmodules-url-absent` put that staged file back over its own + write (P5b). The move puts the file back first now, and no finding + after it does. So does `member-gitlink-absent`'s own section lines + (#681), whose one-section file was the same fault. +- C2. `member-gitlink-removal-staged` read "section taken" from the empty + work tree and printed `git checkout HEAD -- .gitmodules` after a finding + had put the file back and written a section there, which it took out + again (P5d). It reads the file git reads now, puts nothing back after an + earlier finding did, and where a plain `git rm` took its section out, + writes that section back key by key into the file put back. +- C3. After a `gitmodules-value-refused` finding's lines, a stray's `git + rm --cached` exited 128 (`please stage your changes to .gitmodules or + stash them to proceed`, P5g), and so did a hand mount's `git rm` and the + wrong-case move's `git rm -r --cached`. Each comes after `git add -- + .gitmodules` there now, as #878's do. +- C4. After the `git mv` state's lines were typed, the checkout `git mv` + moved stayed at `Members/

`, untracked and named nowhere, so `family.py + add` refused it, and after the update `git -C Members/

status` read + the row's path. `member-gitlink-removal-staged` names it now, with that + status, before its update. +- C5. With every member checked out, bootstrap said `family bootstrap ok` + where git's update had exited 128, its init stopped at a gitlink it has + no url for or at an unmerged `.gitmodules`. It fails now, naming the + stop and its cure, with no credential advice. + +Every remedy is typed as printed, in the order printed, split as a POSIX +shell splits it and run with no shell; then the gate is asked in every +mode, and a plain clone's `git submodule update --init --recursive` must +check out both members. A url a remedy writes is the one `family.py add` +writes, on GitHub, and the clone reaches the fixture's local remotes +through it by a `url..insteadOf` for the one command (`cloning`). +THE CONTROLS: the same states with the work tree's `.gitmodules` on disk, +or with no value finding before the `git rm`, print what they printed. + +RV882'S ITEMS ON #882, TAKEN HERE (lane 2's review of #878): both of +`_stage_first`'s branches that print no `git add -- .gitmodules`, where +the work tree holds no file and where the index holds none (SHOULD 1); +`gitlink-unnamed` reading the paths from the file git reads where the +work tree's is gone (SHOULD 2); and `-f` over what is on disk at a staged +gitlink and is no checkout, a directory holding a file or a file, and +none over an empty directory, which git takes the plain line over (NIT +2). NIT 3's clause is in `STAGE_SAID`, which the C3 rows pin. + +LANE 1'S REVIEW OF #889, ROUND 1, TAKEN HERE: a staged removal's section +written back key by key is staged too, and the first removal puts back +HEAD's file where the index's lacks a section a second removal needs +(MUST 1, its P4 and P5); no removal puts HEAD's file back over an earlier +finding's write with the file on disk either (SHOULD 1); a clone of its +own at a case variant is said to be one (SHOULD 2); a checkout `git mv` +moved out of `members/` is named (SHOULD 3); a symbolic link to an empty +directory takes `-f` (NIT 2); the move says what is on disk at each +variant (NIT 3); a run with no row says nothing was updated (NIT 5); and +the value finding's put-back serves the removal after it (NIT 6). + +LANE 1'S REVIEW OF #889, ROUND 2, TAKEN HERE: a hand mount's `git rm` +writes nothing into a `.gitmodules` that is gone, so a removal after it +puts the file back itself rather than write a section into no file (MUST +2); a clone at no case variant of the row's path is not said to be one +`family.py add` refuses (the NIT, lane 1's mutant MY3); and a hand mount's +`git rm` says it refuses a gitlink staged at a commit HEAD does not record +there (the SHOULD). + +THE SAME FAMILY, OFFLINE RULES AND HELPERS as +`tests/test_family_holder_bootstrap_texts.py` (#839) and +`tests/test_family_unnamed_gitlink.py` (#878). A case variant is an index +record (`recorded_at`, `stage_gitlink`), but for the `git mv` states, which +need `Members/IRRS` apart from `members/IRRS` on disk and are skipped where +the disk folds case (`case_sensitive_disk`). +""" + +from __future__ import annotations + +import re +import shlex +import shutil +from pathlib import Path + +import pytest + +from conftest import rmtree, run_script +from test_family import FAMILY, SPARE, family, validate # noqa: F401 +from test_family_add_refusals import holder, offline # noqa: F401 +from test_family_bootstrap_gate_order import ( + OUTSIDE_CURE, STOPPED, UNMERGED, UNMERGED_CURE, UNNAMED_CURE, bootstrap, + fetching, hand_mounted, stray, type_line, with_config, without_url, +) +from test_family_dry_run import isolated +from test_family_head_lost_gitmodules import ( # noqa: F401 + absent, assert_passes_in_every_mode, case_sensitive_disk, commit_all, + committed_gitmodules, github_to, pinned, recorded_at, + staged_and_committed, typed, variant_refused, +) +from test_family_holder_bootstrap_texts import ( + WORK, after_fetch_line, put_back_first, section_taken_out, + unmerged_gitmodules, +) +from test_family_member_path import MEMBER, OTHER, raw +from test_family_remove_gitmodules import verb_args +from test_family_unnamed_gitlink import ( + COMMIT_THAT, FORCED_NO_HEAD, RENAMED, STAGE_LINE, STAGE_SAID, TAKE_OUT, + TOOLS, assert_a_plain_clone_checks_out_every_member, at_tools, + found_in_every_mode, outside, section_taken_out_alone, staged_at, + submodule_added, +) +from test_validate_family_typed_states import ( + GONE, ROW, SECTION_GIVEN_BACK, URL_ABSENT, VARIANT_PATH, gone, + unnamed_as_moved, +) +from test_validate_family_unnamed_mounts import ( + GITLINK_SAID, GITLINK_SAID_FETCHED, VARIANT, codes, commands, findings, + spare_commits, stage_gitlink, +) + +MODES = ((), ("--pins",), ("--no-members",)) +GITMODULES = ".gitmodules" + +#: What a remedy that writes `.gitmodules` says first where the work +#: tree's is gone, `{whose}` as `GONE` names it (C1). +PUT_BACK_THEN = (".gitmodules, gone from the work tree, back from {whose}, " + "so that every other section stays") + + +def cloning(offline, tmp_path: Path, family) -> dict: + """The offline environment under which a plain clone checks the members + out from the fixture's local remotes, through the GitHub url a remedy + writes as `family.py add` writes one (`github_to`).""" + key, value = github_to(family["remotes"])[1].split("=", 1) + return with_config(fetching(isolated(offline, tmp_path)), key, value) + + +def typed_in_order(root: Path, env: dict, result) -> None: + """Every `git` command every finding of `result` prints, typed as + printed, in the order printed: not `family.py add`, which mounts a hand + mount again only where the person wants it.""" + for finding in findings(result): + for line in commands(finding): + if line.startswith("git "): + type_line(root, env, line) + + +def refusal_lines(result) -> list[str]: + """The command lines a refusal prints, as printed.""" + return [line.strip() for line in result.stderr.splitlines()[2:]] + + +def found_alike(root: Path, *modes: tuple[str, ...]) -> list[str]: + """The gate's findings, exit 1, the same in each of `modes`.""" + seen = [] + for extra in modes: + result = validate(root, *extra) + assert result.returncode == 1, (extra, result.stdout + result.stderr) + seen.append(findings(result)) + assert all(found == seen[0] for found in seen), seen + return seen[0] + + +def ends_well(root: Path, env: dict, family, tmp_path: Path) -> None: + """The gate passes in every mode, and a plain clone checks out both + members (#878's helper).""" + assert_passes_in_every_mode(root, 2) + assert_a_plain_clone_checks_out_every_member(root, env, family, tmp_path) + + +# --- C1: the wrong-case move where the work tree's .gitmodules is gone ------- + +def moved_in_head(root: Path, env: dict) -> str: + """MEMBER's gitlink recorded only at `Members/IRRS`, and the section of + HEAD's `.gitmodules` giving that path, as a committed `git mv` leaves + them (rv876's P5e state), by index records. Returns the commit.""" + commit = recorded_at(root, env, MEMBER, VARIANT_PATH) + data = raw(root, env, "show", f"HEAD:{GITMODULES}").stdout + line = f"\tpath = {ROW}\n".encode() + assert data.count(line) == 1, data + committed_gitmodules(root, env, data.replace( + line, f"\tpath = {VARIANT_PATH}\n".encode())) + return commit + + +def moved_refused(commit: str, how: str | None) -> list[str]: + """The members-mode refusal for `moved_in_head`'s state, with the line + that puts `.gitmodules` back first where the work tree's is gone as + `how` says (`GONE`), or none.""" + section = (f"git config -f .gitmodules submodule.{ROW}.path {ROW}", + "git add -- .gitmodules") + said = SECTION_GIVEN_BACK + if how is not None: + whose, line = GONE[how] + section = (line, *section) + said = f"put {PUT_BACK_THEN.format(whose=whose)}, then {said}" + return variant_refused(ROW, commit, [VARIANT_PATH], + {VARIANT_PATH: commit}, section=section, + section_said=said) + + +@pytest.mark.parametrize("how", GONE) +def test_c1_the_move_puts_the_file_back_before_its_section_line( + family, holder, offline, tmp_path, how): + """C1, rv876's P5e. Members mode's refusal is printed alone; its `git + config -f` line made a file of one key, which committed left HEAD with + no other section. It puts the file back first now, from where git + reads it. Typed as printed and committed, HEAD keeps OTHER's section, + the gate passes in every mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + commit = moved_in_head(holder, env) + gone(holder, env, how) + + refused = validate(holder) + + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refused.stderr.splitlines() == moved_refused(commit, how) + for line in refusal_lines(refused): + type_line(holder, env, line) + commit_all(holder, env, "Move the gitlink as printed") + assert f'[submodule "members/{OTHER}"]'.encode() in raw( + holder, env, "show", f"HEAD:{GITMODULES}").stdout + ends_well(holder, env, family, tmp_path) + + +@pytest.mark.parametrize("how", GONE) +def test_c1_one_put_back_serves_the_move_and_a_url_finding_after_it( + family, holder, offline, tmp_path, how): + """C1, rv876's P5b: the same state with OTHER's url gone too. Under + `--no-members` the move prints first, and `member-gitmodules-url-absent` + after it put back the file the move had just staged, one key in it. + The move puts the file back first now, and the url finding puts nothing + back. Typed in order and committed, the gate passes in every mode and a + plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + commit = moved_in_head(holder, env) + data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout + committed_gitmodules(holder, env, without_url(data, OTHER)) + gone(holder, env, how) + + result = validate(holder, "--no-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert findings(result) == [ + unnamed_as_moved(VARIANT_PATH, commit, moved_refused(commit, how), + said=GITLINK_SAID_FETCHED), + URL_ABSENT.format(commit=pinned(holder, OTHER))] + typed_in_order(holder, env, result) + staged_and_committed(holder, env, "Typed in order") + ends_well(holder, env, family, tmp_path) + + +def test_c1_control_with_the_file_on_disk_the_move_is_as_it_was( + family, holder, offline, tmp_path): + """No misfire: the same state with the work tree's `.gitmodules` on + disk. The move prints no put-back, as before; typed and committed, the + gate passes in every mode.""" + env = cloning(offline, tmp_path, family) + commit = moved_in_head(holder, env) + + refused = validate(holder) + + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refused.stderr.splitlines() == moved_refused(commit, None) + for line in refusal_lines(refused): + type_line(holder, env, line) + commit_all(holder, env, "Move the gitlink as printed") + ends_well(holder, env, family, tmp_path) + + +@pytest.mark.parametrize("how", GONE) +def test_c1_the_absent_gitlink_s_section_lines_put_the_file_back_first( + family, holder, offline, tmp_path, how): + """C1's sibling, found with it: `member-gitlink-absent`'s own remedy + (#681) for a member taken out by a plain `git rm`, committed, with the + work tree's `.gitmodules` then gone. Its section lines made a file of + MEMBER's section alone, so committed, HEAD lost OTHER's. The file is + put back first now. Typed as printed, committed, and the member checked + out as the text says, the gate passes in every mode.""" + env = cloning(offline, tmp_path, family) + commit = pinned(holder, MEMBER) + raw(holder, env, "rm", "-q", "--", ROW) + commit_all(holder, env, "MEMBER taken out by hand") + gone(holder, env, how) + whose, line = GONE[how] + + refused = validate(holder) + + assert refused.returncode == 2, refused.stdout + refused.stderr + assert (f"with the lines after it, the first of which puts " + f"{PUT_BACK_THEN.format(whose=whose)}, then stage .gitmodules. " + in refused.stderr), refused.stderr + assert refusal_lines(refused) == [ + f"git update-index --add --cacheinfo 160000,{commit},{ROW}", line, + f"git config -f .gitmodules submodule.{ROW}.path {ROW}", + f"git config -f .gitmodules submodule.{ROW}.url " + f"https://github.com/InkRouter/{MEMBER}.git"] + for line in refusal_lines(refused): + type_line(holder, env, line) + staged_and_committed(holder, env, "Put the member back as printed") + raw(holder, env, "submodule", "update", "--init", "--recursive") + ends_well(holder, env, family, tmp_path) + + +# --- C2: the removal staged, beside a finding that put the file back -------- + +@pytest.mark.parametrize("how", GONE) +def test_c2_the_removal_s_line_puts_nothing_back_over_an_earlier_write( + family, holder, offline, tmp_path, how): + """C2, rv876's P5d: `git rm --cached members/IRRS`, OTHER's section out + of HEAD's `.gitmodules`, and the work tree's file gone. The absent + finding puts the file back and writes OTHER's section; the removal's + `git checkout HEAD -- .gitmodules`, read from the empty work tree, then + took it out, so the commit had nothing to commit. It reads the file git + reads now, which gives MEMBER's section, and prints no put-back. Typed + in order and committed, the gate passes and a plain clone checks both + out.""" + env = cloning(offline, tmp_path, family) + data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout + committed_gitmodules(holder, env, section_taken_out(data, OTHER)) + raw(holder, env, "rm", "-q", "--cached", "--", ROW) + gone(holder, env, how) + + found = found_alike(holder, *MODES) + + assert [finding.split(":", 1)[0] for finding in found] == [ + "FINDING member-gitmodules-absent", + "FINDING member-gitlink-removal-staged"] + assert found[0] == put_back_first( + absent(OTHER, pinned(holder, OTHER), held=True), how) + assert commands(found[1]) == [f"git reset -q -- {ROW}"] + typed_in_order(holder, env, validate(holder, "--no-members")) + staged_and_committed(holder, env, "Typed in order") + ends_well(holder, env, family, tmp_path) + + +def url_of(root: Path, env: dict, path: str) -> str: + """The url the section of HEAD's `.gitmodules` named `path` gives.""" + return raw(root, env, "config", "--blob", f"HEAD:{GITMODULES}", + f"submodule.{path}.url").stdout.decode("utf-8").strip() + + +#: What `member-gitlink-removal-staged` says its stage line does, and what +#: it says after its lines where they stage `.gitmodules` (lane 1's MUST 1 +#: on #889). +STAGED_WHOLE = ("that section in the index, staging .gitmodules whole, which " + "stages every other change in it too") +STAGED_BACK = (": commit .gitmodules then, with what the lines above write " + "in it, and nothing else is left to commit.") + + +def written_back(root: Path, env: dict, path: str) -> list[str]: + """The removal's lines that write the section of HEAD's `.gitmodules` + named `path` back, key by key as HEAD has it, then stage the file (lane + 1's MUST 1 on #889). Read before the state is made.""" + return [f"git config -f .gitmodules submodule.{path}.path {path}", + f"git config -f .gitmodules submodule.{path}.url " + f"{shlex.quote(url_of(root, env, path))}", + "git add -- .gitmodules"] + + +def test_c2_a_section_git_rm_took_is_written_back_into_the_file_put_back( + family, holder, offline, tmp_path): + """C2's other half, found with it: a plain `git rm members/IRRS`, which + takes its section out of the work tree's `.gitmodules` and stages that, + beside OTHER's section out of HEAD's, and the work tree's file then + deleted. The absent finding puts back the index's, which lacks MEMBER's + section, and the removal's `git checkout HEAD -- .gitmodules` took + OTHER's out again. Now the removal writes MEMBER's section back, key by + key as HEAD has it, into the file put back, and stages the file (lane + 1's MUST 1 on #889), before the update, which dies `No url found` + without it. Typed in order and committed, the gate passes and a plain + clone checks both out.""" + env = cloning(offline, tmp_path, family) + data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout + committed_gitmodules(holder, env, section_taken_out(data, OTHER)) + lines = written_back(holder, env, ROW) + raw(holder, env, "rm", "-q", "--", ROW) + gone(holder, env, "deleted") + + result = validate(holder, "--no-members") + + assert codes(result) == ["member-gitmodules-absent", + "member-gitlink-removal-staged"] + removal = findings(result)[1] + assert commands(removal) == [ + f"git reset -q -- {ROW}", *lines, + f"git submodule update --init --recursive -- {ROW}"] + assert ("the first its gitlink, the second to the third the .gitmodules " + "section git rm took out, key by key as HEAD has it, in the file " + f"a line above put back, the fourth {STAGED_WHOLE}, the fifth its " + "checkout" + STAGED_BACK) in removal, removal + typed_in_order(holder, env, result) + staged_and_committed(holder, env, "Typed in order") + ends_well(holder, env, family, tmp_path) + + +#: `(how the removal and the file's loss are made, the line the removal +#: prints for .gitmodules)`: the removal's own finding alone, where it is +#: the one that puts the file back. +ALONE_GONE = { + "plain-rm-file-deleted": ( + lambda root, env: (raw(root, env, "rm", "-q", "--", ROW), + (root / GITMODULES).unlink()), + "git checkout HEAD -- .gitmodules"), + "cached-rm-deletion-staged": ( + lambda root, env: (raw(root, env, "rm", "-q", "--cached", "--", ROW), + raw(root, env, "rm", "-q", "--", GITMODULES)), + "git checkout HEAD -- .gitmodules"), + "cached-rm-file-deleted": ( + lambda root, env: (raw(root, env, "rm", "-q", "--cached", "--", ROW), + (root / GITMODULES).unlink()), + "git checkout -- .gitmodules"), +} + + +@pytest.mark.parametrize("make, line", ALONE_GONE.values(), ids=ALONE_GONE) +def test_c2_a_removal_alone_puts_the_file_back_as_git_reads_it( + family, holder, offline, tmp_path, make, line): + """The removal's own finding, with no other before it, where the work + tree's `.gitmodules` is gone: it puts the file back, from HEAD where a + plain `git rm` took MEMBER's section out of the index's, else from + where git reads it, so a deletion of `.gitmodules` staged beside the + removal is undone too. Typed as printed, nothing is left to commit, and + the gate passes in every mode.""" + env = cloning(offline, tmp_path, family) + make(holder, env) + + (removal,) = found_alike(holder, *MODES) + + assert line in commands(removal) + typed_in_order(holder, env, validate(holder, "--no-members")) + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +def test_c2_control_with_the_file_on_disk_the_removal_is_as_it_was( + family, holder, offline, tmp_path): + """No misfire: a plain `git rm members/IRRS` with the work tree's + `.gitmodules` on disk prints what it printed: the reset, `git checkout + HEAD -- .gitmodules` for the section `git rm` took out, and the + update.""" + env = cloning(offline, tmp_path, family) + raw(holder, env, "rm", "-q", "--", ROW) + + (removal,) = found_alike(holder, *MODES) + + assert commands(removal) == [ + f"git reset -q -- {ROW}", "git checkout HEAD -- .gitmodules", + f"git submodule update --init --recursive -- {ROW}"] + assert ("the first its gitlink, the second the .gitmodules section git " + "rm took out, putting that file back as HEAD has it and dropping " + "any other change to it, the third its checkout") in removal + + +# --- C3: a git rm of a gitlink after a value finding's lines ---------------- + +def valued(data: bytes, path: str) -> bytes: + """`.gitmodules` bytes with the section giving `path` given `update = + None` too, a value git dies on (#768).""" + line = f"\tpath = {path}\n".encode() + assert data.count(line) == 1, data + return data.replace(line, line + b"\tupdate = None\n") + + +def value_committed(root: Path, env: dict, path: str = ROW) -> None: + """The section giving `path`, MEMBER's unless said, given `update = + None`, committed (#768).""" + committed_gitmodules(root, env, valued( + raw(root, env, "show", f"HEAD:{GITMODULES}").stdout, path)) + + +def staged_first(text: str) -> str: + """`text` after #878's sentence that stages `.gitmodules` first.""" + said = STAGE_SAID + text + return said[0].upper() + said[1:] + + +@pytest.mark.parametrize("value", [True, False], ids=["value", "control"]) +def test_c3_the_stray_s_git_rm_stages_the_file_first_after_a_value( + family, holder, offline, tmp_path, value): + """C3, rv876's P5g: a committed `update = None`, and a stray gitlink at + `Members/IRRS` staged with nothing on disk. The value finding's + `--unset-all` leaves `.gitmodules` with changes not staged, and the + stray's `git rm --cached` then exited 128. It comes after `git add -- + .gitmodules` now, in every mode, as #878's do. With no value finding + before it, the control, it prints none. Typed in order, and the value's + fix committed, nothing is left, the gate passes and a plain clone + checks both out.""" + env = cloning(offline, tmp_path, family) + if value: + value_committed(holder, env) + commit = pinned(holder, MEMBER) + stage_gitlink(holder, VARIANT_PATH, commit) + take = (staged_first(TAKE_OUT) + COMMIT_THAT + ":" + STAGE_LINE if value + else f"Take{TAKE_OUT[4:]}{COMMIT_THAT}:") + + found = found_alike(holder, *MODES) + + assert found[-1] == ( + GITLINK_SAID.format(path=VARIANT_PATH, commit=commit) + + VARIANT.format(row_path=ROW, project=MEMBER) + f" {take}" + + f"\n git rm --cached -- {VARIANT_PATH}") + typed_in_order(holder, env, validate(holder, "--no-members")) + if value: + staged_and_committed(holder, env, "Typed in order") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +def test_c3_a_hand_mount_s_git_rm_stages_the_file_first_after_a_value( + family, holder, offline, tmp_path): + """C3's sibling, found with it: a hand mount's plain `git rm`, after + the value finding's lines, exited 128 too. `git add -- .gitmodules` + comes before it now, the second of the four, and the text counts the + others on. Typed in order to the removal, and committed, the gate + passes and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + value_committed(holder, env) + path = hand_mounted(holder, env, family) + + result = validate(holder, "--no-members") + + assert codes(result) == ["gitmodules-value-refused", + "member-gitlink-unnamed"] + mount = findings(result)[1] + switch, stage, removal, add = commands(mount) + assert (stage, removal) == ("git add -- .gitmodules", f"git rm -- {path}") + assert (", stage .gitmodules with the second, which stages every other " + "change in it too, since git rm refuses to take any gitlink out " + "of the index") in mount + assert ("take it out with the third, which refuses while its checkout " + "holds changes") in mount + assert "then mount it with the fourth, " in mount + assert add.startswith("python3 scripts/family.py add "), add + for line in commands(findings(result)[0]) + [switch, stage, removal]: + type_line(holder, env, line) + staged_and_committed(holder, env, "Typed in order") + ends_well(holder, env, family, tmp_path) + + +def test_c3_the_wrong_case_move_s_git_rm_stages_the_file_first_after_a_value( + family, holder, offline, tmp_path): + """C3's sibling, found with it: the wrong-case move under + `--no-members`, after the value finding's lines: its `git rm -r + --cached` exited 128, and its `update-index` then left the gitlink at + both paths. `git add -- .gitmodules` comes first now. Typed in order + and committed, the gate passes and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + value_committed(holder, env) + commit = recorded_at(holder, env, MEMBER, VARIANT_PATH) + + result = validate(holder, "--no-members") + + assert codes(result) == ["gitmodules-value-refused", + "member-gitlink-unnamed"] + move = findings(result)[1] + assert commands(move) == [ + "git add -- .gitmodules", + f"git rm -r --cached --ignore-unmatch -- {VARIANT_PATH}", + f"git update-index --add --cacheinfo 160000,{commit},{ROW}"] + assert (" The member is recorded in the wrong case. " + + staged_first("move its gitlink to the row's path, at the " + "commit the row pins, in the index alone, which " + "touches no file, with the next two lines, and " + "commit that.")) in move + typed_in_order(holder, env, result) + staged_and_committed(holder, env, "Typed in order") + ends_well(holder, env, family, tmp_path) + + +# --- C4: the checkout git mv moved ------------------------------------------ + +#: What `member-gitlink-removal-staged` says of the checkout `git mv` moved +#: to `Members/IRRS`, before its update (C4). +MOVED = ( + " The checkout git mv moved to {variant} stays there, untracked{refused}: " + "its .git file " + "names the member's store under this holder's .git/modules/ by a path " + "relative to it, and its commits are kept there whatever becomes of the " + "checkout, so only what is not committed is in the checkout alone, which " + "`git -C {variant} status` shows. Delete it by hand once nothing only it " + "holds is needed, before the last line: that update points the store at " + "{path}, after which `git -C {variant} status` reads {path} instead.") + +#: What it says of one at a case variant: `family.py add` refuses it. +REFUSED_AS_VARIANT = (", and family.py add refuses it as " + "family-members-dir-variant") + +#: The `git mv` states: nothing more, and #876's two. +MOVES = {"clean": lambda checkout, env: None, **WORK} + + +@pytest.mark.parametrize("work", MOVES.values(), ids=MOVES) +def test_c4_the_checkout_git_mv_moved_is_named_before_the_update( + family, holder, offline, tmp_path, case_sensitive_disk, work): + """C4, in the three `git mv` states rv876 measured: `git mv + members/IRRS Members/IRRS`, staged, clean, with a commit, or with an + untracked file in the checkout it moved. After the lines were typed, + `Members/IRRS` stayed, untracked and named nowhere, `family.py add` + refused it, and the update made its status read `members/IRRS`. The + removal's finding names it now, in every mode, with `git -C + Members/IRRS status` before the update. Typed in order, with that + status read and the checkout deleted by hand before the last line as + the text says, nothing is left untracked, the gate passes, `family.py + add` takes the spare member, the commit is in the member's store, and a + plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + (holder / "Members").mkdir() + raw(holder, env, "mv", ROW, VARIANT_PATH) + work(holder / VARIANT_PATH, env) + made = raw(holder / VARIANT_PATH, env, "rev-parse", "HEAD").stdout + + found = found_alike(holder, *MODES) + + assert ("and nothing is left to commit." + + MOVED.format(variant=VARIANT_PATH, path=ROW, + refused=REFUSED_AS_VARIANT) + + " If it is to go,") in found[-1], found[-1] + lines = [line for finding in found for line in commands(finding)] + assert lines[-1] == f"git submodule update --init --recursive -- {ROW}" + for line in lines[:-1]: + type_line(holder, env, line) + status = raw(holder, env, "-C", VARIANT_PATH, "status", "--porcelain") + assert (b"?? scratch.txt\n" in status.stdout) is (work is WORK.get( + "untracked")), status.stdout + rmtree(holder / VARIANT_PATH) + type_line(holder, env, lines[-1]) + assert raw(holder, env, "status", "--porcelain").stdout == b"" + store = holder / ".git" / "modules" / ROW + assert raw(holder, env, f"--git-dir={store}", "cat-file", "-e", + made.decode("ascii").strip(), check=False).returncode == 0 + added = run_script(FAMILY, *verb_args("add", holder, family), + "--dry-run", env=env) + assert added.returncode == 0, added.stdout + added.stderr + ends_well(holder, env, family, tmp_path) + + +# --- C5: bootstrap with every member checked out ---------------------------- + +#: What (a) says where every row's member is checked out and git updated +#: none of them, and how the run then ends (C5). +NONE_UPDATED = (" NO MEMBER UPDATED, and not for want of a credential: " + "{why}. {cure}") +STOP_FAILED = "family bootstrap FAILED: {path} (git stopped at it)" + + +def outside_gitlink(root: Path, env: dict, family) -> str: + """SPARE's gitlink recorded by hand at `vendor/IRQS`, outside + `members/`, with no section, and committed. Returns its path.""" + stage_gitlink(root, f"vendor/{SPARE}", spare_commits(family)[1]) + commit_all(root, env, "A gitlink mounted by hand outside members/") + return f"vendor/{SPARE}" + + +#: `(how the stop is made in the holder, every member checked out, what +#: the line says cures it)`: each a gitlink git's init has no url for. +STOPS = { + "outside": (outside_gitlink, OUTSIDE_CURE), + "stray": (stray, UNNAMED_CURE), + "hand-mount": (hand_mounted, UNNAMED_CURE), +} + + +@pytest.mark.parametrize("make, cure", STOPS.values(), ids=STOPS) +def test_c5_a_stop_with_every_member_checked_out_fails_naming_it( + family, holder, offline, tmp_path, make, cure): + """C5: every member checked out, as in the holder itself, and a gitlink + git's init has no url for, at which `git submodule update --init` + exited 128 before it updated any member. Bootstrap gave the credential + advice and said ok. It names the stop now, with its cure, gives no + advice, and fails on the gitlink. The gate's remedy for that gitlink + typed as printed and committed, the run ends ok and a plain clone + checks both out.""" + env = cloning(offline, tmp_path, family) + path = make(holder, env, family) + + result = bootstrap(holder, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert after_fetch_line(result) == [ + NONE_UPDATED.format(why=STOPPED.format(path=path), + cure=cure.format(path=path)), + STOP_FAILED.format(path=path)] + typed_in_order(holder, env, validate(holder, "--no-members")) + commit_all(holder, env, "The gate's remedy, typed as printed") + booted = bootstrap(holder, env, "--skip-members") + assert booted.stdout.endswith("\nfamily bootstrap ok\n"), ( + booted.stdout + booted.stderr) + ends_well(holder, env, family, tmp_path) + + +def test_c5_a_bad_line_after_the_first_section_fails_naming_a_stop( + family, holder, offline, tmp_path): + """C5, the issue's own state (RV877's O1): every member checked out, a + line git cannot parse committed in HEAD's `.gitmodules` after the first + section, and the work tree's file deleted. Git reads the index's up to + that line and dies `No url found` at the second member. Bootstrap said + ok with the advice. It fails now on the gitlink it stopped at, with no + advice: which one is the blob's reading, #877's (#856), so either + member's path is taken here.""" + env = cloning(offline, tmp_path, family) + data = (holder / GITMODULES).read_bytes() + first = data.index(b"[submodule", 1) + bad = (chr(9) + 'branch = "unterminated' + chr(10)).encode("ascii") + committed_gitmodules(holder, env, data[:first] + bad + data[first:]) + (holder / GITMODULES).unlink() + + result = bootstrap(holder, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + said = after_fetch_line(result) + assert said[0].startswith( + " NO MEMBER UPDATED, and not for want of a credential: git stopped " + "at members/"), said + assert re.fullmatch(rf"family bootstrap FAILED: members/({MEMBER}|" + rf"{OTHER}) \(git stopped at it\)", said[1]), said + + +def test_c5_an_unmerged_file_with_every_member_checked_out_fails( + family, holder, offline, tmp_path): + """C5's unmerged case: every member checked out, and `.gitmodules` held + only at a merge's conflict stages, which git reads nothing from, so its + init died at the first gitlink. Bootstrap said ok. It says why and the + merge's cure now, and fails on `.gitmodules`; resolved and added as the + cure says, the run ends ok.""" + env = cloning(offline, tmp_path, family) + unmerged_gitmodules(holder, env) + + result = bootstrap(holder, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert after_fetch_line(result) == [ + NONE_UPDATED.format(why=UNMERGED, cure=UNMERGED_CURE), + "family bootstrap FAILED: .gitmodules (git cannot read it)"] + raw(holder, env, "add", GITMODULES) + booted = bootstrap(holder, env, "--skip-members") + assert booted.stdout.endswith("\nfamily bootstrap ok\n"), ( + booted.stdout + booted.stderr) + + +def test_c5_control_the_holder_as_made_bootstraps_ok(family, holder, offline, + tmp_path): + """No misfire: the holder as `family.py` made it, every member checked + out and nothing that stops git's init. Asking `init_stop` with no row + left finds nothing, and the run ends ok, saying every member and leg + was fetched, with nothing on stderr.""" + env = cloning(offline, tmp_path, family) + + result = bootstrap(holder, env, "--skip-members") + + assert result.returncode == 0, result.stdout + result.stderr + assert " every member and leg fetched" in result.stdout.splitlines() + assert result.stderr == "" + + +# --- rv882's items on #882, taken here --------------------------------------- + +#: What the `-f` sentence says is on disk at a gitlink with no `.git` entry +#: there (rv882's NIT 2), in place of "a checkout". +OVER_FILES = "what is on disk there, which is no checkout" + + +def head_paths(root: Path, env: dict) -> set[str]: + """Every path a section of HEAD's `.gitmodules` gives.""" + listed = raw(root, env, "config", "--blob", f"HEAD:{GITMODULES}", + "--get-regexp", r"\.path$").stdout.decode("utf-8") + return {line.split(" ", 1)[1] for line in listed.splitlines()} + + +def test_rv882_should_1_no_git_add_where_the_work_tree_holds_no_file( + family, holder, offline, tmp_path): + """rv882's SHOULD 1 on #882: (D), the gitlink at tools/x that no + section gives, with the work tree's `.gitmodules` deleted and not + staged. No `git add -- .gitmodules` is printed, in any mode: it would + stage that deletion, the commit after it deleted the file from HEAD, + and a plain clone stopped at members/IRRS. Typed as printed and + committed, HEAD's `.gitmodules` still gives both members, the gate + passes in every mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + section_taken_out_alone(holder, env, family) + gone(holder, env, "deleted") + + finding = found_in_every_mode(holder, "gitlink-unnamed") + + assert finding == outside(TOOLS, staged_at(holder, env, TOOLS), + COMMIT_THAT) + typed(holder, env, finding) + commit_all(holder, env, "Typed as printed") + assert head_paths(holder, env) == {ROW, f"members/{OTHER}"} + ends_well(holder, env, family, tmp_path) + + +def test_rv882_should_1_no_git_add_where_the_index_holds_no_file( + family, holder, offline, tmp_path): + """rv882's SHOULD 1, the other branch: (D) with `.gitmodules` on disk + and its removal staged (`git rm --cached`), so the index holds none. + No `git add -- .gitmodules` is printed: it would stage the file again + over the removal the person staged. The `git rm --cached` alone, typed + as printed, exits 0, and the staged removal stays as it was.""" + env = cloning(offline, tmp_path, family) + section_taken_out_alone(holder, env, family) + raw(holder, env, "rm", "-q", "--cached", "--", GITMODULES) + + finding = found_in_every_mode(holder, "gitlink-unnamed") + + assert finding == outside(TOOLS, staged_at(holder, env, TOOLS), + COMMIT_THAT) + typed(holder, env, finding) + staged = raw(holder, env, "diff", "--cached", "--name-status").stdout + assert staged == f"D\t{GITMODULES}\nD\t{TOOLS}\n".encode(), staged + + +@pytest.mark.parametrize("how", GONE) +def test_rv882_should_2_a_gitlink_the_file_git_reads_gives_is_no_finding( + family, holder, offline, tmp_path, how): + """rv882's SHOULD 2 on #882: (A), IRSS mounted at tools/x with `git + submodule add` and committed, with the work tree's `.gitmodules` gone. + The index's or HEAD's file, which git reads in its place, gives tools/x + a section, and a plain clone checks it out, while `gitlink-unnamed` + said that no section gave it. The paths are read from the file git + reads now: no mode finds `gitlink-unnamed`, and the gate passes.""" + env = cloning(offline, tmp_path, family) + at_tools(holder, env, family) + gone(holder, env, how) + + assert_passes_in_every_mode(holder, 2) + + +def on_disk_under(root: Path, path: str) -> list[str]: + """Every entry on disk under `path`, and its bytes where it is a file.""" + return sorted((str(entry.relative_to(root)), + entry.read_bytes() if entry.is_file() else b"") + for entry in [root / path, *(root / path).rglob("*")]) + + +def files_at(root: Path, path: str) -> None: + """A directory at `path` that holds a file and no `.git`.""" + (root / path).mkdir(parents=True) + (root / path / "kept.txt").write_bytes(b"kept\n") + + +def a_file_at(root: Path, path: str) -> None: + """A file at `path`.""" + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_bytes(b"kept\n") + + +def an_empty_directory_at(root: Path, path: str) -> None: + """An empty directory at `path`.""" + (root / path).mkdir(parents=True) + + +def a_link_to_an_empty_directory_at(root: Path, path: str) -> None: + """A symbolic link at `path` to an empty directory outside the holder + (lane 1's NIT 2 on #889), or a skip where none can be made.""" + target = root.parent / "an-empty-directory" + target.mkdir() + (root / path).parent.mkdir(parents=True, exist_ok=True) + try: + (root / path).symlink_to(target, target_is_directory=True) + except OSError as exc: + pytest.skip(f"no symbolic link can be made here: {exc}") + + +#: `(what is on disk at tools/x, the flag its git rm --cached carries, what +#: the text says of it)`: git 2.43 refused the plain line over the first +#: two (rv882's NIT 2) and the fourth (`use -f to force removal`, exit 1, +#: lane 1's NIT 2 on #889), and took it over the third. +ON_DISK = { + "a-directory-holding-a-file": (files_at, " -f", FORCED_NO_HEAD.replace( + "over a checkout", f"over {OVER_FILES}")), + "a-file": (a_file_at, " -f", FORCED_NO_HEAD.replace( + "over a checkout", f"over {OVER_FILES}")), + "an-empty-directory": (an_empty_directory_at, "", COMMIT_THAT), + "a-link-to-an-empty-directory": ( + a_link_to_an_empty_directory_at, " -f", FORCED_NO_HEAD.replace( + "over a checkout", f"over {OVER_FILES}")), +} + + +@pytest.mark.parametrize("make, flag, said", ON_DISK.values(), ids=ON_DISK) +def test_rv882_nit_2_a_gitlink_staged_over_what_is_no_checkout( + family, holder, offline, tmp_path, make, flag, said): + """rv882's NIT 2 on #882: IRQS's gitlink staged by hand at tools/x, + HEAD recording nothing there, over a directory holding a file and no + `.git`, or a file. Git refused the plain `git rm --cached` there + (`staged content different from both the file and the HEAD`, exit 1), + and `_forced` asked for a `.git` entry alone, so no `-f` was printed. + It is now, with what is on disk there; over an empty directory, which + git takes the plain line over, it is not. Typed as printed, the line + exits 0 and leaves what was on disk; the gate passes in every mode and + a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + make(holder, TOOLS) + commit = spare_commits(family)[1] + stage_gitlink(holder, TOOLS, commit) + before = on_disk_under(holder, "tools") + + finding = found_in_every_mode(holder, "gitlink-unnamed") + + assert finding == outside(TOOLS, commit, said, flag) + typed(holder, env, finding) + assert on_disk_under(holder, "tools") == before + ends_well(holder, env, family, tmp_path) + + +def test_rv882_nit_2_the_move_takes_f_over_what_is_no_checkout( + family, holder, offline, tmp_path, case_sensitive_disk): + """rv882's NIT 2, in the wrong-case move, which asks `_forced` too: + MEMBER recorded only at `Members/IRRS` and committed, then staged + there at another commit, over a directory holding a file and no + `.git`. The move's `git rm -r --cached` carries `-f` now, and says + what is on disk there; typed as printed and committed, it exits 0, + leaves the file, and the gate passes in members mode.""" + env = cloning(offline, tmp_path, family) + commit = recorded_at(holder, env, MEMBER, VARIANT_PATH) + other = spare_commits(family)[1] + stage_gitlink(holder, VARIANT_PATH, other) + files_at(holder, VARIANT_PATH) + + refused = validate(holder) + + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refused.stderr.splitlines() == [ + line.replace("over a checkout", f"over {OVER_FILES}") + for line in variant_refused(ROW, commit, [VARIANT_PATH], + {VARIANT_PATH: other}, + forced=(VARIANT_PATH,))] + for line in refusal_lines(refused): + type_line(holder, env, line) + commit_all(holder, env, "Moved as printed") + assert (holder / VARIANT_PATH / "kept.txt").read_bytes() == b"kept\n" + assert validate(holder).returncode == 0 + + +# --- lane 1's review of #889, round 1 --------------------------------------- + +OTHER_ROW = f"members/{OTHER}" + + +def test_round1_must_1_p4_the_first_removal_puts_back_head_s_file( + family, holder, offline, tmp_path): + """Lane 1's MUST 1 on #889, its P4: `git rm --cached members/IRRS`, + `git rm members/IRSS`, and the work tree's `.gitmodules` deleted. The + first removal put back the index's file, which the second `git rm` had + taken IRSS's section out of, and the second wrote that section into + the work tree's file alone: `MM .gitmodules`, and a plain commit + dropped it from HEAD. The first puts back HEAD's now, since the + index's lacks a section another staged removal needs, and the second + prints no line for the file, as #876's head printed. Typed in order, + nothing is left to commit, and the gate passes in every mode.""" + env = cloning(offline, tmp_path, family) + raw(holder, env, "rm", "-q", "--cached", "--", ROW) + raw(holder, env, "rm", "-q", "--", OTHER_ROW) + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + assert [commands(finding) for finding in found] == [ + [f"git reset -q -- {ROW}", "git checkout HEAD -- .gitmodules"], + [f"git reset -q -- {OTHER_ROW}", + f"git submodule update --init --recursive -- {OTHER_ROW}"]] + typed_in_order(holder, env, validate(holder, "--no-members")) + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +@pytest.mark.parametrize("how", ["deleted", "on-disk"]) +def test_round1_must_1_p5_the_section_written_back_is_staged( + family, holder, offline, tmp_path, how): + """Lane 1's MUST 1, its P5: MEMBER recorded only at `Members/IRRS` + (`moved_in_head`), then `git rm members/IRSS`, under `--no-members`. + Where the work tree's `.gitmodules` was gone, the move put back the + index's, which lacks IRSS's section, and the removal wrote that + section into the work tree's file alone, so the commit the move names + dropped it; on disk, the removal's `git checkout HEAD -- .gitmodules` + took the move's write out. Either way it writes IRSS's section back + key by key and stages the file now. Typed in order and committed with + a plain `git commit`, as the move says, nothing is left, HEAD's + `.gitmodules` gives both members, and the gate passes in every mode.""" + env = cloning(offline, tmp_path, family) + moved_in_head(holder, env) + lines = written_back(holder, env, OTHER_ROW) + raw(holder, env, "rm", "-q", "--", OTHER_ROW) + if how == "deleted": + gone(holder, env, how) + + result = validate(holder, "--no-members") + + assert codes(result)[0] == "member-gitlink-unnamed" + assert codes(result)[-1] == "member-gitlink-removal-staged" + assert commands(findings(result)[-1]) == [ + f"git reset -q -- {OTHER_ROW}", *lines, + f"git submodule update --init --recursive -- {OTHER_ROW}"] + typed_in_order(holder, env, result) + commit_all(holder, env, "Committed as the move says") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + assert head_paths(holder, env) == {ROW, OTHER_ROW} + ends_well(holder, env, family, tmp_path) + + +def stale_section(root: Path, env: dict, family) -> None: + """A section for `members/IRQS`, which no row names and no gitlink is + at, committed in HEAD's `.gitmodules`: one `family.py remove` left.""" + data = raw(root, env, "show", f"HEAD:{GITMODULES}").stdout + committed_gitmodules(root, env, data + ( + f'[submodule "members/{SPARE}"]\n\tpath = members/{SPARE}\n' + f"\turl = https://github.com/InkRouter/{SPARE}.git\n").encode()) + + +def mounted_with_its_section(root: Path, env: dict, family) -> None: + """A member mounted by hand at `members/IRQS` with `git submodule + add`, its section with it, committed, and no row.""" + submodule_added(root, env, family, f"members/{SPARE}") + commit_all(root, env, "A member mounted by hand with its section") + + +def head_edited(edit): + """A state made by `edit` on HEAD's `.gitmodules` bytes, committed.""" + def made(root: Path, env: dict, family) -> None: + data = raw(root, env, "show", f"HEAD:{GITMODULES}").stdout + committed_gitmodules(root, env, edit(data)) + return made + + +def value_staged_out(path: str): + """A state in which `update = None` is committed in the section giving + `path`, and the file without it is staged: git rm dies reading a work + tree's file that gives such a value (`invalid value for + 'submodule..update'`, exit 128), so the person mended it first.""" + def made(root: Path, env: dict, family) -> None: + data = raw(root, env, "show", f"HEAD:{GITMODULES}").stdout + committed_gitmodules(root, env, valued(data, path)) + (root / GITMODULES).write_bytes(data) + raw(root, env, "add", "--", GITMODULES) + return made + + +#: `(the state before the removal, the code of the finding it makes)`: each +#: finding's lines write the work tree's `.gitmodules`, or it says to +#: commit the file as it stands, and the removal's `git checkout HEAD -- +#: .gitmodules` put HEAD's back over that (lane 1's SHOULD 1 on #889). The +#: value in MEMBER's own section is one a section written back key by key +#: must leave out. +EARLIER_WRITES = { + "section-absent": (head_edited( + lambda data: section_taken_out(data, OTHER)), + "member-gitmodules-absent"), + "url-absent": (head_edited(lambda data: without_url(data, OTHER)), + "member-gitmodules-url-absent"), + "value-in-another-section": (value_staged_out(OTHER_ROW), + "gitmodules-value-refused"), + "value-in-its-own-section": (value_staged_out(ROW), + "gitmodules-value-refused"), + "a-section-no-row-names": (stale_section, "gitmodules-path-unnamed"), + "a-hand-mount-with-its-section": (mounted_with_its_section, + "member-gitlink-unnamed"), +} + + +@pytest.mark.parametrize("make, code", EARLIER_WRITES.values(), + ids=EARLIER_WRITES) +def test_round1_should_1_no_put_back_over_an_earlier_write_on_disk( + family, holder, offline, tmp_path, make, code): + """Lane 1's SHOULD 1 on #889: a finding whose lines write the work + tree's `.gitmodules`, or that says to commit it as it stands, then a + plain `git rm members/IRRS`, with the file on disk. The removal's `git + checkout HEAD -- .gitmodules` put HEAD's file back over that, so the + commit after it had nothing to commit and the earlier finding came + back. It writes MEMBER's section + back key by key now, leaving out a value git dies on, and stages the + file, in every mode. Typed in order and committed, the gate passes in + every mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + lines = written_back(holder, env, ROW) + make(holder, env, family) + raw(holder, env, "rm", "-q", "--", ROW) + + found = found_alike(holder, *MODES) + + assert f"FINDING {code}: " in found[0], found[0] + assert found[-1].startswith("FINDING member-gitlink-removal-staged: ") + assert commands(found[-1]) == [ + f"git reset -q -- {ROW}", *lines, + f"git submodule update --init --recursive -- {ROW}"] + assert ("the second to the third the .gitmodules section git rm took " + "out, key by key as HEAD has it, in the work tree's file, as the " + f"lines above leave it, the fourth {STAGED_WHOLE}, the fifth its " + "checkout" + STAGED_BACK) in found[-1], found[-1] + typed_in_order(holder, env, validate(holder, "--no-members")) + staged_and_committed(holder, env, "Typed in order") + ends_well(holder, env, family, tmp_path) + + +def test_round1_should_1_a_second_section_taken_out_by_hand_stays_out( + family, holder, offline, tmp_path): + """Lane 1's SHOULD 1, beside `gitmodules-path-twice`, whose remedy is a + by-hand `--unset-all` of each key of the section that is not the + member's: a second section giving IRSS's path committed, then a plain + `git rm members/IRRS`. The removal's `git checkout HEAD -- + .gitmodules` put the second section back. It writes MEMBER's section + back key by key now and stages the file. The second section taken out + by hand as that finding says, then the removal's lines, as printed + before, typed and committed, the gate passes in every mode.""" + env = cloning(offline, tmp_path, family) + lines = written_back(holder, env, ROW) + data = raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout + committed_gitmodules(holder, env, data + ( + f'[submodule "second"]\n\tpath = {OTHER_ROW}\n' + "\turl = https://github.com/InkRouter/Second.git\n").encode()) + raw(holder, env, "rm", "-q", "--", ROW) + + found = found_alike(holder, *MODES) + + assert [finding.split(":", 1)[0] for finding in found] == [ + "FINDING gitmodules-path-twice", + "FINDING member-gitlink-removal-staged"] + assert commands(found[1]) == [ + f"git reset -q -- {ROW}", *lines, + f"git submodule update --init --recursive -- {ROW}"] + for key in ("path", "url"): + raw(holder, env, "config", "-f", GITMODULES, "--unset-all", + f"submodule.second.{key}") + typed(holder, env, found[1]) + staged_and_committed(holder, env, "Typed in order") + ends_well(holder, env, family, tmp_path) + + +#: What `member-gitlink-removal-staged` says of a clone of its own at a case +#: variant of the row's path (lane 1's SHOULD 2 on #889), as the wrong-case +#: move says it. +CLONE_KEPT = ( + " The clone at {variant}, whose .git is a directory, stays there, " + "untracked, and family.py add refuses it as family-members-dir-variant: " + "it holds its commits there and nowhere else, and `git -C {variant} log " + "--oneline HEAD --branches --not --remotes` lists the ones not pushed, " + "so move it out of the holder whole, by hand, rather than delete it.") + + +def test_round1_should_2_a_clone_at_a_case_variant_is_moved_out_whole( + family, holder, offline, tmp_path, case_sensitive_disk): + """Lane 1's SHOULD 2 on #889: `git rm --cached members/IRRS`, its + checkout deleted, IRRS cloned at `Members/IRRS` with a commit only that + clone holds, and `git add Members/IRRS`. The removal's finding said + the checkout `git mv` moved was there, its commits kept in the + member's store, to delete by hand: no `git mv` happened, its `.git` is + a directory, and deleted, the commit was lost. It says what a clone + of its own is now, in every mode, with the `git log` that lists that + commit, and to move it out whole. Typed in order, the clone moved out + of the holder as it says, nothing is left, the moved clone keeps the + commit, the gate passes in every mode and a plain clone checks both + out.""" + env = cloning(offline, tmp_path, family) + raw(holder, env, "rm", "-q", "--cached", "--", ROW) + rmtree(holder / ROW) + (holder / "Members").mkdir() + raw(holder, env, "clone", "-q", "--", + str(family["remotes"] / f"{MEMBER}.git"), VARIANT_PATH) + clone = holder / VARIANT_PATH + raw(clone, env, "-c", "user.name=t", "-c", "user.email=t@t", "commit", + "-q", "--allow-empty", "-m", "Only this clone holds it") + made = raw(clone, env, "rev-parse", "HEAD").stdout.decode("ascii").strip() + raw(holder, env, "add", "--", VARIANT_PATH) + + found = found_alike(holder, *MODES) + + assert "The checkout git mv moved" not in found[-1] + assert CLONE_KEPT.format(variant=VARIANT_PATH) in found[-1], found[-1] + for finding in found: + typed(holder, env, finding) + listed = raw(holder, env, "-C", VARIANT_PATH, "log", "--oneline", "HEAD", + "--branches", "--not", "--remotes").stdout.decode().split() + assert made.startswith(listed[0]), listed + kept = tmp_path / "kept-out" + shutil.move(str(clone), str(kept)) + assert raw(holder, env, "status", "--porcelain").stdout == b"" + assert raw(kept, env, "cat-file", "-e", made, + check=False).returncode == 0 + ends_well(holder, env, family, tmp_path) + + +def test_round1_should_3_a_checkout_git_mv_moved_out_of_members_is_named( + family, holder, offline, tmp_path): + """Lane 1's SHOULD 3 on #889: `git mv members/IRRS renamed-1`, staged, + with an untracked file in the checkout it moved. Typed as printed, + `renamed-1/` was left untracked and named by no finding, after the + update its status read members/IRRS, so the file went unseen, and the + gate passed. The removal's finding names it now, in every mode, as C4 + names one at a case variant, with no `family.py add` refusal, which + only a variant meets; its section is written back key by key, after + the unnamed section's lines took it out. Typed in order but the last + line, its status shows the file; deleted by hand as the text says, + then the update typed and the file committed, nothing is left, the + gate passes in every mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + lines = written_back(holder, env, ROW) + raw(holder, env, "mv", "--", ROW, RENAMED) + (holder / RENAMED / "scratch.txt").write_bytes(b"mine\n") + + found = found_alike(holder, *MODES) + + assert codes_of(found) == ["gitmodules-path-unnamed", + "member-gitlink-removal-staged"] + assert (MOVED.format(variant=RENAMED, path=ROW, refused="") + + " If it is to go,") in found[-1], found[-1] + assert commands(found[-1]) == [ + f"git reset -q -- {ROW}", *lines, + f"git submodule update --init --recursive -- {ROW}"] + typed_lines = [line for finding in found for line in commands(finding)] + for line in typed_lines[:-1]: + type_line(holder, env, line) + status = raw(holder, env, "-C", RENAMED, "status", "--porcelain").stdout + assert b"?? scratch.txt\n" in status, status + rmtree(holder / RENAMED) + type_line(holder, env, typed_lines[-1]) + staged_and_committed(holder, env, "Typed in order") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +def codes_of(found: list[str]) -> list[str]: + """The code of each of `found`, the findings as printed.""" + return [finding.split(":", 1)[0].removeprefix("FINDING ") + for finding in found] + + +def test_round1_nit_3_the_move_says_what_is_on_disk_at_each_variant( + family, holder, offline, tmp_path, case_sensitive_disk): + """Lane 1's NIT 3 on #889: MEMBER recorded only at `MEMBERS/IRRS` and + `Members/IRRS`, both then staged at another commit, a directory + holding a file at the one and a checkout at the other. The `-f` + sentence said of both "what is on disk there, which is no checkout" + once one was no checkout. It says each by its path now. Typed as + printed and committed, the lines exit 0, and the gate passes in + members mode.""" + env = cloning(offline, tmp_path, family) + variants = ["MEMBERS/IRRS", VARIANT_PATH] + commit = recorded_at(holder, env, MEMBER, *variants) + other = spare_commits(family)[1] + for variant in variants: + stage_gitlink(holder, variant, other) + files_at(holder, variants[0]) + raw(holder, env, "init", "-q", "--", variants[1]) + + refused = validate(holder) + + assert refused.returncode == 2, refused.stdout + refused.stderr + assert refused.stderr.splitlines() == [ + line.replace("over a checkout", f"over what is on disk at " + f"{variants[0]}, which is no checkout, and over a " + f"checkout at {variants[1]}") + for line in variant_refused(ROW, commit, variants, + dict.fromkeys(variants, other), + forced=tuple(variants))] + for line in refusal_lines(refused): + type_line(holder, env, line) + commit_all(holder, env, "Moved as printed") + assert validate(holder).returncode == 0 + + +#: What (a) says where no row is declared and git updated nothing (lane 1's +#: NIT 5 on #889). +NOTHING_UPDATED = NONE_UPDATED.replace("NO MEMBER UPDATED", "NOTHING UPDATED") + + +def no_rows(root: Path, env: dict) -> None: + """`members: []` in `family.yaml`, staged: the rows are its last key, + rewritten wholesale by `family.py`.""" + manifest = root / "family.yaml" + text = manifest.read_text(encoding="utf-8") + manifest.write_text(text[:text.index("\nmembers:")] + "\nmembers: []\n", + encoding="utf-8") + raw(root, env, "add", "--", "family.yaml") + + +def test_round1_nit_5_with_no_rows_nothing_updated_is_said( + family, holder, offline, tmp_path): + """Lane 1's NIT 5 on #889: `members: []`, and a gitlink at vendor/IRQS + that no section gives, committed. Git's init stops there, so the run + fails, as it should, where #876's head said ok; but its line said "NO + MEMBER UPDATED" under "0 member(s)". It says nothing was updated + now.""" + env = cloning(offline, tmp_path, family) + no_rows(holder, env) + path = outside_gitlink(holder, env, family) + + result = bootstrap(holder, env, "--skip-members") + + assert result.returncode == 1, result.stdout + result.stderr + assert result.stdout.splitlines()[0].endswith(" 0 member(s)") + assert after_fetch_line(result) == [STOP_FAILED.format(path=path)] + assert NOTHING_UPDATED.format( + why=STOPPED.format(path=path), + cure=OUTSIDE_CURE.format(path=path)) in result.stderr.splitlines() + + +def test_round1_nit_6_the_value_s_put_back_serves_the_removal_after_it( + family, holder, offline, tmp_path): + """Lane 1's NIT 6 on #889 (its mutant MX1): `update = None` committed + in IRSS's section, `git rm --cached members/IRRS`, and the work tree's + `.gitmodules` deleted. The value finding puts back HEAD's file and + records that, so the removal after it prints no line for the file, + which HEAD's gives MEMBER's section in. Put back again from the + index's, which holds the value, it undid the fix, and the commit died + (`invalid value for 'submodule.members/IRSS.update'`, exit 128). + Typed in order and committed, the gate passes in every mode and a + plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + value_committed(holder, env, OTHER_ROW) + raw(holder, env, "rm", "-q", "--cached", "--", ROW) + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + assert codes_of(found) == ["gitmodules-value-refused", + "member-gitlink-removal-staged"] + assert commands(found[0])[0] == "git checkout HEAD -- .gitmodules" + assert commands(found[1]) == [f"git reset -q -- {ROW}"] + typed_in_order(holder, env, validate(holder, "--no-members")) + staged_and_committed(holder, env, "Typed in order") + ends_well(holder, env, family, tmp_path) + + +# --- lane 1's review of #889, round 2 --------------------------------------- + +#: `(the flags of the removal, the lines its finding prints)` where a hand +#: mount's `git rm` came before it and the work tree's `.gitmodules` is +#: gone: the index's file put back, or HEAD's after a plain `git rm`, as +#: round 0 printed (lane 1's MUST 2 on #889, round 2). +HAND_MOUNT_THEN = { + "cached": (["--cached"], + [f"git reset -q -- {ROW}", "git checkout -- .gitmodules"]), + "plain": ([], [f"git reset -q -- {ROW}", + "git checkout HEAD -- .gitmodules", + f"git submodule update --init --recursive -- {ROW}"]), +} + + +@pytest.mark.parametrize("kind", HAND_MOUNT_THEN) +def test_round2_must_2_a_hand_mount_s_git_rm_writes_nothing_in_a_gone_file( + family, holder, offline, tmp_path, kind): + """Lane 1's MUST 2 on #889, round 2: SPARE mounted by hand at + `members/IRQS` (`hand_mounted`), then `git rm --cached members/IRRS` + or a plain `git rm members/IRRS`, and the work tree's `.gitmodules` + deleted. Round 1 counted the hand mount's `git rm` as a write, though + it writes nothing into a file that is gone, so the removal wrote + MEMBER's section key by key "in the file a line above put back", which + no line had: committed, HEAD's `.gitmodules` held that one section, + and the gate found `member-gitmodules-absent` in every mode. Only a + put-back makes a file to write into there, so the removal puts the + file back itself, as round 0 did. Typed in order and committed, + nothing is left, HEAD's `.gitmodules` gives both members, the gate + passes in every mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + flags, lines = HAND_MOUNT_THEN[kind] + hand_mounted(holder, env, family) + raw(holder, env, "rm", "-q", *flags, "--", ROW) + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + assert codes_of(found) == ["member-gitlink-unnamed", + "member-gitlink-removal-staged"] + assert commands(found[1]) == lines + typed_in_order(holder, env, validate(holder, "--no-members")) + staged_and_committed(holder, env, "Typed in order") + assert raw(holder, env, "status", "--porcelain").stdout == b"" + assert head_paths(holder, env) == {ROW, OTHER_ROW} + ends_well(holder, env, family, tmp_path) + + +def test_round2_nit_a_clone_at_no_case_variant_is_not_said_refused( + family, holder, offline, tmp_path): + """Lane 1's NIT on #889, round 2 (its mutant MY3): `git rm --cached + members/IRRS`, its checkout deleted, then IRRS cloned at `tools/x`, + which is no case variant of the row's path, and `git add tools/x`. + `member-gitlink-removal-staged` names a clone of its own only at a + case variant of the row's path, which `family.py add` refuses as + `family-members-dir-variant` (round 1's SHOULD 2); of one at + `tools/x`, which `gitlink-unnamed` takes out of the index, it says in + every mode what it says with no clone there. Typed in order, the clone + stays where it is, untracked, nothing is left to commit, the gate + passes in every mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + raw(holder, env, "rm", "-q", "--cached", "--", ROW) + rmtree(holder / ROW) + alone = found_alike(holder, *MODES) + raw(holder, env, "clone", "-q", "--", + str(family["remotes"] / f"{MEMBER}.git"), TOOLS) + raw(holder, env, "add", "--", TOOLS) + + found = found_alike(holder, *MODES) + + assert codes_of(found) == ["gitlink-unnamed", + "member-gitlink-removal-staged"] + assert found[1] == alone[-1] + typed_in_order(holder, env, validate(holder, "--no-members")) + assert raw(holder, env, "status", "--porcelain").stdout == b"?? tools/\n" + ends_well(holder, env, family, tmp_path) + + +#: What a hand mount's text says of its `git rm` where git refuses the +#: gitlink as staged (lane 1's SHOULD on #889, round 2), with git's two +#: refusals (its NIT 1, round 3), and elsewhere. +STAGED_REFUSED = ( + "take it out with the second, which refuses while its checkout holds " + "changes, and while the index stages it at a commit HEAD does not record " + "there, as it does here (the following file has changes staged in the " + "index, or, where what is on disk differs too, has staged content " + "different from both the file and the HEAD, exit 1), and takes its " + ".gitmodules section out too") +PLAIN_REFUSED = ("take it out with the second, which refuses while its " + "checkout holds changes and takes its .gitmodules section " + "out too") + + +def moved_by_git_mv(root: Path, env: dict, family) -> str: + """`git mv members/IRRS members/IRRS2`, staged. Returns the new path.""" + raw(root, env, "mv", "--", ROW, f"{ROW}2") + return f"{ROW}2" + + +def staged_by_hand(make): + """A state in which SPARE's gitlink is staged at `members/IRQS`, not + committed, with what `make` puts on disk there.""" + def staged(root: Path, env: dict, family) -> str: + path = f"members/{SPARE}" + stage_gitlink(root, path, spare_commits(family)[1]) + make(root / path) + return path + return staged + + +def committed_with_its_checkout(root: Path, env: dict, family) -> str: + """`mounted_with_its_section`'s state. Returns the gitlink's path.""" + mounted_with_its_section(root, env, family) + return f"members/{SPARE}" + + +def moved_with_a_commit(root: Path, env: dict, family) -> str: + """`moved_by_git_mv`'s state, then a commit in the checkout it moved. + Returns the new path.""" + path = moved_by_git_mv(root, env, family) + raw(root / path, env, "-c", "user.name=t", "-c", "user.email=t@t", + "commit", "-q", "--allow-empty", "-m", "Made in the moved checkout") + return path + + +def holding_a_file(where: Path) -> None: + """A directory at `where` that holds a file and no `.git`.""" + where.mkdir() + (where / "kept.txt").write_bytes(b"kept\n") + + +def moved_back_and_added(root: Path, env: dict, family) -> str: + """`committed_with_its_checkout`'s state, then its checkout moved to + `HEAD~1` and `git add`-ed: HEAD records another commit there (lane 1's + NIT 2 on #889, round 3). Returns the path.""" + path = committed_with_its_checkout(root, env, family) + raw(root / path, env, "checkout", "-q", "HEAD~1") + raw(root, env, "add", "--", path) + return path + + +#: `(the state, whether a plain git rm refuses its gitlink as staged)`: +#: measured on git 2.43, it took one staged over nothing on disk, and one +#: committed with its checkout. Where it refuses, the first three say `has +#: changes staged in the index`, and the others, whose disk differs from +#: what is staged, `has staged content different from both the file and +#: the HEAD` (lane 1's NIT 1 on #889, round 3). +STAGED_STATES = { + "git-mv": (moved_by_git_mv, True), + "an-empty-directory": (staged_by_hand(Path.mkdir), True), + "head-records-another-commit": (moved_back_and_added, True), + "git-mv-and-a-commit-in-it": (moved_with_a_commit, True), + "a-directory-holding-a-file": (staged_by_hand(holding_a_file), True), + "nothing-on-disk": (staged_by_hand(lambda where: None), False), + "committed-with-its-checkout": (committed_with_its_checkout, False), +} + + +def git_refusal(stderr: bytes) -> str: + """What `git rm` said it refused, after "the following file ", up to + its colon, on one line: git wraps the longer of its two refusals.""" + said = stderr.decode("utf-8").split("the following file ", 1)[1] + return " ".join(said.split(":", 1)[0].split()) + + +@pytest.mark.parametrize("make, refused", STAGED_STATES.values(), + ids=STAGED_STATES) +def test_round2_should_a_hand_mount_s_git_rm_says_it_refuses_a_staged_gitlink( + family, holder, offline, tmp_path, make, refused): + """Lane 1's SHOULD on #889, round 2: a gitlink no row names, staged at + a commit HEAD does not record there, as `git mv members/IRRS + members/IRRS2` leaves one, or as one staged by hand over an empty + directory. The hand mount's `git rm -- ` exits 1 there, as + before this change, where its text said only that it refuses while + its checkout holds changes. It says that refusal too now, in every + mode, and not where nothing is on disk or HEAD records the gitlink as + staged, which git takes. No line takes such a gitlink out: `-f` would + take the checkout's changes with it. Round 3 (lane 1's NITs 1 and 2): + the text quotes the refusal git prints, either of two, and a gitlink + HEAD records at another commit gets it too.""" + env = cloning(offline, tmp_path, family) + path = make(holder, env, family) + + found = found_alike(holder, *MODES) + + mount = found[0] + assert mount.startswith(f"FINDING member-gitlink-unnamed: {path} "), mount + assert (STAGED_REFUSED if refused else PLAIN_REFUSED) in mount, mount + switch, removal, _ = commands(mount) + assert removal == f"git rm -- {path}" + type_line(holder, env, switch) + typed = raw(holder, env, *shlex.split(removal)[1:], check=False) + assert typed.returncode == int(refused), typed.stderr + if refused: + assert git_refusal(typed.stderr) in mount, typed.stderr diff --git a/tests/test_family_unnamed_gitlink.py b/tests/test_family_unnamed_gitlink.py index 6f922260..4450822d 100644 --- a/tests/test_family_unnamed_gitlink.py +++ b/tests/test_family_unnamed_gitlink.py @@ -96,8 +96,10 @@ #: What it says first where lines printed before it may leave `.gitmodules` #: with changes not staged, or it holds some, and the line it prints for -#: that: git takes no gitlink out of the index then (#879's C3). -STAGE_SAID = ("stage .gitmodules with the first line, since git rm refuses to " +#: that: git takes no gitlink out of the index then (#879's C3), and the +#: line stages every other change in the file too (rv882's NIT 3). +STAGE_SAID = ("stage .gitmodules with the first line, which stages every " + "other change in it too, since git rm refuses to " "take any gitlink out of the index, -f or not, while " ".gitmodules holds changes not staged (please stage your " "changes to .gitmodules or stash them to proceed, exit 128), "