diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 172647b3..18a0942d 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -64,7 +64,8 @@ clone fetches that path from only one of them, so the other is a url the next edit can make the one. It reads that file alone, as git reads it: the work tree's, or where that is gone from disk the index's, - then HEAD's (#1077), so `--no-members` asks it too. + then HEAD's (#1077), and none while a merge leaves it unmerged in the + index (#1150), so `--no-members` asks it too. - that no section of `.gitmodules` gives a value git dies on as it reads the file, before `git submodule update` checks out any member, the `gitmodules-value-refused` finding (#768): a `path`, `url`, `update`, @@ -72,8 +73,12 @@ `checkout`, `rebase` or `merge`, a `shallow` that is no boolean, or a `fetchRecurseSubmodules` that is neither a boolean nor `on-demand`. It reads the file git reads, the work tree's, or where that is gone from - disk the index's, then HEAD's (#1077), and HEAD's, which a clone reads, - so `--no-members` asks it too, + disk the index's, then HEAD's (#1077), and none while a merge leaves + it unmerged in the index; HEAD's, which a clone reads; and, where the + work tree's is on disk, the index's, which a plain `git commit` + records, each line set that says to stage the file ending with `git + add -- .gitmodules` (#1150). An index's file git cannot parse is + `gitmodules-unreadable`, as HEAD's is. So `--no-members` asks it too, and where it is found no member that is not checked out is refused as uninitialized: git checked none out. - the REVERSE of every member check (#361): that each gitlink this @@ -104,7 +109,9 @@ of the index. One a section gives is that section's `gitmodules-path-unnamed`, which takes the gitlink out of the index first, then the section: taken out alone, the section left the - gitlink, and this gate passed it. Each `git rm --cached` comes after + gitlink, and this gate passed it. None is asked about while a merge + leaves `.gitmodules` unmerged in the index, as no section is: git + reads no file then (#1150). Each `git rm --cached` comes after `git add -- .gitmodules` where lines printed before it may leave that file with changes not staged, or it holds some, since git takes no gitlink out of the index then. Neither needs a member checked out, @@ -1977,10 +1984,28 @@ def _head_listed(root: Path) -> tuple[bool, bytes] | None: #: Where `gitmodules-value-refused` says a value is, by whether the file #: git reads here and HEAD's give it (#768); `{here}` names the file git #: reads here (`READ_HERE`), the work tree's, or where that is gone from -#: disk the index's (#1077). +#: disk the index's (#1077). `(False, False)` is where neither gives it and +#: the index's file alone does, the work tree's being on disk (#1150, +#: RV1124's N1 on #1124). GIVEN_WHERE = {(True, True): "here and in HEAD, which a clone reads", (True, False): "in {here}, not in HEAD", - (False, True): "in HEAD, which a clone reads, not in {here}"} + (False, True): "in HEAD, which a clone reads, not in {here}", + (False, False): "in the index's file alone, which a plain " + "`git commit` records, not in {here} or in " + "HEAD"} + +#: How git dies on a value, by where it is given (`GIVEN_WHERE`): here, +#: in a clone where HEAD's alone gives it, and in a clone of the commit +#: that records the index's where that file alone gives it (#1150). +VALUE_DIES = { + (True, True): "before `git submodule update` checks out any member, so " + "neither that nor make bootstrap checks one out", + (False, True): "so in a clone neither `git submodule update` nor make " + "bootstrap checks out any member", + (False, False): "so in a clone of the commit that records it neither " + "`git submodule update` nor make bootstrap checks out " + "any member"} +VALUE_DIES[(True, False)] = VALUE_DIES[(True, True)] #: How `GIVEN_WHERE` names the file git reads here, by git's name for the #: one it reads in place of the work tree's (`_listed_as_git_reads`): none @@ -1989,6 +2014,30 @@ def _head_listed(root: Path) -> tuple[bool, bytes] | None: f":{GITMODULES}": "the index's file, which git reads here", f"HEAD:{GITMODULES}": "HEAD's file, which git reads here"} +#: How `GIVEN_WHERE` names the file git reads here while a merge leaves +#: `.gitmodules` unmerged in the index (`_gitmodules_unmerged`): none +#: (#1150, RV1124's N6 on #1124). +UNMERGED_HERE = ("the file git reads here, which is none while a merge " + f"leaves {GITMODULES} unmerged") + +#: What the index's file adds to `gitmodules-value-refused` where the work +#: tree's, on disk, gives the value and HEAD's does not: a plain `git +#: commit` records the index's (#1150, RV1124's N1 on #1124). +INDEX_TOO = ("The index's file, which a plain `git commit` records, gives it " + "too.") + +#: What it adds in `INDEX_TOO`'s place where the index's file gives that +#: key another value git dies on, `{values}` as `_values_said` says them +#: (rv1227's N4 on #1227). +INDEX_GIVES = ("The index's file, which a plain `git commit` records, gives " + "it {values}.") + +#: What it adds where the work tree's file alone gives the value and a line +#: an earlier finding of the check prints stages that file, the value with +#: it, so its own lines end staging the file again (rv1227's S1 on #1227). +STAGED_ABOVE = (f"A line above stages the work tree's {GITMODULES}, this " + "value with it, so the lines below end staging it again.") + #: What git takes for a `submodule..` whose value it dies on as #: it reads `.gitmodules` (#768), as `gitmodules-value-refused` says it. #: This, `_values_refused` and `_git_takes_bool` say what bootstrap's @@ -2042,76 +2091,163 @@ def _check_gitmodules_values(root: Path, members: list, and ends staging it: the index's file is what the commit records. 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).""" + 1's SHOULD 1). + + THE INDEX'S FILE TOO, where the work tree's is on disk (#1150, + RV1124's N1 on #1124): git reads the work tree's here, but a plain + `git commit` records the index's. Read from the work tree's and HEAD's + alone, an `update = !true` given in the work tree's file and staged + was taken out of the work tree's file by a line that staged nothing, + so a plain `git commit` after it recorded the value in HEAD, and one + given in the index's file alone, the work tree's clean, was `family + ok`, exit 0. Every remedy that says to stage the file now ends with + the line that stages it, `git add -- .gitmodules` (`STAGE_LINE`). + + AFTER A LINE THAT STAGES THE FILE, every later finding that takes a + key out of it stages it again (`STAGED_ABOVE`, rv1227's S1 on #1227): + that `git add` stages the work tree's file as it stands, so an + `update = !true` staged for one section, then an `update = !false` + the work tree's file alone gave another, typed in order and committed + with a plain `git commit`, left `!false` in HEAD. + + NO FILE WHILE A MERGE LEAVES `.gitmodules` UNMERGED (#1150, RV1124's + N6 on #1124): git's submodule commands read none then, the work + tree's, the index's or HEAD's (`_gitmodules_unmerged`), so none is + read here, and HEAD's, which a clone reads, is said to be resolved: + read as the work tree's, or HEAD's where that was gone, a value HEAD's + gave was "here", and its line put HEAD's back "as the index holds + none".""" present = os.path.lexists(root / GITMODULES) # The work tree's file, then HEAD's, then the one git reads in place of # the work tree's: with no git to run, `git-not-runnable` names the # first of them it meets, as before #1077. on_disk = _gitmodules_listed(root) head = _head_listed(root) - listed, spec = (on_disk, "") if present else _listed_as_git_reads(root) + unmerged = _gitmodules_unmerged(root) + listed, spec = ((b"", "") if unmerged else (on_disk, "") if present + else _listed_as_git_reads(root)) here = _submodule_entries(listed) there = _submodule_entries(head[1]) if head else [] in_work = _values_refused(root, here) in_head = _values_refused(root, there) + in_index = (_values_refused(root, _submodule_entries(_index_listed(root))) + if present else {}) rows = {f"{DEFAULT_MEMBERS_DIR}/{row['project']}": row.get("repository") for row in members if isinstance(row, dict) and isinstance(row.get("project"), str) and row["project"]} - for name, key in dict.fromkeys([*in_work, *in_head]): + # Whether a finding printed above ends staging the work tree's file + # (rv1227's S1 on #1227). + staging = False + for name, key in dict.fromkeys([*in_work, *in_head, *in_index]): report.wrote = True - said = _value_said(name, key, in_work, in_head, READ_HERE[spec]) + said = _value_said(name, key, (in_work, in_head, in_index), + UNMERGED_HERE if unmerged else READ_HERE[spec]) if (name, key) not in in_work: - report.finding("gitmodules-value-refused", f"{said} " + ( - f"Stage and commit the work tree's {GITMODULES}, which does " - "not give it." if present else - f"Commit the index's {GITMODULES}, which git reads here and " - "does not give it.")) + report.finding("gitmodules-value-refused", f"{said} " + + _value_not_here(present, unmerged, + (name, key) in in_head)) continue back = _written_back(name, key, here, rows) + committed = (name, key) in in_head + staged = not present or (name, key) in in_index report.finding("gitmodules-value-refused", _value_taken_out( said, f"submodule.{name}.{key}", back, - _file_put_back(root, report), committed=(name, key) in in_head, - staged=not present)) + _file_put_back(root, report), committed=committed, + staged=staged, again=staging)) + staging = staging or committed or staged return bool(in_work), bool(in_work or in_head) +def _value_not_here(present: bool, unmerged: bool, committed: bool) -> str: + """`gitmodules-value-refused`'s remedy where the file git reads here + does not give the value, HEAD's or the index's does (#768, #1077, + #1150): the merge resolved without it where a merge leaves + `.gitmodules` unmerged (`unmerged`), or else the work tree's file, + which does not give it, staged, and committed where HEAD's gives it + (`committed`), or, where that file is gone from disk (`present` + False), the index's, which git reads here, committed as it stands, + with no line. Each that says to stage ends with the line that does + (`STAGE_LINE`).""" + if unmerged: + return (f"Resolve the merge in {GITMODULES} by hand, leaving that " + f"key out, then stage and commit {GITMODULES}:" + f"\n {STAGE_LINE}") + if not present: + return (f"Commit the index's {GITMODULES}, which git reads here and " + "does not give it.") + return (("Stage and commit" if committed else "Stage") + + f" the work tree's {GITMODULES}, which does not give it:" + f"\n {STAGE_LINE}") + + def _value_said(name: str, key: str, - in_work: dict[tuple[str, str], list[str | None]], - in_head: dict[tuple[str, str], list[str | None]], + given: tuple[dict[tuple[str, str], list[str | None]], + dict[tuple[str, str], list[str | None]], + dict[tuple[str, str], list[str | None]]], here: str = READ_HERE[""]) -> str: """`gitmodules-value-refused` up to its remedy, for the key `key` of the section `name` (#768): each value the file git reads here (`here`, `READ_HERE`, #1077) and HEAD's give it, where, and what git takes. - Where HEAD's alone gives one, the file git reads here may not, so it - says git dies on it in a clone, which reads HEAD's.""" + `given` holds what the file git reads here, HEAD's and the index's + give, in that order: where the work tree's is on disk, git reads that + one here, not the index's, but a plain `git commit` records the + index's (#1150). Where HEAD's alone gives one, the file git reads here + may not, so it says git dies on it in a clone, which reads HEAD's; + where the index's alone does, in a clone of the commit that records it + (`VALUE_DIES`); and where the work tree's gives it, not HEAD's, it says + that the index's gives it too where it does (`INDEX_TOO`), which is + why its lines stage it, or, where the index's gives that key another + value git dies on, names that value (`INDEX_GIVES`, rv1227's N4 on + #1227): "gives it too" said of `!false` staged beside a work tree's + `!true`.""" + in_work, in_head, in_index = given full = f"submodule.{name}.{key}" - values = dict.fromkeys([*in_work.get((name, key), []), - *in_head.get((name, key), [])]) where = (name, key) in in_work, (name, key) in in_head - dies = ("so in a clone neither `git submodule update` nor make " - "bootstrap checks out any member" if where == (False, True) - else "before `git submodule update` checks out any member, so " - "neither that nor make bootstrap checks one out") - return (f"{GITMODULES} gives `{spelled(full)}` " + " and ".join( - "no value" if value is None else f"the value `{spelled(value)}`" - for value in values) + f" {GIVEN_WHERE[where].format(here=here)}, " - "and git dies " - f"reading a file that does, {dies}. Git takes {GIT_TAKES[key]} for " - "that key.") + values = dict.fromkeys([*in_work.get((name, key), []), + *in_head.get((name, key), [])] + or in_index.get((name, key), [])) + staged_too = "" + if where == (True, False) and (name, key) in in_index: + staged = dict.fromkeys(in_index[(name, key)]) + staged_too = " " + (INDEX_TOO if set(staged) == set(values) else + INDEX_GIVES.format(values=_values_said(staged))) + return (f"{GITMODULES} gives `{spelled(full)}` {_values_said(values)} " + f"{GIVEN_WHERE[where].format(here=here)}, and git dies reading " + f"a file that does, {VALUE_DIES[where]}. Git takes " + f"{GIT_TAKES[key]} for that key.{staged_too}") + + +def _values_said(values: dict[str | None, None]) -> str: + """Each of `values`, in order, as `gitmodules-value-refused` says the + values a file gives a key (`_value_said`): the value, backticked, or + `no value` for a key with none, joined by `and`.""" + return " and ".join("no value" if value is None + else f"the value `{spelled(value)}`" + for value in values) def _value_taken_out(said: str, full: str, back: str | None, put_back: tuple[str, str] = ("", ""), *, - committed: bool, staged: bool = False) -> str: + committed: bool, staged: bool = False, + again: bool = False) -> str: """`gitmodules-value-refused` whole, `said` (`_value_said`) and its commands, one to a line, on the work tree's file (#768): the file git reads put back first where the work tree has none and no finding before put it back (`put_back`, the line and whose file it puts back, `_file_put_back`, #1077), the key `full` taken out, and `back` written in, where it is given, then the commit where HEAD's gives the value - (`committed`), or, where the work tree's was gone and the index's - alone gave it (`staged`), the file staged, which the commit records.""" + (`committed`), or, where the index's gives it, or the work tree's was + gone (`staged`), the file staged, which the commit records. EITHER + ENDS WITH THE LINE THAT STAGES IT, `git add -- .gitmodules` + (`STAGE_LINE`, #1150, RV1124's N1 on #1124): said in words alone, a + plain `git commit` after the lines typed recorded the index's file, + the value and all. So does one whose value the work tree's file alone + gives, where a finding printed above ends staging that file (`again`, + `STAGED_ABOVE`, rv1227's S1 on #1227): that line staged it as it + stood, this value with it, and a plain `git commit` after the lines + typed recorded the value in HEAD.""" key = full.rpartition(".")[2] + again = again and not (committed or staged) line, whose = put_back lines = [line] if line else [] lines.append(f"git config -f {GITMODULES} --unset-all " @@ -2119,14 +2255,16 @@ def _value_taken_out(said: str, full: str, back: str | None, if back is not None: lines.append(f"git config -f {GITMODULES} {shlex.quote(full)} " f"{back}") - return (f"{said} " + if committed or staged or again: + lines.append(STAGE_LINE) + return (f"{said} " + (f"{STAGED_ABOVE} " if again else "") + (f"The work tree holds no {GITMODULES}, so the first line " f"below puts back {whose}. " if line else "") + "Take the key out" + (f" and write its {key} back" if back is not None else "") + " by hand, in a POSIX shell" + (f", then stage and commit {GITMODULES}" if committed else - f", then stage {GITMODULES}" if staged else "") + f", then stage {GITMODULES}" if staged or again else "") + ":" + "".join(f"\n {line}" for line in lines)) @@ -2864,10 +3002,21 @@ def _check_gitlinks_elsewhere(root: Path, gitlinks: dict[str, str], 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).""" + plain clone checked it out (rv882's SHOULD 2 on #882). + + NOTHING IS ASKED WHILE A MERGE LEAVES `.gitmodules` UNMERGED in the + index (`_gitmodules_unmerged`, rv1227's M2 on #1227), as no section is + read then (`_sections_as_git_reads`): git reads no file, so its + `submodule status` and `update --init` exit 128 at every gitlink, and + which section the commit after the merge records is the merge's to + say. Read as giving no path, a gitlink HEAD's file gives, which a + plain clone of HEAD checks out (git 2.43.0, exit 0), was said to stop + a plain clone, and its `git rm --cached` then said to commit that, + which git refuses until the merge is resolved (exit 128). Resolved and + staged, the gitlink is asked about again.""" unnamed = [path for path in sorted(gitlinks) if path not in named and not _in_members(path)] - if not unnamed: + if not unnamed or _gitmodules_unmerged(root): return given = _given_as_git_reads(root) stage_first = _stage_first(root, @@ -2894,7 +3043,18 @@ def _given_as_git_reads(root: Path) -> set[str]: one git reads in its place (`_gitmodules_put_back`, the index's, then HEAD's), else none (rv882's SHOULD 2 on #882), read as `_sections_as_git_reads` reads it, so the hand mount's put-back and - `gitmodules-path-unnamed` agree on one file (#1036).""" + `gitmodules-path-unnamed` agree on one file (#1036). + + NONE WHILE A MERGE LEAVES THE FILE UNMERGED, which is what git's own + `git rm` reads then (rv1227's M2 on #1227, measured on git 2.43.0): a + plain `git rm` of a clone whose `.git` is a directory, at a path a + section gives, dies there (`could not lookup name for submodule`, exit + 128), and `git rm --cached` takes it, exit 0, so the hand mount's + `_hand_cached` says `--cached`; with the work tree's file gone, its + plain `git rm` takes the gitlink and no section, exit 0, so + `_plain_rm_said` puts nothing back, where HEAD's file put back would + resolve the merge to HEAD's side. `gitlink-unnamed`, which says what a + clone does, asks nothing then (`_check_gitlinks_elsewhere`).""" return {keys["path"] for keys in _sections_as_git_reads( root, _gitmodules_sections(root)).values() if "path" in keys} @@ -2910,13 +3070,43 @@ def _sections_as_git_reads(root: Path, worktree: dict[str, dict[str, str]] staged in the index's file, whose work tree's copy was deleted, was never named, and the commit after the hand mount's lines recorded it. Git 2.43 reads the index's there: `git submodule status ` maps - the path, which HEAD's file does not give.""" + the path, which HEAD's file does not give. + + NONE WHILE A MERGE LEAVES `.gitmodules` UNMERGED in the index + (`_gitmodules_unmerged`, #1150, RV1124's N6 on #1124), on disk or not: + git reads no file then, and `git submodule status` dies at the first + gitlink (`no submodule mapping found`, exit 128). The work tree's, or + HEAD's where that was gone, was read in its place.""" + if _gitmodules_unmerged(root): + return {} if os.path.lexists(root / GITMODULES): return worktree spec = _gitmodules_put_back(root)[0] return _blob_sections(root, spec) if spec else {} +def _gitmodules_unmerged(root: Path) -> bool: + """Whether the index holds `.gitmodules` only at a merge's conflict + stages, 1 to 3, as `git ls-files -s` lists it, and not at stage 0 + (#1150, RV1124's N6 on #1124). Git's submodule commands then read no + `.gitmodules` at all, the work tree's, the index's or HEAD's + (`repo_read_gitmodules` asks `is_gitmodules_unmerged` first), and + `git submodule status` dies at the first gitlink (`no submodule + mapping found`, exit 128, git 2.43), as bootstrap's + `gitmodules_source` reads it. An index git cannot list holds none.""" + with _git_runs(f"whether a merge left {GITMODULES} unmerged could not " + "be read"): + try: + listed = git_out(["ls-files", "-s", "-z", "--", GITMODULES], + cwd=root, binary=True) + except Refusal: + return False + stages = {meta.split()[2] for meta, _, name in ( + record.partition(b"\t") for record in listed.split(b"\0")) + if name == GITMODULES.encode() and len(meta.split()) == 3} + return bool(stages) and b"0" not in stages + + #: Why a plain `git rm` of a hand mount's gitlink refuses it where the #: index stages it at a commit HEAD does not record there, with something #: on disk at it (`_staged_over`): git's two refusals, each exit 1, the @@ -3841,29 +4031,73 @@ def _keys_back(path: str, sections: dict[str, dict[str, str]], 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).""" + `_gitmodules_sections` reads the work tree's (#879): none for no + `spec`, and `gitmodules-unreadable` where git cannot parse it + (`_blob_listed`, #1150; rv1227's N1 on #1227).""" return {keys["path"] for keys in _blob_sections(root, spec).values() if "path" in keys} def _blob_sections(root: Path, spec: str) -> dict[str, dict[str, str]]: """Every `submodule` section of the `.gitmodules` git names `spec`, - `{name: {key: value}}`, read as `_blob_paths` reads its paths, and none - where git cannot read it (#879, #1036).""" + `{name: {key: value}}`, read as `_blob_paths` reads its paths (#879, + #1036): none for no `spec`, and `gitmodules-unreadable` where git + cannot parse it (`_blob_listed`, #1150; rv1227's N1 on #1227).""" return _sections_listed(_blob_listed(root, spec)) def _blob_listed(root: Path, spec: str) -> bytes: """What `git config -z --blob --list` prints of the - `.gitmodules` git names `spec`, or nothing where git cannot read it - (`_blob_sections`, `_listed_as_git_reads`, #1077).""" + `.gitmodules` git names `spec`, one git resolves, or nothing for no + `spec` (`_blob_sections`, `_listed_as_git_reads`, #1077). + + ONE GIT CANNOT PARSE IS `gitmodules-unreadable` (#1150, RV1124's N2 on + #1124), as HEAD's is (`_head_listed`), which `_check_gitmodules_values` + asks before any blob is read here, so the blob refused here is the + index's. `git config --blob` prints the entries before a line it + cannot parse, then exits 128, and nothing was read: an `update = + !true` before the line was no finding, `family ok` in every mode, while + git's own `submodule status` read it and died on it (exit 128), and a + section before it was never named. Git's submodule commands read such + a blob up to the line where the work tree holds no `.gitmodules`, and + a clone of a commit that records it checks the file out, which git + dies reading.""" + if not spec: + return b"" with _git_runs(f"{spec} could not be read as git reads it"): try: return git_out(["config", "-z", "--blob", spec, "--list"], cwd=root, binary=True) + except Refusal as exc: + raise Refusal( + "gitmodules-unreadable", + f"the index's {GITMODULES} could not be read as git reads " + f"it: {exc.detail}", + "Remediation: git reads it only up to that line where the " + f"work tree holds no {GITMODULES}, and a clone of a commit " + "that records it dies reading it, before it checks out any " + f"member. Mend {GITMODULES} in the work tree by hand (`git " + f"config -f {GITMODULES} --list` says where it breaks; where " + f"the work tree holds none, `git checkout -- {GITMODULES}` " + f"puts the index's back first), stage it with `{STAGE_LINE}`, " + "then run this again.") from exc + + +def _index_listed(root: Path) -> bytes: + """What `git config -z --list` prints of the index's `.gitmodules`, + the one at stage 0, which a plain `git commit` records, read as + `_blob_listed` reads a blob, or nothing where the index holds none + there (#1150, RV1124's N1 on #1124): what `gitmodules-value-refused` + reads beside the work tree's file where that is on disk, and git reads + it here, not the index's.""" + with _git_runs(f"the index's {GITMODULES} could not be read as git " + "reads it"): + try: + git_out(["rev-parse", "--verify", "--quiet", f":{GITMODULES}"], + cwd=root) except Refusal: return b"" + return _blob_listed(root, f":{GITMODULES}") def _listed_as_git_reads(root: Path) -> tuple[bytes, str]: @@ -4051,6 +4285,16 @@ def _init_stop_said(root: Path, sections: dict[str, dict[str, str]], merge left unmerged) is not read here; where nothing is found the plain remedy stands. + NOTHING IS ASKED WHILE A MERGE LEAVES `.gitmodules` UNMERGED + (`_gitmodules_unmerged`, rv1227's M2 on #1227): git's init reads no + file then and dies at the first gitlink it asks about (measured, git + 2.43.0: `No url found for submodule path 'members/IRRS'`, exit 128), + whatever a section gives, and bootstrap names the cure for that, the + merge resolved and added. Asked of the sections read then, which are + none (`_sections_as_git_reads`), it named the first row's gitlink, and + its cure said to write back a section the work tree's file and HEAD's + both gave. + THE `.gitmodules` GIT READS is the work tree's (`sections`) where it is on disk, and where it is gone from disk the index's, else HEAD's (`_gitmodules_put_back`, rv820's SHOULD 2): read from the work tree @@ -4064,6 +4308,8 @@ def _init_stop_said(root: Path, sections: dict[str, dict[str, str]], THE CURE IS BOOTSTRAP'S FOR THE SAME GITLINK (`cures`), word for word but for the path, printed as this file prints one (`_init_stop_cure`). """ + if _gitmodules_unmerged(root): + return "" spec, put_back, whose = _gitmodules_put_back(root) with _git_runs("the gitlinks git submodule update asks could not be " "listed"): diff --git a/tests/test_family_gitmodules_values.py b/tests/test_family_gitmodules_values.py index fc89631a..e2c9ffef 100644 --- a/tests/test_family_gitmodules_values.py +++ b/tests/test_family_gitmodules_values.py @@ -99,7 +99,7 @@ from test_family_member_path import MEMBER, OTHER, raw from test_family_tracking_branch import on_main # noqa: F401 from test_validate_family_typed_states import GONE, ROW, removal_staged -from test_validate_family_unnamed_mounts import commands, findings +from test_validate_family_unnamed_mounts import STAGE, commands, findings #: The issue's table, `update = None` first, then `branch` with no value: #: each a line added to OTHER's section after its url, as rv648 (p11) and @@ -190,12 +190,17 @@ def taken_out(key: str, value: str | None, *, back: str | None = None, out of the work tree's file, after the one that puts back the file git reads in its place where the work tree's is gone as `put_back` (`GONE`) says, the index's, or HEAD's where the index holds none - (#1077), and write `back` in where it is given.""" + (#1077), and write `back` in where it is given, then, where HEAD's + gives the value, the line that stages the file, which the commit + records (`STAGE`, #1150): said in words alone, a plain `git commit` + after the lines recorded the value the index's file still gave.""" whose, line = GONE[put_back] if put_back else ("", "") lines = ([line] if put_back else []) + [ f"git config -f .gitmodules --unset-all {full(key)}"] if back is not None: lines.append(f"git config -f .gitmodules {full(key)} {back}") + if where != WORK_TREE_ONLY: + lines.append(STAGE) return (said(key, value, where) + ("The work tree holds no .gitmodules, so the first line below " f"puts back {whose}. " if put_back else "") @@ -794,6 +799,10 @@ def test_a_bare_key_in_a_blob_is_read_up_to_and_named_in_the_cure( #: A line of the person's own, staged with the line git cannot parse. OWN_LINE = b"# a line of the person's own, staged with it\n" +#: How the gate refuses an index's `.gitmodules` git cannot parse (#1150). +INDEX_UNREADABLE = ("REFUSED gitmodules-unreadable: the index's .gitmodules " + "could not be read as git reads it: ") + @pytest.mark.parametrize("place", ("inside-a-section", "before-the-first-section")) @@ -803,12 +812,13 @@ def test_the_index_s_file_cut_short_is_put_back_from_the_index( whole: the line inside OTHER's section, or first in the file (RV877's SHOULD-2), staged with a line of the person's own, not committed, and the work tree's file lost. Git reads the index's up to the line, the - first one reading nothing, and dies at OTHER or MEMBER. The gate - reads the work tree's file and HEAD's, not the index's, so it cannot - name the line, and HEAD's file put back would lose what was staged. - The cure puts the index's back, its line first included: typed as - printed, mended and committed, the person's own line is in HEAD, and - the run is the credential's skip.""" + first one reading nothing, and dies at OTHER or MEMBER. HEAD's file + put back would lose what was staged. The cure puts the index's back, + its line first included: typed as printed, mended and committed, the + person's own line is in HEAD, and the run is the credential's skip. + The gate read the index's file as nothing there, so it never named the + line; it refuses that file `gitmodules-unreadable` now, naming the + line, as it refuses HEAD's (#1150).""" env = isolated(offline, tmp_path) clone = cloned(holder, env, family, tmp_path, recurse=False) staged = raw(clone, env, "show", f"HEAD:{GITMODULES}").stdout + OWN_LINE @@ -817,8 +827,9 @@ def test_the_index_s_file_cut_short_is_put_back_from_the_index( lose_work_tree_file(clone, env) git_reads_up_to_the_line(clone, env, place) gate = validate(clone, "--no-members") - assert "bad config line" not in gate.stdout + gate.stderr, ( - "premise: the gate cannot name the line", gate.stdout + gate.stderr) + assert gate.returncode == 2, gate.stdout + gate.stderr + assert gate.stderr.startswith(INDEX_UNREADABLE), gate.stderr + assert "bad config line" in gate.stderr, gate.stderr result = bootstrap(clone, env, "--skip-members") @@ -1247,7 +1258,8 @@ def edited_not_committed(root, env, data: bytes) -> None: WHERE = { "head-only": (mended_not_committed, said("update", "None", HEAD_ONLY) + "Stage and commit the " - "work tree's .gitmodules, which does not give it."), + "work tree's .gitmodules, which does not give it:" + f"\n {STAGE}"), "work-tree-only": (edited_not_committed, taken_out("update", "None", where=WORK_TREE_ONLY)), } @@ -1258,9 +1270,10 @@ def test_the_gate_says_which_file_gives_it(holder, offline, tmp_path, set_up, expected): """In the holder, members checked out: HEAD's file alone gives the value, the work tree's mended and not committed, and the finding says - to commit that, printing no command; or the work tree's alone, which - git reads here and a clone does not, and the command takes it out with - nothing to commit. Done as it says, the holder passes in every mode.""" + to stage and commit that, its one line staging it (#1150); or the work + tree's alone, which git reads here and a clone does not, and the + command takes it out with nothing to commit. Done as it says, the + holder passes in every mode.""" env = isolated(offline, tmp_path) set_up(holder, env, raw(holder, env, "show", f"HEAD:{GITMODULES}").stdout) diff --git a/tests/test_validate_family_index_gitmodules.py b/tests/test_validate_family_index_gitmodules.py index 86606003..7493c30d 100644 --- a/tests/test_validate_family_index_gitmodules.py +++ b/tests/test_validate_family_index_gitmodules.py @@ -219,10 +219,10 @@ def test_1077_a_value_the_index_alone_gives_is_named_and_taken_out( Git reads the index's file and dies on it (`git submodule status`, exit 128). Main read the work tree's file and HEAD's and said `family ok`. It is `gitmodules-value-refused` now, in every mode, naming the - index's file, and its lines put that file back, not HEAD's, and take - the key out; the text says to stage the file. Typed, staged as it - says and committed: nothing is left, and the gate and a plain clone - pass.""" + index's file, and its lines put that file back, not HEAD's, take the + key out, and stage the file, the last line as the text says (#1150). + Typed, then a plain `git commit`: nothing is left, and the gate and a + plain clone pass.""" env = cloning(offline, tmp_path, family) staged_only(holder, env, valued(head_bytes(holder, env), ROW, b"\tupdate = !true\n")) @@ -237,9 +237,9 @@ def test_1077_a_value_the_index_alone_gives_is_named_and_taken_out( + "The work tree holds no .gitmodules, so the first line below puts " "back the index's. Take the key out by hand, in a POSIX shell, then " "stage .gitmodules:\n git checkout -- .gitmodules\n git " - f"config -f .gitmodules --unset-all submodule.{ROW}.update"] + f"config -f .gitmodules --unset-all submodule.{ROW}.update\n " + f"{STAGE}"] typed_in_order(holder, env, validate(holder, "--no-members")) - raw(holder, env, "add", "--", GITMODULES) committed_as_staged(holder, env) assert raw(holder, env, "status", "--porcelain").stdout == b"" ends_well(holder, env, family, tmp_path) @@ -284,8 +284,11 @@ def test_1077_a_value_head_gives_where_the_deletion_is_staged_puts_head_s_back( reads`, and its first line puts back HEAD's file, as the index holds none. A check that read the index's file alone, with no fallback to HEAD's, found nothing in it and said to commit the index's file, with - no line, over a deletion a plain `git commit` records. Typed in order, - staged and committed: HEAD's file holds no value, both members stay, + no line, over a deletion a plain `git commit` records. Its last line + stages the file, as the text says (#1150): typed without it, the + plain `git commit` after them exits 1 (`no changes added to commit`) + and HEAD keeps the value (rv1227's M1 on #1227). Typed in order, then + a plain `git commit`: HEAD's file holds no value, both members stay, nothing is left, and the gate and a plain clone pass.""" env = cloning(offline, tmp_path, family) committed_gitmodules(holder, env, valued( @@ -303,9 +306,8 @@ def test_1077_a_value_head_gives_where_the_deletion_is_staged_puts_head_s_back( "back HEAD's, as the index holds none. Take the key out by hand, in " "a POSIX shell, then stage and commit .gitmodules:\n git " "checkout HEAD -- .gitmodules\n git config -f .gitmodules " - f"--unset-all submodule.{ROW}.update"] + f"--unset-all submodule.{ROW}.update\n {STAGE}"] typed_in_order(holder, env, validate(holder, "--no-members")) - raw(holder, env, "add", "--", GITMODULES) committed_as_staged(holder, env) assert b"!true" not in head_bytes(holder, env) assert head_paths(holder, env) == {ROW, OTHER_ROW} @@ -355,9 +357,9 @@ def test_1077_a_url_git_dies_on_is_written_back_as_the_file_git_reads_gave_it( MEMBER's section a `url` that is not HEAD's, then a `url` with no value, which git dies on. The line that writes the url back, as it last gave one, takes it from the file git reads (the index's), not - HEAD's: from HEAD's it undid what was staged. Typed in order, then - staged and committed: HEAD gives the staged url, and the gate passes - in `--no-members`.""" + HEAD's: from HEAD's it undid what was staged. Its last line stages + the file (#1150). Typed in order, then a plain `git commit`: HEAD + gives the staged url, and the gate passes in `--no-members`.""" env = cloning(offline, tmp_path, family) data = head_bytes(holder, env) url = "https://github.com/InkRouter/Staged.git" @@ -375,10 +377,9 @@ def test_1077_a_url_git_dies_on_is_written_back_as_the_file_git_reads_gave_it( assert codes_of(found) == ["gitmodules-value-refused"], found lines = commands(found[0]) assert lines[0] == "git checkout -- .gitmodules", lines - assert lines[-1] == (f"git config -f .gitmodules submodule.{ROW}.url " - f"{url}"), lines + assert lines[-2:] == [f"git config -f .gitmodules submodule.{ROW}.url " + f"{url}", STAGE], lines typed_in_order(holder, env, validate(holder, "--no-members")) - raw(holder, env, "add", "--", GITMODULES) committed_as_staged(holder, env) assert url.encode() in head_bytes(holder, env) assert raw(holder, env, "status", "--porcelain").stdout == b"" diff --git a/tests/test_validate_family_index_value_unmerged.py b/tests/test_validate_family_index_value_unmerged.py new file mode 100644 index 00000000..30d812aa --- /dev/null +++ b/tests/test_validate_family_index_value_unmerged.py @@ -0,0 +1,694 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The family holder's pinned `scripts/validate-family.py`: the check for +values git dies on reads the index's `.gitmodules` beside the work tree's, +an index's file git cannot parse is refused, and no file is read while a +merge leaves `.gitmodules` unmerged (#1150). + +RV1124's review of #1124 (pull/1124#issuecomment-6321372469) found three +things, each older than #1124, and each measured again on #1124's head +8dfa32bd and on main 961640c6 before this change: + +- N1. Where the work tree's file is on disk, the check never read the + index's, which a plain `git commit` records. An `update = !true` given + in the work tree's file and staged was taken out of the work tree's by + a line that staged nothing, so a plain `git commit` after it recorded + the value in HEAD; one given in the index's file alone, the work tree's + clean on disk, was `family ok`, exit 0, in every mode. +- N2. An index's file with a line git cannot parse was read as nothing, + since `git config --blob` exits 128 there: an `update = !true` before + the line was `family ok` while git's own `submodule status` died on it + (exit 128), and a section before the line was never named. +- N6. While a merge leaves `.gitmodules` unmerged in the index, git reads + no file at all, and the checks read the work tree's, or HEAD's where + that was gone: a value HEAD's gave was "here and in HEAD", its line put + HEAD's back "as the index holds none", and a section HEAD's gave was + named with that same line, which resolves the merge to HEAD's side. A + value the work tree's file alone gives, on disk, while the merge is + unresolved, is no finding now, as git reads no file; once the merge is + resolved and staged, it is named as N1's is. + +rv1227's review of this change (#1227, issuecomment-6374575208) found +more, each measured on its head b031f493 and on main e04826db before the +fix round: + +- M2. While a merge leaves `.gitmodules` unmerged, `gitlink-unnamed` read + no section, so a gitlink outside `members/` that HEAD's file gives, which + a plain clone of HEAD checks out, was said to stop a plain clone; and a + member not checked out was told to write back a section the file gave. + Neither asks anything while the merge is unresolved now. +- S1. After a finding whose lines stage the work tree's file, one whose + value the work tree's file alone gives ended at its `--unset-all`: the + `git add` above had staged that value, and a plain `git commit` after + the lines recorded it in HEAD. It ends staging the file again now. +- N2, N3, N4. An index's file git cannot parse beside a work tree's file + on disk, a real modify/delete conflict and the sections with the work + tree's file on disk while unmerged had no row; and where the index's + file gives the key another value, the finding said it "gives it too". + +Every row types only the printed `git` lines, in order, and does by hand +only what the text says in words, then commits what is staged with a +plain `git commit`, as `tests/test_validate_family_index_gitmodules.py` +does: HEAD's `.gitmodules` is then right, the gate passes in every mode, +and a plain clone checks both members out. +""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path + +import pytest + +from test_family import NAME, family, validate # noqa: F401 +from test_family_add_refusals import holder, offline # noqa: F401 +from test_family_bootstrap_gate_order import ( + bootstrap, cloned, type_line, without_url, +) +from test_family_head_lost_gitmodules import committed_gitmodules +from test_family_holder_remedies_compose import ( + MODES, OTHER_ROW, cloning, codes_of, ends_well, found_alike, head_paths, + typed_in_order, +) +from test_family_member_path import MEMBER, raw +from test_family_unnamed_gitlink import at_tools +from test_validate_family_index_gitmodules import ( + DIES_HERE, DIES_IN_A_CLONE, STALE, VALUE_SAID, committed_as_staged, + head_bytes, staged_only, valued, +) +from test_validate_family_typed_states import ROW, UNINITIALIZED, UPDATE +from test_validate_family_unnamed_mounts import STAGE, commands + +GITMODULES = ".gitmodules" +NOT_RUN = b"\tupdate = !true\n" + +#: What the value's finding adds where the work tree's file gives it, not +#: HEAD's, and the index's does too. +INDEX_TOO = ("The index's file, which a plain `git commit` records, gives " + "it too.") + +#: What the value's finding says where the work tree's file and the +#: index's give it, not HEAD's: the key taken out, then the file staged. +INDEX_TOO_TAKEN_OUT = ( + VALUE_SAID.format(where="in the work tree, not in HEAD", dies=DIES_HERE) + + INDEX_TOO + " Take the key out by hand, in a POSIX shell, then stage " + ".gitmodules:\n git config -f .gitmodules --unset-all " + f"submodule.{ROW}.update\n {STAGE}") + +#: How git dies on a value the index's file alone gives. +DIES_IN_THE_COMMIT = ("so in a clone of the commit that records it neither " + "`git submodule update` nor make bootstrap checks out " + "any member") + +#: A line git cannot parse, as `tests/test_family_gitmodules_values.py`'s +#: RV1124 probe P9 has it. +UNPARSED = b"this is no config line\n" + + +def a_plain_clone_of_what_is_staged(root: Path, env: dict, family, + tmp_path: Path): + """A copy of `root` whose staged `.gitmodules` a plain `git commit` + records, then a plain clone of that commit and its `git submodule + update --init --recursive`, whose result is returned: what the text + says a clone of the commit that records the file does.""" + copy = tmp_path / "staged-copy" / NAME + shutil.copytree(root, copy, symlinks=True) + raw(copy, env, "commit", "-q", "-m", "What is staged, committed") + where = tmp_path / "staged-clone" + where.mkdir() + clone = cloned(copy, env, family, where, recurse=False) + return raw(clone, env, "submodule", "update", "--init", "--recursive", + check=False) + + +# --- N1: the index's file beside the work tree's on disk --------------------- + +def test_a_value_the_work_tree_s_file_and_the_index_s_give_ends_staging_it( + family, holder, offline, tmp_path): + """RV1124's P1: `update = !true` in MEMBER's section, in the work + tree's file on disk and staged; HEAD's does not give it. The line + took the key out of the work tree's file and staged nothing, so a + plain `git commit` after it recorded the value in HEAD, and a plain + clone's update died on it. The text says the index's file gives it + too, and the lines end staging the file. Typed, then a plain `git + commit`: HEAD is as it was, and the gate and a plain clone pass.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + (holder / GITMODULES).write_bytes(valued(data, ROW, NOT_RUN)) + raw(holder, env, "add", "--", GITMODULES) + + found = found_alike(holder, *MODES) + + assert found == [INDEX_TOO_TAKEN_OUT] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +def test_a_value_the_index_s_file_alone_gives_is_named_beside_a_clean_one( + family, holder, offline, tmp_path): + """RV1124's P1b: `update = !true` in MEMBER's section of the index's + file alone; the work tree's, on disk, and HEAD's do not give it. Git + reads the work tree's here, and `submodule status` exits 0, but a + plain `git commit` records the index's, and a plain clone of that + commit dies on it (exit 128). The gate said `family ok` in every + mode. It is `gitmodules-value-refused` now, naming the index's file + and the clone of the commit that records it, and its line stages the + work tree's file. Typed, then a plain `git commit`: HEAD is as it was, + and the gate and a plain clone pass.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + staged_only(holder, env, valued(data, ROW, NOT_RUN)) + (holder / GITMODULES).write_bytes(data) + raw(holder, env, "submodule", "status") + died = a_plain_clone_of_what_is_staged(holder, env, family, tmp_path) + assert died.returncode == 128, ("premise: a clone of the commit dies", + died.stderr) + + found = found_alike(holder, *MODES) + + assert found == [ + VALUE_SAID.format(where="in the index's file alone, which a plain " + "`git commit` records, not in the work tree or in " + "HEAD", dies=DIES_IN_THE_COMMIT) + + "Stage the work tree's .gitmodules, which does not give it:" + f"\n {STAGE}"] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +def test_a_value_here_and_in_head_ends_staging_it_with_no_index_s_file( + family, holder, offline, tmp_path): + """`update = !true` committed in MEMBER's section, the work tree's + file on disk giving it too, and `git rm --cached` has taken + `.gitmodules` out of the index: git reads the work tree's here, and + the index's, which a plain `git commit` records, gives nothing. HEAD's + giving the value is what makes the lines end staging the work tree's + file, not the index's: with no stage line, a plain `git commit` after + them records `.gitmodules` gone from HEAD. Typed, then a plain `git + commit`: HEAD gives no value, and the gate and a plain clone pass.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + committed_gitmodules(holder, env, valued(data, ROW, NOT_RUN)) + raw(holder, env, "rm", "-q", "--cached", "--", GITMODULES) + + found = found_alike(holder, *MODES) + + assert found == [ + VALUE_SAID.format(where="here and in HEAD, which a clone reads", + dies=DIES_HERE) + + "Take the key out by hand, in a POSIX shell, then stage and " + "commit .gitmodules:\n git config -f .gitmodules --unset-all " + f"submodule.{ROW}.update\n {STAGE}"] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +#: A second value git dies on, `update = !false`. +NOT_RUN_EITHER = b"\tupdate = !false\n" + +#: What the value's finding adds where a line printed above stages the work +#: tree's file, the value with it (rv1227's S1 on #1227). +STAGED_ABOVE = ("A line above stages the work tree's .gitmodules, this value " + "with it, so the lines below end staging it again.") + +#: What the first finding says, by how MEMBER's value was given: staged in +#: the index's file, or committed. +FIRST = { + "staged": INDEX_TOO_TAKEN_OUT, + "committed": ( + VALUE_SAID.format(where="here and in HEAD, which a clone reads", + dies=DIES_HERE) + + "Take the key out by hand, in a POSIX shell, then stage and " + "commit .gitmodules:\n git config -f .gitmodules --unset-all " + f"submodule.{ROW}.update\n {STAGE}"), +} + + +@pytest.mark.parametrize("first", FIRST) +def test_a_value_the_work_tree_alone_gives_after_a_staging_one_stages_again( + family, holder, offline, tmp_path, first): + """rv1227's S1 on #1227, its e4o1 and e4o2: `update = !true` in + MEMBER's section, staged in the index's file or committed, then + `update = !false` in OTHER's, given in the work tree's file alone. + The first finding's lines end `git add -- .gitmodules`, which stages + the work tree's file as it stands, OTHER's value with it, and the + second's ended at its `--unset-all`: typed in order, then a plain `git + commit`, HEAD gave OTHER `!false`, and the next run named it in HEAD. + The second ends staging the file again now, and says why. Typed, then + a plain `git commit`: HEAD gives neither value, and the gate and a + plain clone pass.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + given = valued(data, ROW, NOT_RUN) + if first == "committed": + committed_gitmodules(holder, env, given) + else: + (holder / GITMODULES).write_bytes(given) + raw(holder, env, "add", "--", GITMODULES) + (holder / GITMODULES).write_bytes(valued(given, OTHER_ROW, + NOT_RUN_EITHER)) + + found = found_alike(holder, *MODES) + + assert found == [ + FIRST[first], + "FINDING gitmodules-value-refused: .gitmodules gives " + f"`submodule.{OTHER_ROW}.update` the value `!false` in the work " + f"tree, not in HEAD, and git dies reading a file that does, " + f"{DIES_HERE}. Git takes one of `none`, `checkout`, `rebase` or " + f"`merge` for that key. {STAGED_ABOVE} Take the key out by hand, in " + "a POSIX shell, then stage .gitmodules:\n git config -f " + f".gitmodules --unset-all submodule.{OTHER_ROW}.update\n {STAGE}"] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +def test_another_value_the_index_s_file_gives_the_key_is_named( + family, holder, offline, tmp_path): + """rv1227's N4 on #1227, its e4c: the index's file gives MEMBER's + section `update = !false`, the work tree's, on disk, `update = !true`, + and HEAD's neither. The finding said the index's file "gives it too", + of a value it does not give. It names the index's value now. Typed, + then a plain `git commit`: HEAD is as it was, and the gate and a plain + clone pass.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + (holder / GITMODULES).write_bytes(valued(data, ROW, NOT_RUN_EITHER)) + raw(holder, env, "add", "--", GITMODULES) + (holder / GITMODULES).write_bytes(valued(data, ROW, NOT_RUN)) + + found = found_alike(holder, *MODES) + + assert found == [ + VALUE_SAID.format(where="in the work tree, not in HEAD", + dies=DIES_HERE) + + "The index's file, which a plain `git commit` records, gives it " + "the value `!false`. Take the key out by hand, in a POSIX shell, " + "then stage .gitmodules:\n git config -f .gitmodules " + f"--unset-all submodule.{ROW}.update\n {STAGE}"] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +# --- N2: an index's file git cannot parse ------------------------------------ + +#: What comes before the line git cannot parse in the index's file, the +#: work tree's deleted: `(the edit of HEAD's bytes, what git's own +#: `submodule status` exits, a word of what it says, the code the gate +#: finds once the line is mended)`. Before a value, git dies on the value +#: (RV1124's P9); after a section, it reads up to the line, says it, and +#: goes on. +BEFORE_THE_LINE = { + "a-value": (lambda data: valued(data, ROW, NOT_RUN), 128, + b"invalid value", "gitmodules-value-refused"), + "a-section": (lambda data: data + STALE, 0, b"bad config line", + "gitmodules-path-unnamed"), +} + +#: What the gate says of the index's file git cannot parse. +UNREADABLE_LEAD = ("REFUSED gitmodules-unreadable: the index's .gitmodules " + "could not be read as git reads it: `git config -z " + "--blob :.gitmodules --list` in ") +UNREADABLE_REMEDY = ( + "Remediation: git reads it only up to that line where the work tree " + "holds no .gitmodules, and a clone of a commit that records it dies " + "reading it, before it checks out any member. Mend .gitmodules in the " + "work tree by hand (`git config -f .gitmodules --list` says where it " + "breaks; where the work tree holds none, `git checkout -- .gitmodules` " + "puts the index's back first), stage it with `git add -- .gitmodules`, " + "then run this again.") + + +@pytest.mark.parametrize("edit, status, said, then", + BEFORE_THE_LINE.values(), ids=BEFORE_THE_LINE) +def test_an_index_s_file_git_cannot_parse_is_refused_unreadable( + family, holder, offline, tmp_path, edit, status, said, then): + """RV1124's P9: the index's file, the work tree's deleted, is HEAD's + with `update = !true` in MEMBER's section, or a stale section, and + then a line git cannot parse. `git config --blob` printed the entries + before the line and exited 128, and the gate read nothing: `family + ok` in every mode, while git's own `submodule status` died on the + value, or read the section. It is `gitmodules-unreadable` now, exit 2, + as HEAD's file is, and a clone of a commit that records the file dies + reading it, as the remediation says. Done as it says, the line taken + out by hand: the gate finds what the file gave before it, and that + finding's lines, typed, then a plain `git commit`, leave HEAD as it + was, and the gate and a plain clone pass.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + staged_only(holder, env, edit(data) + UNPARSED) + git_said = raw(holder, env, "submodule", "status", check=False) + assert git_said.returncode == status, git_said.stderr + assert said in git_said.stderr, git_said.stderr + died = a_plain_clone_of_what_is_staged(holder, env, family, tmp_path) + assert died.returncode == 128, ("premise: a clone of the commit dies", + died.stderr) + + for extra in MODES: + gate = validate(holder, *extra) + assert gate.returncode == 2, (extra, gate.stdout + gate.stderr) + lead, remedy = gate.stderr.splitlines() + assert lead.startswith(UNREADABLE_LEAD), lead + assert "bad config line" in lead, lead + assert remedy == UNREADABLE_REMEDY, remedy + type_line(holder, env, re.search(r"`(git checkout [^`]*)`", + UNREADABLE_REMEDY).group(1)) + put_back = (holder / GITMODULES).read_bytes() + assert put_back.endswith(UNPARSED), put_back + (holder / GITMODULES).write_bytes(put_back[:-len(UNPARSED)]) + type_line(holder, env, re.search(r"`(git add [^`]*)`", + UNREADABLE_REMEDY).group(1)) + assert codes_of(found_alike(holder, *MODES)) == [then] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +def test_an_index_s_file_git_cannot_parse_beside_a_clean_one_is_refused( + family, holder, offline, tmp_path): + """rv1227's N2 on #1227, its e1b: the index's file is HEAD's and then + a line git cannot parse; the work tree's, on disk, is HEAD's. Git + reads the work tree's here, and its `submodule status` exits 0, but a + plain `git commit` records the index's, and a plain clone of that + commit dies reading it (exit 128). The gate said `family ok`. It is + `gitmodules-unreadable`, exit 2, in every mode. Done as it says, the + work tree's file, which git parses, staged with its `git add`: the + gate passes in every mode, and a plain `git commit` after it leaves + HEAD as it was.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + staged_only(holder, env, data + UNPARSED) + (holder / GITMODULES).write_bytes(data) + raw(holder, env, "submodule", "status") + died = a_plain_clone_of_what_is_staged(holder, env, family, tmp_path) + assert died.returncode == 128, ("premise: a clone of the commit dies", + died.stderr) + + for extra in MODES: + gate = validate(holder, *extra) + assert gate.returncode == 2, (extra, gate.stdout + gate.stderr) + lead, remedy = gate.stderr.splitlines() + assert lead.startswith(UNREADABLE_LEAD), lead + assert "bad config line" in lead, lead + assert remedy == UNREADABLE_REMEDY, remedy + type_line(holder, env, re.search(r"`(git add [^`]*)`", + UNREADABLE_REMEDY).group(1)) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +# --- N6: an unmerged .gitmodules --------------------------------------------- + +def unmerged(root: Path, env: dict, base: bytes, ours: bytes, + theirs: bytes) -> None: + """`.gitmodules` held at a merge's conflict stages 1 to 3, `base`, + `ours` and `theirs`, and not at stage 0, as a conflicted merge leaves + it, by index records written as BYTES on stdin: first the zero-mode + record that drops the stage-0 entry, as + `tests/test_validate_family_unnamed_mounts.py`'s + `stage_unmerged_gitlink` does it.""" + def blob(data: bytes) -> str: + return raw(root, env, "hash-object", "-w", "--stdin", + stdin=data).stdout.decode("ascii").strip() + + records = (f"0 {'0' * 40}\t{GITMODULES}\n" + f"100644 {blob(base)} 1\t{GITMODULES}\n" + f"100644 {blob(ours)} 2\t{GITMODULES}\n" + f"100644 {blob(theirs)} 3\t{GITMODULES}\n") + raw(root, env, "update-index", "--index-info", stdin=records.encode()) + staged = raw(root, env, "ls-files", "-s", "--", GITMODULES).stdout + assert [line.split()[2] for line in staged.splitlines()] == [ + b"1", b"2", b"3"], staged + + +def git_reads_none(root: Path, env: dict) -> None: + """Git's `submodule status` in `root` reads no `.gitmodules` and dies + at the first gitlink (exit 128).""" + said = raw(root, env, "submodule", "status", check=False) + assert said.returncode == 128, said.stderr + assert b"no submodule mapping found" in said.stderr, said.stderr + + +#: How the work tree's file is left beside the unmerged index: deleted, or +#: on disk as the merge left it, HEAD's side, which gives the value. +LEFT = ["deleted", "on-disk"] + + +@pytest.mark.parametrize("left", LEFT) +def test_a_value_head_gives_while_a_merge_leaves_the_file_unmerged( + family, holder, offline, tmp_path, left): + """RV1124's P6: `update = !true` committed in MEMBER's section, then + `.gitmodules` at a merge's conflict stages, HEAD's side giving the + value and the other mending it, with the work tree's file deleted, or + on disk as HEAD's side. Git reads no file here, and its `submodule + status` dies at the first gitlink. The gate read HEAD's, or the work + tree's: "here and in HEAD", with a line that put HEAD's back "as the + index holds none", or "here" with no word of the merge. It names + HEAD's, which a clone reads, says git reads none here, and says to + resolve the merge without the key and stage it. Resolved by hand as + it says, the line typed, then a plain `git commit`: HEAD gives no + value, and the gate and a plain clone pass.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + given = valued(data, ROW, NOT_RUN) + committed_gitmodules(holder, env, given) + unmerged(holder, env, data, given, data) + if left == "deleted": + (holder / GITMODULES).unlink() + git_reads_none(holder, env) + + found = found_alike(holder, *MODES) + + assert found == [ + VALUE_SAID.format(where="in HEAD, which a clone reads, not in the " + "file git reads here, which is none while a merge " + "leaves .gitmodules unmerged", dies=DIES_IN_A_CLONE) + + "Resolve the merge in .gitmodules by hand, leaving that key out, " + f"then stage and commit .gitmodules:\n {STAGE}"] + (holder / GITMODULES).write_bytes(data) + for line in commands(found[0]): + type_line(holder, env, line) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +def test_a_value_the_work_tree_alone_gives_while_unmerged_waits_for_git( + family, holder, offline, tmp_path): + """RV1124's P6b, the one change of finding this makes: `.gitmodules` + at a merge's conflict stages, the other side giving `update = !true` + in MEMBER's section, and the work tree's file on disk giving it, as a + merge resolved by hand to that side and not yet staged leaves it; + HEAD's does not give it. Git reads no file here, and its `submodule + status` dies at the first gitlink, not on the value. The gate read the + work tree's file and named the value "in the work tree, not in HEAD"; + it reads none while the merge is unresolved now, and every mode + passes, since no file git reads gives the value. Once the merge is + resolved with a plain `git add`, git reads the work tree's file, and + the gate names the value as N1's: its lines typed, then a plain `git + commit`, HEAD is as it was, and the gate and a plain clone pass.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + given = valued(data, ROW, NOT_RUN) + unmerged(holder, env, data, data, given) + (holder / GITMODULES).write_bytes(given) + git_reads_none(holder, env) + + for extra in MODES: + gate = validate(holder, *extra) + assert gate.returncode == 0, (extra, gate.stdout + gate.stderr) + raw(holder, env, "add", "--", GITMODULES) + assert found_alike(holder, *MODES) == [INDEX_TOO_TAKEN_OUT] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +@pytest.mark.parametrize("left", LEFT) +def test_no_section_is_read_while_a_merge_leaves_the_file_unmerged( + family, holder, offline, tmp_path, left): + """RV1124's N6, its sections: a stale section for `members/Old` + committed, then `.gitmodules` at a merge's conflict stages, HEAD's + side keeping it and the other taking it out, the work tree's deleted, + or on disk as HEAD's side (rv1227's N3 on #1227, its e2f). Git reads + no file here. `gitmodules-path-unnamed` read HEAD's, or the work + tree's, and named the section, where the file was deleted with `git + checkout HEAD -- .gitmodules` first, put back "as the index holds + none", which resolves the merge to HEAD's side. No section is read + here now, and no line resolves the merge. + Resolved by hand to HEAD's side and staged, git reads it, and the + section is named; its lines typed, then a plain `git commit`: HEAD + gives the two members alone, and the gate and a plain clone pass.""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + committed_gitmodules(holder, env, data + STALE) + unmerged(holder, env, data + STALE, data + STALE, data) + if left == "deleted": + (holder / GITMODULES).unlink() + git_reads_none(holder, env) + + for extra in MODES: + gate = validate(holder, *extra) + assert gate.returncode == 0, (extra, gate.stdout + gate.stderr) + assert "git checkout HEAD" not in gate.stderr, gate.stderr + (holder / GITMODULES).write_bytes(data + STALE) + raw(holder, env, "add", "--", GITMODULES) + assert codes_of(found_alike(holder, *MODES)) == [ + "gitmodules-path-unnamed"] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +def test_no_section_is_read_in_a_modify_delete_conflict( + family, holder, offline, tmp_path): + """rv1227's N3 on #1227, its e2c, a real conflict: a stale section for + `members/Old` committed on this branch, and a branch that deletes + `.gitmodules` merged in. Git's modify/delete conflict holds the file + at stages 1 and 2 alone, not 3, and leaves the work tree's on disk as + this branch's; git reads no file here. Main read the work tree's and + named the section. No section is read here now, in every mode. + Resolved with a plain `git add`, which keeps this branch's side, the + section is named; its lines typed, then a plain `git commit`, which + records the merge: HEAD gives the two members alone, and the gate and + a plain clone pass. (A value git dies on cannot stand in for the + section here: with one in the work tree's file, `git merge` itself + dies, `invalid value`, exit 128.)""" + env = cloning(offline, tmp_path, family) + data = head_bytes(holder, env) + raw(holder, env, "switch", "-q", "-c", "deletes") + raw(holder, env, "rm", "-q", "--", GITMODULES) + raw(holder, env, "commit", "-q", "-m", ".gitmodules deleted") + raw(holder, env, "switch", "-q", "-") + committed_gitmodules(holder, env, data + STALE) + merged = raw(holder, env, "merge", "deletes", check=False) + assert merged.returncode == 1, merged.stdout + merged.stderr + staged = raw(holder, env, "ls-files", "-s", "--", GITMODULES).stdout + assert [line.split()[2] for line in staged.splitlines()] == [ + b"1", b"2"], staged + git_reads_none(holder, env) + + for extra in MODES: + gate = validate(holder, *extra) + assert gate.returncode == 0, (extra, gate.stdout + gate.stderr) + raw(holder, env, "add", "--", GITMODULES) + assert codes_of(found_alike(holder, *MODES)) == [ + "gitmodules-path-unnamed"] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + + +# --- rv1227's M2: what a check that reads sections asks while unmerged ----- + +@pytest.mark.parametrize("left", LEFT) +def test_no_gitlink_outside_members_is_asked_about_while_unmerged( + family, holder, offline, tmp_path, left): + """rv1227's M2 on #1227, its e2d: IRSS mounted at tools/x with `git + submodule add`, its section and all, and committed; then `.gitmodules` + at a merge's conflict stages, every side HEAD's, with the work tree's + file deleted or on disk. Git reads no file here, so its `submodule + status` and `update --init` exit 128 at every gitlink, and a plain + clone of HEAD, which reads HEAD's file, checks tools/x out. + `gitlink-unnamed` read no section here, said none gave tools/x and a + plain clone stopped there, and said to commit its `git rm --cached`, + which git refuses while the merge is unresolved (exit 128). It asks + nothing while the merge is unresolved now, as no section is read: + every mode passes. Resolved with a plain `git add`, the section is + `gitmodules-path-unnamed`'s, as before: its lines typed, then a plain + `git commit`, HEAD gives the two members alone, and the gate and a + plain clone pass.""" + env = cloning(offline, tmp_path, family) + at_tools(holder, env, family) + data = head_bytes(holder, env) + where = tmp_path / "head-clone" + where.mkdir() + clone = cloned(holder, env, family, where, recurse=False) + raw(clone, env, "submodule", "update", "--init", "--recursive") + assert (clone / "tools" / "x" / ".git").exists(), "premise: checked out" + unmerged(holder, env, data, data, data) + if left == "deleted": + (holder / GITMODULES).unlink() + git_reads_none(holder, env) + + for extra in MODES: + gate = validate(holder, *extra) + assert gate.returncode == 0, (extra, gate.stdout + gate.stderr) + (holder / GITMODULES).write_bytes(data) + raw(holder, env, "add", "--", GITMODULES) + assert codes_of(found_alike(holder, *MODES)) == [ + "gitmodules-path-unnamed"] + typed_in_order(holder, env, validate(holder, "--no-members")) + committed_as_staged(holder, env) + assert head_paths(holder, env) == {ROW, OTHER_ROW} + assert raw(holder, env, "status", "--porcelain").stdout == b"?? tools/\n" + ends_well(holder, env, family, tmp_path) + + +def test_a_member_not_checked_out_while_unmerged_is_told_the_plain_update( + family, holder, offline, tmp_path): + """rv1227's M2 on #1227, its e2e: MEMBER not checked out, and + `.gitmodules` at a merge's conflict stages, the work tree's file on + disk as a hand resolution left it, with MEMBER's url taken out. Git's + init reads no file here and dies at MEMBER's gitlink, the first, + whatever the file gives. `member-uninitialized` read the work tree's + file, or no section, and said to write MEMBER's section back and + commit it, which git refuses while the merge is unresolved. It asks + nothing while the merge is unresolved now, and says the plain update, + whose `make bootstrap` names the merge resolved and added as the + cure. Done as that says, then the update: the gate and a plain clone + pass.""" + env = cloning(offline, tmp_path, family) + raw(holder, env, "submodule", "deinit", "-f", "--", ROW) + data = head_bytes(holder, env) + theirs = without_url(data, MEMBER) + unmerged(holder, env, data, data, theirs) + (holder / GITMODULES).write_bytes(theirs) + stopped = raw(holder, env, "submodule", "update", "--init", "--recursive", + check=False) + assert stopped.returncode == 128, stopped.stderr + assert f"No url found for submodule path '{ROW}'".encode() in ( + stopped.stderr), stopped.stderr + + for extra in ((), ("--pins",)): + gate = validate(holder, *extra) + assert gate.returncode == 2, (extra, gate.stdout + gate.stderr) + assert gate.stderr.splitlines() == [ + UNINITIALIZED, f"Remediation: {UPDATE}"], gate.stderr + said = bootstrap(holder, env) + assert ("(git reads no .gitmodules while a merge leaves it unmerged). " + "Resolve the merge in .gitmodules and `git add .gitmodules`, then " + "run this again.") in said.stderr, said.stderr + (holder / GITMODULES).write_bytes(data) + raw(holder, env, "add", "--", GITMODULES) + raw(holder, env, "submodule", "update", "--init", "--recursive") + committed_as_staged(holder, env) + assert head_bytes(holder, env) == data + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path)