diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 19caec0d..655c8cca 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -89,7 +89,10 @@ which HEAD still records, says to commit it, and prints no `git rm`, which would fail (#681). One no section of HEAD's `.gitmodules` gives a url is said to be checked out nowhere by a recursive clone, and to - stop a plain clone's `git submodule update --init` (#762). + stop a plain clone's `git submodule update --init` (#762). Where the + work tree's `.gitmodules` is gone from disk, the sections are read + from the file git reads in its place, the index's, then HEAD's, and + the lines that take one out put that file back first (#1036). - that every gitlink OUTSIDE `members/`, which no row can name, is at a path a section of `.gitmodules` gives, the `gitlink-unnamed` finding (#878): a plain clone's `git submodule update --init` stops at one no @@ -2837,12 +2840,29 @@ 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).""" + 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).""" + return {keys["path"] for keys in _sections_as_git_reads( + root, _gitmodules_sections(root)).values() if "path" in keys} + + +def _sections_as_git_reads(root: Path, worktree: dict[str, dict[str, str]] + ) -> dict[str, dict[str, str]]: + """Every `submodule` section of `.gitmodules` as git's submodule + commands read the file, `{name: {key: value}}`: `worktree`, the work + tree's (`_gitmodules_sections`), where it is on disk, else the one git + reads in its place (`_gitmodules_put_back`, the index's, then HEAD's, + `_blob_sections`), else none (#1036). What `gitmodules-path-unnamed` + reads: from the work tree's alone, a section `git submodule add` + 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.""" if os.path.lexists(root / GITMODULES): - return {keys["path"] for keys in _gitmodules_sections(root).values() - if "path" in keys} + return worktree spec = _gitmodules_put_back(root)[0] - return _blob_paths(root, spec) if spec else set() + return _blob_sections(root, spec) if spec else {} #: Why a plain `git rm` of a hand mount's gitlink refuses it where the @@ -3154,6 +3174,16 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], 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). + + WHERE THE WORK TREE'S `.gitmodules` IS GONE FROM DISK, `sections` are + the file git reads in its place, the index's, then HEAD's + (`_sections_as_git_reads`, #1036), and each finding that prints lines + puts that file back before its keys' lines, unless a finding printed + before did (`_file_put_back`, recorded in `Report.put_back`): read from + the work tree alone, a section `git submodule add` staged, its file + then deleted, was never named, the hand mount's `git rm --cached` + exited 0, the gate passed, and the commit of what was staged recorded + the section (RV1025's SHOULD 1 on #1025). """ holds = {keys["path"] for keys in sections.values() if "path" in keys} given = Counter(keys["path"] for keys in sections.values() @@ -3173,8 +3203,10 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], said + _variant_advice(kind, path, *variant, "section")) continue + then, staged = _section_then(root, name) said += _variant_said(*variant) + _section_out( - name, keys, "Take", f"then commit {GITMODULES}") + name, keys, "Take", then, back=_file_put_back(root, report), + staged=staged) elif (path in gitlinks and path not in removed and given[path] == 1 and _in_members(path) and _hand_cached(root, path, gitlinks[path]) is None): @@ -3186,7 +3218,8 @@ def _check_gitmodules_named(root: Path, sections: dict[str, dict[str, str]], continue else: said += SECTION_ROW_BACK + _gitlink_out_first( - root, name, keys, gitlinks, removed, taken, edited) + root, name, keys, gitlinks, removed, taken, edited, + _file_put_back(root, report)) report.finding("gitmodules-path-unnamed", said) edited = report.wrote = True @@ -3208,22 +3241,61 @@ def _section_unnamed_said(name: str, keys: dict[str, str]) -> str: def _section_out(name: str, keys: dict[str, str], lead: str, then: str, - line: str = "") -> str: + line: str = "", back: tuple[str, str] = ("", ""), + staged: bool = False) -> 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.""" + lines. + + WHERE THE WORK TREE'S `.gitmodules` IS GONE FROM DISK, `back` is the + line that puts it back from where git reads it and whose file that is + (`_file_put_back`), printed after `line` and before the keys', and + said: `git config -f` on no file exits 5 and makes none, measured on + git 2.43, so the keys' lines took nothing out (#1036). A `git rm + --cached` takes the gitlink out with no file on disk, so `line` stays + first. + + `staged`, where HEAD's `.gitmodules` holds no section of `name` + (`_section_then`), prints `git add -- .gitmodules` after the keys' + lines: the commit records the index's file, which still held the + section `git submodule add` staged, so a `git commit` after the lines + recorded it, and `git commit -- .gitmodules` had nothing to commit + (RV1069's SHOULD 1 on #1069).""" removal = "".join( f"\n git config -f {GITMODULES} --unset-all " f"{shlex.quote(f'submodule.{name}.{key}')}" for key in keys) + if staged: + removal += f"\n {STAGE_LINE}" + put_back, whose = back + if put_back: + then = (f"after the line that puts {GITMODULES}, gone from the work " + f"tree, back from {whose}, so that every other section " + f"stays, {then}") + removal = f"\n {put_back}{removal}" 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 _section_then(root: Path, name: str) -> tuple[str, bool]: + """How `gitmodules-path-unnamed`'s text for the section `name` ends, + and whether its lines end staging `.gitmodules` (`_section_out`): the + commit where HEAD's file holds a section of that name, else the file + staged, with nothing of it left to commit, as after a `git submodule + add` not committed (RV1069's SHOULD 1 on #1069). Every branch asks + it, in `members/`, outside it and in another case.""" + in_head = _head_gitmodules(root) + if in_head is not None and name in in_head[1]: + return f"then commit {GITMODULES}", False + return (f"then stage {GITMODULES}: HEAD's holds no section of that name, " + "so nothing of it is left to commit"), True + + def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], gitlinks: dict[str, str], removed: frozenset[str], - taken: set[str], edited: bool) -> str: + taken: set[str], edited: bool, + back: tuple[str, str] = ("", "")) -> 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/` @@ -3256,19 +3328,21 @@ def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], 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 prints - one for is added to `taken`.""" + one for is added to `taken`. + + `back` is the line that puts back a `.gitmodules` gone from the work + tree, and whose file it puts back (`_file_put_back`), which the + section's own lines come after, the `git rm --cached` before it + (`_section_out`, #1036).""" path = keys["path"] if (path not in gitlinks or path in removed or _in_members(path) or path in taken): - return _section_out(name, keys, "Otherwise take", - f"then commit {GITMODULES}") + then, staged = _section_then(root, name) + return _section_out(name, keys, "Otherwise take", then, back=back, + staged=staged) taken.add(path) head = _forced(root, path, gitlinks[path]) - in_head = _head_gitmodules(root) - then = (f"then commit {GITMODULES}" - if in_head is not None and name in in_head[1] else - f"then stage {GITMODULES}: HEAD's holds no section of that name, " - "so nothing of it is left to commit") + then, staged = _section_then(root, name) stage = _stage_first(root, edited) nth = "the second line" if stage else "the first line" return (" Otherwise take its gitlink out first, since a gitlink no " @@ -3280,7 +3354,8 @@ def _gitlink_out_first(root: Path, name: str, keys: dict[str, str], + _forced_said(head, nth, _held_if_forced(root, path, head)) + "." + _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)}")) + + f"\n {_cached_removal(path, head)}", back, + staged)) def _section_lines(path: str, repository, @@ -3715,14 +3790,21 @@ def _blob_paths(root: Path, spec: str) -> set[str]: (`:.gitmodules`, the index's, or `HEAD:.gitmodules`), read as `_gitmodules_sections` reads the work tree's; none where git cannot read it (#879).""" + 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).""" 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} + return {} + return _sections_listed(listed) def _put_back_lines(root: Path, path: str, @@ -4026,6 +4108,15 @@ def _check_members(root: Path, members: list, no_members: bool, names: the finding fails the run, where `member-uninitialized` named the update that dies, and `_init_stop_said`, which would name a stop git never reaches there, is not asked. + + `gitmodules-path-unnamed` READS THE FILE GIT READS (#1036): where the + work tree's `.gitmodules` is gone from disk, the index's, then HEAD's + (`_sections_as_git_reads`), not nothing. Not where either file gives a + value git dies on: that finding's lines put HEAD's file back, take the + key out and stage the file, which drops every section the index's file + alone gave, so a section's `--unset-all` after them met a key the file + did not hold (exit 5), and it reads what it read before. Every other + reader of `sections` is as it was. """ _check_member_names_unique(members, report) _check_member_ids_unique(root, members, report) @@ -4048,7 +4139,9 @@ def _check_members(root: Path, members: list, no_members: bool, removed = _staged_removals(root, gitlinks) _check_gitlinks_named(root, gitlinks, named, removed, report) _check_gitlinks_elsewhere(root, gitlinks, named, removed, report) - _check_gitmodules_named(root, sections, gitlinks, named, removed, report) + _check_gitmodules_named(root, sections if refused + else _sections_as_git_reads(root, sections), + gitlinks, named, removed, report) unregistered = _check_gitlinks_registered(root, members, gitlinks, report, refused) unregistered |= _check_removals_staged(root, members, gitlinks, removed, diff --git a/tests/test_family_hand_mount_git_rm_lines.py b/tests/test_family_hand_mount_git_rm_lines.py index 191dd7d8..c1d90ac1 100644 --- a/tests/test_family_hand_mount_git_rm_lines.py +++ b/tests/test_family_hand_mount_git_rm_lines.py @@ -57,6 +57,16 @@ in every mode, and a plain clone's `git submodule update --init --recursive` must check out both members. The helpers are #879's (`tests/test_family_holder_remedies_compose.py`) and those it imports. + +#1036, RV1025'S SHOULD 1 ON #1025: state 1's `git submodule add`, not +committed, with the work tree's `.gitmodules` then deleted. The section +the add staged is in the index's file alone, which git reads in place of +the one gone (`git submodule status members/IRQS` maps it, git 2.43), but +`gitmodules-path-unnamed` read the work tree's alone and named nothing: +every line typed exited 0, the gate passed, and the commit of what was +staged recorded the section. It reads the file git reads now, the index's, +then HEAD's, and its lines put that file back first (`git config -f` +on no file exits 5 and makes none). Its rows are at the end of this file. """ from __future__ import annotations @@ -75,15 +85,20 @@ commit_all, staged_and_committed, ) from test_family_holder_remedies_compose import ( - MODES, OTHER_ROW, a_file_at, cloning, committed_with_its_checkout, - ends_well, files_at, found_alike, head_paths, holding_a_file, - moved_back_and_added, moved_by_git_mv, moved_with_a_commit, - staged_by_hand, typed_in_order, + MODES, OTHER_ROW, PUT_BACK_THEN, a_file_at, cloning, codes_of, + committed_with_its_checkout, ends_well, files_at, found_alike, + head_paths, holding_a_file, moved_back_and_added, moved_by_git_mv, + moved_with_a_commit, staged_by_hand, typed_in_order, value_committed, ) from test_family_member_path import raw -from test_family_unnamed_gitlink import submodule_added -from test_validate_family_typed_states import GONE, ROW -from test_validate_family_unnamed_mounts import commands +from test_family_unnamed_gitlink import ( + FORCED_NO_HEAD, THEN_STAGE, TOOLS, section_first, submodule_added, +) +from test_validate_family_typed_states import GONE, ROW, gone +from test_validate_family_unnamed_mounts import ( + SECTION_REMEDY, SECTION_SAID, SECTION_UNDER_GIT_RM, VARIANT, commands, + removal, with_section, +) HAND = f"members/{SPARE}" @@ -531,3 +546,250 @@ def test_a_hand_mount_s_git_this_user_cannot_reach_is_refused_by_name( assert "Traceback" not in result.stderr assert "chmod u+rx" in result.stderr assert found_alike(holder, *MODES) == before + + +# --- #1036: a section the file git reads gives, the work tree's gone --------- + +def put_back_said(how: str) -> str: + """What `gitmodules-path-unnamed` says of the line it prints before a + section's keys where the work tree's `.gitmodules` is gone, as `how` + (`GONE`) says, ahead of what it says last (#1036).""" + whose = GONE[how][0] + return f"after the line that puts {PUT_BACK_THEN.format(whose=whose)}, " + + +def index_url(root: Path, env: dict, name: str) -> str: + """The url the section `name` of the index's `.gitmodules` gives.""" + return raw(root, env, "config", "--blob", ":.gitmodules", + f"submodule.{name}.url").stdout.decode("utf-8").strip() + + +def what_is_staged_committed(root: Path, env: dict) -> None: + """`git commit` of what is staged, typed by the person after the + printed lines, the issue's Verify, and nothing else: no `git add` the + text does not print (RV1069's SHOULD 1 on #1069). Where nothing is + staged it exits 1, `nothing to commit`, and HEAD is as it was.""" + raw(root, env, "commit", "-q", "-m", "What is staged", check=False) + + +def lines_of(*lines: str) -> str: + """`lines` as a finding prints them, one to an indented line.""" + return "".join(f"\n {line}" for line in lines) + + +def ghost_out(how: str, *, put_back: bool) -> str: + """`gitmodules-path-unnamed` whole for a section named `ghost` giving + `Members/IRRS`, a case variant of MEMBER's path, url + `/nowhere/ghost.git`, with the work tree's `.gitmodules` gone as `how` + (`GONE`) says: its keys' lines, after the line that puts the file back + where `put_back`.""" + line = GONE[how][1] + return (SECTION_SAID.format(path="Members/IRRS", name="ghost", + url="url /nowhere/ghost.git") + + VARIANT.format(row_path=ROW, project="IRRS") + + " Take the section out by hand, in a POSIX shell, every key it " + "holds, so a submodule later given its name inherits none of " + "them, " + (put_back_said(how) if put_back else "") + + "then commit .gitmodules:" + + lines_of(*([line] if put_back else []), + *removal("ghost", "path", "url"))) + + +def test_1036_a_section_the_index_alone_gives_comes_out_of_the_file_put_back( + family, holder, offline, tmp_path): + """#1036, RV1025's SHOULD 1 on #1025: `git submodule add` at + `members/IRQS`, not committed, then the work tree's `.gitmodules` + deleted, not staged (`MD .gitmodules`). The index's file, which git + reads in its place, gives IRQS's section, and `gitmodules-path-unnamed` + read the work tree's alone: the hand mount's `git rm --cached -f` exited + 0, the gate passed, and the commit of what was staged recorded the + section. The section is a finding now, in every mode, whose lines put + the index's file back first, take every key out, then stage the file, + since HEAD's holds no section of that name (RV1069's SHOULD 1 on #1069: + the text said "then commit .gitmodules", the index's file kept the + section, and a `git commit` recorded it). Only the printed lines typed, + in order, the checkout deleted as the text says, then `git commit` of + what is staged, the issue's Verify: HEAD's `.gitmodules` gives the two + members alone, nothing is left, the gate passes in every mode and a + plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + path = added_not_committed(holder, env, family) + url = index_url(holder, env, path) + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + typed_around(holder, env, found, lambda: deleted(holder / path)) + what_is_staged_committed(holder, env) + assert head_paths(holder, env) == {ROW, OTHER_ROW} + assert raw(holder, env, "status", "--porcelain").stdout == b"" + ends_well(holder, env, family, tmp_path) + assert codes_of(found) == ["member-gitlink-unnamed", + "gitmodules-path-unnamed"] + assert (commands(mount_of(found, path))[1] + == f"git rm --cached -f -- {path}") + put_back = GONE["deleted"][1] + assert found[1] == ( + SECTION_SAID.format(path=path, name=path, url=f"url {url}") + + SECTION_REMEDY.replace("then commit .gitmodules:", + put_back_said("deleted") + THEN_STAGE + ":") + + lines_of(put_back, *removal(path, "path", "url"), + "git add -- .gitmodules")) + + +def test_1036_outside_members_the_gitlink_goes_first_then_the_put_back( + family, holder, offline, tmp_path): + """#1036 outside `members/`: `git submodule add` at tools/x, not + committed, then the work tree's `.gitmodules` deleted. The index's file + gives tools/x its section, so `gitlink-unnamed` rightly says nothing, + and `gitmodules-path-unnamed` read the work tree's alone: the gate + passed, and the next commit recorded a gitlink and a section no row can + name. The section's finding takes the gitlink out of the index first, + then puts the index's file back, takes every key out and stages the + file. Only the printed lines typed, then `git commit` of what is staged, + nothing is left to commit, the checkout stays untracked, the gate passes + in every mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + submodule_added(holder, env, family, TOOLS) + url = index_url(holder, env, TOOLS) + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + put_back = GONE["deleted"][1] + removed = f"\n git rm --cached -f -- {TOOLS}" + assert found == [section_first( + TOOLS, TOOLS, url, FORCED_NO_HEAD, + put_back_said("deleted") + THEN_STAGE, " -f").replace( + removed, removed + lines_of(put_back))] + typed_in_order(holder, env, validate(holder, "--no-members")) + what_is_staged_committed(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) + + +@pytest.mark.parametrize("how", GONE) +def test_1036_a_stray_section_comes_out_of_the_file_put_back( + family, holder, offline, tmp_path, how): + """#1036 in the case-variant branch: a section named `ghost` giving + `Members/IRRS`, a second path to MEMBER's directory on macOS and + Windows, committed, then the work tree's `.gitmodules` deleted or its + deletion staged. Git reads the index's file, or HEAD's, both of which + give it, and the gate passed. Its lines put that file back first now, + then take every key out. Typed in order, staged and committed as it + says, HEAD's `.gitmodules` gives the two members alone, the gate + passes in every mode and a plain clone checks both out.""" + env = cloning(offline, tmp_path, family) + with_section(holder, tmp_path, "ghost", "Members/IRRS", + "/nowhere/ghost.git") + gone(holder, env, how) + + found = found_alike(holder, *MODES) + + assert found == [ghost_out(how, put_back=True)] + 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_1036_one_put_back_serves_two_sections(family, holder, offline, + tmp_path): + """#1036, two sections no row names, `ghost` giving `Members/IRRS` and + `members/Old` giving its own name, committed, then the work tree's + `.gitmodules` deleted. The first finding puts the index's file back + before its keys, and records that; the second prints its keys alone, + as with the file on disk: a second put-back, typed after the first + finding's lines, brought back the section they had just taken out. + Typed in order, staged and committed, HEAD's `.gitmodules` gives the + two members alone, the gate passes in every mode and a plain clone + checks both out.""" + env = cloning(offline, tmp_path, family) + with_section(holder, tmp_path, "ghost", "Members/IRRS", + "/nowhere/ghost.git") + with_section(holder, tmp_path, "members/Old", "members/Old", + "/nowhere/old.git") + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + assert found == [ + ghost_out("deleted", put_back=True), + SECTION_SAID.format(path="members/Old", name="members/Old", + url="url /nowhere/old.git") + + SECTION_REMEDY + lines_of(*removal("members/Old", "path", "url"))] + 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) + + +@pytest.mark.parametrize("how", GONE) +def test_1036_a_hand_mount_s_section_under_its_plain_git_rm_prints_nothing( + family, holder, offline, tmp_path, how): + """#1036 where the hand mount's plain `git rm` takes the section out: a + member mounted by hand with its checkout and its section, committed, + then the work tree's `.gitmodules` deleted or its deletion staged. The + section, which the file git reads gives, is named now, as with the file + on disk, and prints no command: the hand mount's text puts that file + back first and its `git rm` takes the section out of it (#936). Typed in + order and committed, nothing is left, HEAD's `.gitmodules` gives the two + members alone, the gate passes in every mode and a plain clone checks + both out.""" + env = cloning(offline, tmp_path, family) + path = committed_with_its_checkout(holder, env, family) + url = index_url(holder, env, path) + gone(holder, env, how) + + found = found_alike(holder, *MODES) + + assert codes_of(found) == ["member-gitlink-unnamed", + "gitmodules-path-unnamed"] + assert commands(mount_of(found, path))[1:3] == [GONE[how][1], + f"git rm -- {path}"] + assert found[1] == ( + SECTION_SAID.format(path=path, name=path, url=f"url {url}") + + SECTION_UNDER_GIT_RM.format(path=path)) + typed_in_order(holder, env, validate(holder, "--no-members")) + commit_all(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_1036_control_a_value_git_dies_on_keeps_what_it_read( + family, holder, offline, tmp_path): + """#1036's control: `update = None` committed in IRSS's section, a value + git dies on, then IRQS's gitlink staged over a directory holding a file, + its section staged in the index's `.gitmodules` alone, and the work + tree's file deleted. The value's lines put HEAD's file back and stage + it, which drops that section, so `gitmodules-path-unnamed` reads nothing + from the file git reads there, as before: its `--unset-all` of a key the + file put back does not hold exited 5. Typed in order, what is on disk + deleted as the text says, then staged and committed as the value's + finding says, nothing is left, 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) + path = staged_by_hand(holding_a_file)(holder, env, family) + data = (holder / ".gitmodules").read_bytes() + ( + f'[submodule "{path}"]\n\tpath = {path}\n' + f"\turl = https://github.com/InkRouter/{SPARE}.git\n").encode() + blob = raw(holder, env, "hash-object", "-w", "--stdin", + stdin=data).stdout.decode("ascii").strip() + raw(holder, env, "update-index", "--cacheinfo", + f"100644,{blob},.gitmodules") + gone(holder, env, "deleted") + + found = found_alike(holder, *MODES) + + assert codes_of(found) == ["gitmodules-value-refused", + "member-gitlink-unnamed"] + typed_around(holder, env, found, lambda: deleted(holder / path)) + 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) diff --git a/tests/test_family_holder_remedies_compose.py b/tests/test_family_holder_remedies_compose.py index b6e5effa..ee052fa6 100644 --- a/tests/test_family_holder_remedies_compose.py +++ b/tests/test_family_holder_remedies_compose.py @@ -49,7 +49,8 @@ `_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 +work tree's is gone (SHOULD 2, whose section is `gitmodules-path-unnamed` +since #1036 reads that file too); 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. @@ -113,17 +114,17 @@ 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, + THEN_COMMIT, TOOLS, assert_a_plain_clone_checks_out_every_member, + at_tools, found_in_every_mode, outside, section_first, + 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, + GITLINK_SAID, GITLINK_SAID_FETCHED, SECTION_SAID, VARIANT, WRONG_CASE, + codes, commands, findings, spare_commits, stage_gitlink, ) MODES = ((), ("--pins",), ("--no-members",)) @@ -238,12 +239,15 @@ def test_c1_one_put_back_serves_the_move_and_a_url_finding_after_it( `--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.""" + back. The section of the file git reads that gives `Members/IRRS` is + named between them, as with the file on disk, and prints no command + (#1036). 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)) + url = url_of(holder, env, ROW) gone(holder, env, how) result = validate(holder, "--no-members") @@ -252,6 +256,8 @@ def test_c1_one_put_back_serves_the_move_and_a_url_finding_after_it( assert findings(result) == [ unnamed_as_moved(VARIANT_PATH, commit, moved_refused(commit, how), said=GITLINK_SAID_FETCHED), + SECTION_SAID.format(path=VARIANT_PATH, name=ROW, url=f"url {url}") + + WRONG_CASE.format(row_path=ROW, project=MEMBER, what="section"), URL_ABSENT.format(commit=pinned(holder, OTHER))] typed_in_order(holder, env, result) staged_and_committed(holder, env, "Typed in order") @@ -827,12 +833,33 @@ def test_rv882_should_2_a_gitlink_the_file_git_reads_gives_is_no_finding( 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.""" + reads now: no mode finds `gitlink-unnamed`. + + IT SAID THE GATE PASSED, which it did only because + `gitmodules-path-unnamed` read the work tree's file alone (#1036): with + the file on disk the same state is that finding + (`tests/test_family_unnamed_gitlink.py`'s `tools-x` form). It is that + finding now, in every mode: the gitlink out first, then the file put + back, then the section's keys. Typed as printed, staged and committed, + HEAD's `.gitmodules` gives the two members alone, the gate passes in + every mode and a plain clone checks both out.""" env = cloning(offline, tmp_path, family) at_tools(holder, env, family) + url = url_of(holder, env, TOOLS) gone(holder, env, how) - assert_passes_in_every_mode(holder, 2) + finding = found_in_every_mode(holder, "gitmodules-path-unnamed") + + whose, line = GONE[how] + removed = f"\n git rm --cached -- {TOOLS}" + assert finding == section_first( + TOOLS, TOOLS, url, COMMIT_THAT, + f"after the line that puts {PUT_BACK_THEN.format(whose=whose)}, " + + THEN_COMMIT).replace(removed, f"{removed}\n {line}") + typed(holder, env, finding) + staged_and_committed(holder, env, "Typed as printed") + assert head_paths(holder, env) == {ROW, OTHER_ROW} + ends_well(holder, env, family, tmp_path) def on_disk_under(root: Path, path: str) -> list[str]: diff --git a/tests/test_family_unnamed_gitlink.py b/tests/test_family_unnamed_gitlink.py index 4450822d..8dd6db02 100644 --- a/tests/test_family_unnamed_gitlink.py +++ b/tests/test_family_unnamed_gitlink.py @@ -167,7 +167,8 @@ def section_first(path: str, name: str, url: str, said: str, then: str, + SECTION_THEN.format(then=then) + (STAGE_LINE if stage else "") + f"\n git rm --cached{flag} -- {path}" + "".join(f"\n {line}" for line in removal(name, "path", - "url"))) + "url")) + + (STAGE_LINE if then.endswith(THEN_STAGE) else "")) def found_in_every_mode(root: Path, code: str) -> str: