diff --git a/scripts/family.py b/scripts/family.py index 338c52c0..e4698d3a 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2743,19 +2743,43 @@ def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: #: with `git mv` where it is on disk in its own spelling, with `git mv #: --sparse` where a sparse checkout keeps it off disk, and otherwise taken #: out of the index, which touches no file, as #318 takes out a variant of -#: the mount path. A member's own gitlink, held only under another spelling -#: of its row's path, is moved back to that path in the index alone (#646). -#: What is on disk and untracked is moved out of the way, and a `members/` a -#: hand renamed is moved back. +#: the mount path. A `members/` renamed with `git mv` is renamed back with +#: it, whole, in two steps through a free name (#685). A member's own +#: gitlink, held only under another spelling of its row's path, is moved +#: back to that path in the index alone (#646). What is on disk and +#: untracked is moved out of the way, and a `members/` a hand renamed is +#: moved back. COMMIT = "git commit" RENAME = "git mv" RENAME_SPARSE = "git mv --sparse" UNSTAGE = "git rm -r --cached" +RENAME_BACK = "git mv back" RESTORE = "git update-index" MOVE = "mv" MOVE_BACK = "mv back" -REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, RESTORE, MOVE, - MOVE_BACK) +REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, RENAME_BACK, RESTORE, + MOVE, MOVE_BACK) + +#: What a `--sparse` the remedy prints needs (#685). `git rm --sparse` came +#: with git 2.34. `git mv` took `--sparse` in 2.34 too, but there it still +#: says `bad source` for one entry a sparse checkout keeps off disk, which +#: is the one entry the remedy names: its `builtin/mv.c` is the same from +#: 2.34.0 to 2.37.7, and 2.38.0 is the first to look that entry up in the +#: index, and the first whose tests move one (`can move out-of-cone file +#: with --sparse`). Read from git's source, not run. +#: +#: NO OTHER WAY IS NAMED. On git 2.43, the only git run here, clearing the +#: entry's `skip-worktree` bit first (`git update-index +#: --no-skip-worktree`) leaves `git mv` saying `bad source`, since the file +#: is still off disk, and in a cone-mode sparse checkout `git rm --cached` +#: then still removes nothing. An older git may differ (2.33's `mv.c` has +#: no sparse code at all), but none was run. +SPARSE_MV_GIT = "which needs git 2.38 or later" +SPARSE_RM_GIT = "which needs git 2.34 or later" + +#: The name a `members/` renamed with `git mv` is renamed back through, or +#: the first of its numbered variants that nothing holds (#685). +RENAMING = "members.renaming" class Spelled(NamedTuple): @@ -2775,7 +2799,15 @@ class Spelled(NamedTuple): `sparse` whether a sparse checkout keeps it off disk (`skip-worktree`), which `git rm --cached --ignore-unmatch` passes over without a word unless it is given `--sparse` too; `recorded` is empty, and `sparse` - false, for every other remedy.""" + false, for every other remedy. + + WHAT A `members/` RENAMED WITH `git mv` HOLDS (`RENAME_BACK`, #685) has + that spelling as `target`, `committed` says whether renaming it back + leaves anything to commit, `through` is the free name it is renamed + back through, and `put_back` says whether the disk lacks this entry, + which is put back first, since `git mv` of a directory refuses any + entry under it that is not on disk (`bad source`); `through` is empty, + and `put_back` false, for every other remedy.""" name: str kind: str @@ -2786,6 +2818,8 @@ class Spelled(NamedTuple): committed: bool = False recorded: str = "" sparse: bool = False + through: str = "" + put_back: bool = False def _folded(name: str) -> str: @@ -3037,6 +3071,119 @@ def _head_spellings(root: Path) -> list[tuple[str, str, str]]: return held +def _free_name(root: Path, listed: dict[str, list]) -> str: + """`RENAMING`, or else the first of `RENAMING-2`, `RENAMING-3` and on, + that nothing the index holds (`listed`) is at or under, in any case, and + that the disk does not hold, as it resolves a name.""" + folded = {_folded(name) for name in listed} + count = 1 + while True: + name = RENAMING if count == 1 else f"{RENAMING}-{count}" + if not os.path.lexists(root / name) and not any( + held == name or held.startswith(name + "/") + for held in folded): + return name + count += 1 + + +def _renamed_with_git(root: Path, found: list[Spelled], + listed: dict[str, list], canon: str | None + ) -> tuple[str, str, frozenset[str]] | None: + """`(the spelling, the free name, the entries to put back first)` where + `members/` was renamed to that spelling with `git mv`, and what + `_members_spellings` found in the index is that and nothing else + (#685), or None. `listed` is every index entry's records, and `canon` + is `_members_dir_on_disk`'s answer. + + `git mv` of `members/` renames each member's checkout, its gitlink and + its section of `.gitmodules` (`path = Members/

`), staged or + committed. The move back of a member's own gitlink (#646) puts the + gitlink back and leaves the section naming the other spelling, so `git + submodule update` then says `No url found for submodule path + 'members/

'`, and the gate finds `gitmodules-path-unnamed`. `git mv` + of the spelling to a free name, then of that to `members`, takes all of + it back, on every disk: on macOS and Windows the spelling IS + `members/`, and a `git mv -- Members members` would take `members` for + that directory and rename it into itself (`members/Members`), which the + disk refuses. It is the remedy where all of these hold: + + - The index holds nothing at or under `members` itself. + - Everything the index holds under another spelling is under this one + spelling, none of it a case variant of the mount path, and some of it + is a member's own gitlink (`RESTORE`), which nothing at that spelling + itself can sit beside. + - HEAD holds no member's gitlink under another spelling that the index + no longer does, which the rename would leave out. + - No `members/` is on this disk, or the one there is that spelling's own + directory (`samefile`), as on macOS and Windows: a second directory + would take the second step into it. + - Neither that spelling nor `members` is a link, which is never + `members/` itself (`_members_dir_on_disk`): `git mv` of a link to a + directory says `not under version control`, and with a link `members + -> Members` the second step says `destination already exists` and + leaves the holder half renamed (#685's review, MUST-B). + - Every entry under it is at stage 0 (`git mv` says `source directory + is empty` for an unmerged gitlink, and `conflicted` for a file), and + no sparse checkout keeps one off disk, which `git mv` refuses without + `--sparse`. + - Every entry under it is on disk as the index holds it, as the disk + resolves the name, or can be put back first: `git mv` of a directory + says `bad source` for any entry under it that is not. A file is put + back with `git checkout --`; a gitlink with `git submodule update`, + where a section of `.gitmodules` with a url gives its path. + """ + if any(name == MEMBERS_DIR or name.startswith(MEMBERS_DIR + "/") + for name in listed): + return None + held = [spelled for spelled in found if spelled.where == IN_INDEX] + spellings = {spelled.name.partition("/")[0] for spelled in held} + if (len(spellings) != 1 + or not any(spelled.remedy == RESTORE for spelled in held) + or any(spelled.mount for spelled in held) + or any(spelled.remedy == RESTORE for spelled in found + if spelled.where == IN_HEAD_ONLY)): + return None + (spelling,) = spellings + if os.path.islink(root / spelling) or ( + canon is not None and os.path.islink(root / canon)): + return None + if canon is not None: + try: + if not (os.path.isdir(root / spelling) and os.path.samefile( + root / spelling, root / canon)): + return None + except OSError: + return None + under = [name for name in listed if name.startswith(spelling + "/")] + if any(fields[2] != b"0" for name in under for fields in listed[name]): + return None + skipped = _skip_worktree(root) + sections: set[str] | None = None + put_back = set() + for name in under: + if name in skipped: + return None + here = root / name + gitlink = listed[name][0][0] == b"160000" + # As the index holds it: a gitlink a directory, anything else not. + if (os.path.isdir(here) if gitlink else os.path.lexists(here) and ( + os.path.islink(here) or not os.path.isdir(here))): + continue + if os.path.lexists(here): + return None + if gitlink: + if sections is None: + try: + sections = {path for path, url, _ + in _submodule_paths(root).values() if url} + except Refusal: + return None + if name not in sections: + return None + put_back.add(name) + return spelling, _free_name(root, listed), frozenset(put_back) + + def _members_spellings(root: Path, project: str, on_disk: dict[str, tuple[str, list[str] | None]], rows: frozenset[str] = frozenset()) -> list[Spelled]: @@ -3063,6 +3210,13 @@ def _members_spellings(root: Path, project: str, taking out leaves the member where it is, and the move would replace a file there or fail on one under it: it keeps the remedy below. + A `members/` RENAMED WITH `git mv` (#685), staged or committed, is + renamed back with it, whole, in two steps through a free name + (`RENAME_BACK`, `_renamed_with_git`), in place of the move back, which + would leave each member's section of `.gitmodules` naming the other + spelling; what the disk holds under that spelling goes back with it, so + it is not named. + THE INDEX, the whole of it, as `_index_entries` reads it (#362). Each entry's remedy acts on `_spelled_target`'s path: renamed with `git mv` where that path is on disk in its own spelling (`_on_disk_as_named`) and @@ -3153,25 +3307,67 @@ def _members_spellings(root: Path, project: str, continue found.append(Spelled(_shown(name), kind, IN_HEAD_ONLY, _shown(spelled[0]), spelled[1], COMMIT, True)) + renamed = _renamed_with_git(root, found, listed, canon) + if renamed is not None: + spelling, through, missing = renamed + gone = {_shown(name) for name in missing} + found = [spelled._replace(target=spelling, remedy=RENAME_BACK, + recorded="", sparse=False, through=through, + put_back=spelled.name in gone) + if spelled.where == IN_INDEX else spelled + for spelled in found] # What the disk holds at a gitlink moved back from HEAD is the move's to - # name, as what it holds at an index entry is the index's. + # name, as what it holds at an index entry is the index's, and what it + # holds under a `members/` renamed with `git mv` goes back with it. for path, kind, remedy in _disk_spellings(root, on_disk, project, canon, gitlinks): if any(name == path or path.startswith(name + "/") or (remedy == MOVE and name.startswith(path + "/")) - for name in [*indexed, *restored]): + for name in [*indexed, *restored, + *([renamed[0]] if renamed else [])]): continue found.append(Spelled(_shown(path), kind, ON_DISK, _shown(path), _spelled_target(path, project)[1], remedy)) return found +#: How the work tree's `.gitmodules` stands beside the index's copy, as +#: `_gitmodules_beside` answers it (#685). +GITMODULES_MODIFIED = "modified" +GITMODULES_MISSING = "missing" +GITMODULES_UNMERGED = "unmerged" + + +def _gitmodules_beside(root: Path) -> str: + """How the work tree's `.gitmodules` stands beside the index's copy, told + apart as `_checked_gitmodules_as_staged` tells them apart, for the + remedies `_spellings_refusal` prints where the index holds a gitlink + (#685): `GITMODULES_UNMERGED` where the index holds it at a stage other + than 0; `GITMODULES_MISSING` where the index holds it at stage 0 and the + work tree has none; `GITMODULES_MODIFIED` where both hold one and git + lists it as modified (`git ls-files -m`, which lists a missing one too); + else empty: none in the index, one a sparse checkout keeps off disk + (`gitmodules-sparse`'s), or one as the index holds it.""" + records = _gitmodules_in_index(root) + if any(stage for _, _, stage in records): + return GITMODULES_UNMERGED + if not records or records[0][0].upper() == "S": + return "" + if not os.path.lexists(root / GITMODULES): + return GITMODULES_MISSING + if GITMODULES.encode() in _git_bytes(["ls-files", "-m", "-z"], + root).split(b"\0"): + return GITMODULES_MODIFIED + return "" + + #: The order `_spellings_refusal` names its groups in. Its remedies run in #: `REMEDY_ORDER`: a pending change in HEAD is committed first. SPELLED_ORDER = (IN_INDEX, IN_HEAD_ONLY, ON_DISK) -def _spellings_refusal(path: str, found: list[Spelled]) -> Refusal: +def _spellings_refusal(path: str, found: list[Spelled], + gitmodules: str = "") -> Refusal: """The refusal for what `_members_spellings` found: every path named in its exact spelling, at most five in each place, with where it was found, and every remedy (Brett Heap's ruling of 2026-10-14, "Refuse on every @@ -3187,10 +3383,29 @@ def _spellings_refusal(path: str, found: list[Spelled]) -> Refusal: no command takes another's path from under it: `git commit` first for what HEAD holds and the index no longer does, since the add's own commit names three paths; then `git mv`, `git mv --sparse` and `git rm -r - --cached` for what the index holds, and a commit; then the move of a - member's own gitlink back to its row's path, and a commit; then `mv` for - what is on disk and untracked, and `mv` back for a renamed `members/`. - Every path is quoted for a POSIX shell (#532). + --cached` for what the index holds, and a commit; then, for a + `members/` renamed with `git mv` (#685), what puts back an entry under + it that the disk lacks, the `git mv` of it to a free name and of that + to `members`, and a commit; then the move of a member's own gitlink back + to its row's path, and a commit; then `mv` for what is on disk and + untracked, and `mv` back for a renamed `members/`. Every path is quoted + for a POSIX shell (#532). A `--sparse` says the git it needs + (`SPARSE_MV_GIT`, `SPARSE_RM_GIT`, #685). + + THE WORK TREE'S `.gitmodules` (`gitmodules`, `_gitmodules_beside`'s + answer, which `_checked_mount_path` passes only where the index holds a + gitlink this refusal names) comes first (#685): + - A change to it nobody has staged (`GITMODULES_MODIFIED`) is staged: + `git mv` and `git rm` of a gitlink refuse to run beside one (`Please + stage your changes to .gitmodules or stash them to proceed`). + - A copy the work tree lacks (`GITMODULES_MISSING`) is put back from the + index: `git mv` rewrites a moved member's section only in the work + tree's copy and passes over it where there is none, and a `git add` + of the file would stage its deletion, which the printed commit would + then take out of HEAD with every member's section (#685's review, + MUST-A). + `gitmodules-unstaged`, which says the same of the file, is asked after + this refusal; an unmerged copy is its to name first. THE MOVE BACK (#646) is `validate-family.py`'s for the same state (#611), in one line: `git rm -r --cached --ignore-unmatch` of every @@ -3229,6 +3444,16 @@ def each(command: str, targets: list[str]) -> str: for target in targets) parts = [] + if gitmodules == GITMODULES_MODIFIED: + parts.append( + f"stage the change to {GITMODULES} that nobody has staged, or " + "set it aside, since `git mv` and `git rm` of a gitlink refuse " + f"to run beside one (`git add -- {GITMODULES}`)") + elif gitmodules == GITMODULES_MISSING: + parts.append( + f"put back the copy of {GITMODULES} the index holds, which the " + "work tree lacks, since git edits each member's section there " + f"(`git checkout -- {GITMODULES}`)") if by[COMMIT]: named = ", ".join(f"`{target}`" for target in by[COMMIT]) parts.append( @@ -3243,7 +3468,7 @@ def each(command: str, targets: list[str]) -> str: f"entry ({each(RENAME, by[RENAME])})") if by[RENAME_SPARSE]: index.append("rename what the sparse checkout keeps off disk with " - "`git mv --sparse`, entry by entry " + f"`git mv --sparse`, {SPARSE_MV_GIT}, entry by entry " f"({each(RENAME_SPARSE, by[RENAME_SPARSE])})") if by[UNSTAGE]: quoted = " ".join(shell_quoted(target, "posix") @@ -3263,6 +3488,33 @@ def each(command: str, targets: list[str]) -> str: "the rename" if renamed else "the removal") if index: parts.append(", ".join(index)) + # A `members/` RENAMED WITH `git mv` (#685), one spelling at most: what + # puts back an entry under it the disk lacks, then the two `git mv`s. + for target in by[RENAME_BACK]: + held = [spelled for spelled in found if spelled.remedy == RENAME_BACK] + through = shell_quoted(held[0].through, "posix") + quoted = shell_quoted(target, "posix") + gitlink = INDEX_ENTRY_KINDS[0o160000] + back = [f"`{command} -- {' '.join(names)}`" for command, names in ( + ("git submodule update --init --recursive", sorted( + shell_quoted(spelled.name, "posix") for spelled in held + if spelled.put_back and spelled.kind == gitlink)), + ("git checkout", sorted( + shell_quoted(spelled.name, "posix") for spelled in held + if spelled.put_back and spelled.kind != gitlink))) + if names] + parts.append( + (f"put back what the index holds under `{target}` and the disk " + f"does not, {' and '.join(back)}, then " if back else "") + + f"rename `{target}` back to {MEMBERS_DIR}/, since it holds the " + f"gitlinks of members whose rows place them under {MEMBERS_DIR}/, " + "in two steps through a name nothing holds, as macOS and Windows " + f"take `{target}` for {MEMBERS_DIR}/ itself: `git mv` takes each " + f"checkout, and each section of {GITMODULES} that names it, with " + f"it (`{RENAME} -- {quoted} {through}`, then `{RENAME} -- " + f"{through} {MEMBERS_DIR}`)" + + (", and commit the rename" if by[COMMIT] or any( + spelled.committed for spelled in held) else "")) # A MEMBER'S OWN GITLINK (#646), per row: every variant found, the # index's before HEAD's, the first of which names the commit. moved = {row: [spelled for spelled in found @@ -3288,8 +3540,8 @@ def each(command: str, targets: list[str]) -> str: "move each member's gitlink held only under another spelling of " f"its row's path, {pairs}, to that path, at the commit it " "records, in the index alone, which touches no file (`git rm -r " - f"--cached{flag} --ignore-unmatch -- {taken_out}`, then " - f"{recorded})" + f"--cached{flag} --ignore-unmatch -- {taken_out}`, " + + (f"{SPARSE_RM_GIT}, " if flag else "") + f"then {recorded})" + (", and commit that" if by[COMMIT] or any( spelled.committed for held in moved.values() for spelled in held) else "")) @@ -3587,7 +3839,18 @@ def _checked_mount_path(root: Path, project: str, path: str, + "re-run.") spelled = _members_spellings(root, project, on_disk, rows) if spelled: - raise _spellings_refusal(path, spelled) + # The work tree's `.gitmodules`, where a remedy runs `git mv` or `git + # rm` of a gitlink (#685). An unmerged copy is `gitmodules-unstaged`'s + # to name, with its own restore, first: git cannot rewrite it, nor + # read conflict markers, and a `git add` of it stages them. + beside = _gitmodules_beside(root) if any( + spelled_one.where == IN_INDEX + and spelled_one.kind == INDEX_ENTRY_KINDS[0o160000] + for spelled_one in spelled) else "" + if beside == GITMODULES_UNMERGED: + _checked_gitmodules_as_staged(root, "add") + beside = "" + raise _spellings_refusal(path, spelled, beside) # A DIRECTORY NO LISTING COULD READ hides nothing: `lexists` asks the # path itself, as it always did, after every listing has answered. if os.path.lexists(root / path): diff --git a/tests/test_family_add_member_variant.py b/tests/test_family_add_member_variant.py index d90877be..0b879ffb 100644 --- a/tests/test_family_add_member_variant.py +++ b/tests/test_family_add_member_variant.py @@ -50,8 +50,9 @@ family_module, holder, offline, plant, refusal, ) from test_family_members_case_variants import ( - DIR_VARIANT, MOUNT, MOVE_BACK, added, commit_first, disk_as, disk_folds, - git_version, index_part, refused_untouched, remedy_of, renamed_members, + DIR_VARIANT, MOUNT, MOVE_BACK, NEEDS_GIT_2_34, added, commit_first, + disk_as, disk_folds, git_version, index_part, refused_untouched, + remedy_of, renamed_members, ) #: The member whose gitlink is moved into the wrong case, its row's path, @@ -90,7 +91,8 @@ def moved_back(rows: dict, commit: bool = True, f"its row's path, {pairs}, to that path, at the commit it " "records, in the index alone, which touches no file (`git rm -r " f"--cached{' --sparse' if sparse else ''} --ignore-unmatch -- " - f"{' '.join(variants)}`, then {lines})" + f"{' '.join(variants)}`, " + + (f"{NEEDS_GIT_2_34}, " if sparse else "") + f"then {lines})" + (", and commit that" if commit else "")) moved = " and ".join(f"`mv -- {name} ../{name.replace('/', '-')}`" for name in variants) diff --git a/tests/test_family_add_spellings_remedies.py b/tests/test_family_add_spellings_remedies.py new file mode 100644 index 00000000..fe5cb06c --- /dev/null +++ b/tests/test_family_add_spellings_remedies.py @@ -0,0 +1,770 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Three of #574's remedies in `family.py add`'s refusal of another spelling +of `members/`, each typed as printed in the state it describes (#685). + +1. A `members/` RENAMED WITH `git mv` (`git mv members Members`), staged or + committed. git renames each member's checkout, its gitlink and its + section of `.gitmodules` (`path = Members/IRRS`). The remedy was the move + back of each member's own gitlink (#646), which puts the gitlink back at + `members/IRRS` and leaves the section where it is: typed as printed, `git + submodule update` said `No url found for submodule path 'members/IRRS'`, + and the gate found `gitmodules-path-unnamed`. (Before #646 it was #574's + `git mv -- Members/IRRS `.) It is now `git mv -- Members + members.renaming`, then `git mv -- members.renaming members`, which takes + every checkout, gitlink and section back, on every disk: on macOS and + Windows `Members` IS `members/`, where a one-step `git mv -- Members + members` renames it into itself. An entry under it that the disk lacks, + which `git mv` refuses (`bad source`), is put back first. +2. A disk that FOLDS CASE, holding `members/`, that pulled a commit adding + `Members/README`: the file is on disk as `members/README`, and the + remedy takes `Members/README` out of the index (`git rm -r --cached`), + not a `git mv`. Typed as printed, on that disk as the family tests + emulate it, it touches no file, the file stays at `members/README`, + untracked, and `add` and the gate pass. The remedy is not changed: this + test is the line's evidence. +3. A `--sparse` the remedy prints says the git it needs, on every git: + 2.38 for `git mv --sparse` of one entry, 2.34 for `git rm --sparse`. + +States that need `git mv members Members` are made on a disk that does not +fold case alone, where that command works; what a folding disk answers is +asked in process (`disk_as`), as #574's and #646's tests ask it. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from conftest import cloned_just_pushed, git, rmtree +from test_family import NAME, SPARE, family # noqa: F401 +from test_family_add_member_variant import ( + OTHER_ROW, OTHER_VARIANT, REMOVE, ROW, VARIANT, VARIANT_SHOWN, + dry_run_in_process, in_the_index, moved_back, record, refused_in_process, + registered, typed_as_printed, variant, +) +from test_family_add_refusals import ( # noqa: F401 + family_module, holder, index_bytes, offline, plant, refusal, + unborn_holder, +) +from test_family_head_lost_gitmodules import ( + DISKS, assert_passes_in_every_mode, +) +from test_family_members_case_variants import ( + MOUNT, NEEDS_GIT_2_34, added, commit_first, disk_as, disk_folds, + refused_untouched, remedy_of, type_remedy, +) + +#: The refusal's first line for both members' gitlinks under `Members/`. +BOTH = in_the_index(VARIANT_SHOWN, f"`{OTHER_VARIANT}` (a submodule)") +#: The same, and a file of the holder's own under `Members/`. +BOTH_AND_A_FILE = BOTH + ", `Members/README` (a file)" + +#: The free name the rename back goes through, and its two steps. +FREE = "members.renaming" + + +def two_steps(free: str = FREE) -> list[str]: + """The two `git mv` lines of the rename back, through `free`.""" + return [f"git mv -- Members {free}", f"git mv -- {free} members"] + + +#: What puts back a gitlink the disk lacks. +SUBMODULE_BACK = "git submodule update --init --recursive --" + + +def renamed_back(commit: bool, free: str = FREE, gone: tuple = (), + files: tuple = ()) -> str: + """The part of the remedy that renames `Members` back, through `free`, + putting back first each gitlink in `gone` and each file in `files`.""" + back = [f"`{command} {' '.join(names)}`" for command, names in ( + (SUBMODULE_BACK, gone), ("git checkout --", files)) if names] + first, second = two_steps(free) + return ((f"put back what the index holds under `Members` and the disk " + f"does not, {' and '.join(back)}, then " if back else "") + + "rename `Members` back to members/, since it holds the " + "gitlinks of members whose rows place them under members/, in " + "two steps through a name nothing holds, as macOS and Windows " + "take `Members` for members/ itself: `git mv` takes each " + "checkout, and each section of .gitmodules that names it, with " + f"it (`{first}`, then `{second}`)" + + (", and commit the rename" if commit else "")) + + +#: The part of the remedy for a change to `.gitmodules` nobody staged. +STAGE_FIRST = ("stage the change to .gitmodules that nobody has staged, or " + "set it aside, since `git mv` and `git rm` of a gitlink refuse " + "to run beside one (`git add -- .gitmodules`)") +#: The part of the remedy for a `.gitmodules` the work tree lacks. +RESTORE_FIRST = ("put back the copy of .gitmodules the index holds, which the " + "work tree lacks, since git edits each member's section " + "there (`git checkout -- .gitmodules`)") + + +def gitmodules_changed(root: Path) -> None: + """A change to `.gitmodules` nobody staged, a comment written as + BYTES.""" + with (root / ".gitmodules").open("ab") as out: + out.write(b"# our own note\n") + + +def renamed_with_git(root: Path, committed: bool = False) -> dict: + """`git mv members Members`, and its commit where `committed`. Returns + `{row: the commit HEAD records it at}`, as the holder began.""" + commits = {row: git("rev-parse", f"HEAD:{row}", cwd=root).stdout.strip() + for row in (ROW, OTHER_ROW)} + git("mv", "members", "Members", cwd=root) + if committed: + git("commit", "-qm", "git mv members Members", cwd=root) + return commits + + +def folding_skip(tmp_path: Path) -> None: + """`git mv members Members` renames `members/` into itself where the + disk folds case, so these states are made on one that does not.""" + if disk_folds(tmp_path): + pytest.skip("on a disk that folds case `git mv members Members` " + "renames members/ into itself") + + +def typed_with_local_remotes(monkeypatch, root: Path, said: str) -> list: + """`typed_as_printed`, with `protocol.file.allow=always` for the typing + alone: a `git submodule update --recursive` the remedy prints may clone + a member's legs, whose urls are the fixture's local paths.""" + with monkeypatch.context() as patched: + count = int(os.environ.get("GIT_CONFIG_COUNT", "0")) + patched.setenv("GIT_CONFIG_COUNT", str(count + 1)) + patched.setenv(f"GIT_CONFIG_KEY_{count}", "protocol.file.allow") + patched.setenv(f"GIT_CONFIG_VALUE_{count}", "always") + return typed_as_printed(root, said) + + +# --- 1. a members/ renamed with git mv --------------------------------------- + +def staged(root: Path) -> tuple: + """The rename staged: renamed back, nothing is left to commit.""" + return (renamed_with_git(root), BOTH, remedy_of(renamed_back(False)), + two_steps()) + + +def committed(root: Path) -> tuple: + """The rename committed: renamed back, and that committed.""" + return (renamed_with_git(root, True), BOTH, + remedy_of(renamed_back(True)), [*two_steps(), "git commit"]) + + +def staged_ignoring_case(root: Path) -> tuple: + """The rename staged with `core.ignorecase=true`, as git on macOS and + Windows runs: the same text, typed the same.""" + git("config", "--local", "core.ignorecase", "true", cwd=root) + return staged(root) + + +def committed_with_a_file(root: Path) -> tuple: + """The rename committed with a file of its own under `Members/`, written + as BYTES: it goes back with the rest, to `members/README`.""" + commits = renamed_with_git(root) + (root / "Members" / "README").write_bytes(b"our own notes\n") + git("add", "--", "Members/README", cwd=root) + git("commit", "-qm", "git mv members Members, and a README", cwd=root) + return (commits, BOTH_AND_A_FILE, remedy_of(renamed_back(True)), + [*two_steps(), "git commit"]) + + +def after_a_pending_change(root: Path) -> tuple: + """A pending change in HEAD (`MEMBERS/old` taken out of the index, not + committed) beside the staged rename: committed first, which commits the + rename too, so renaming it back has a commit to make after it.""" + plant(root, "100644", "MEMBERS/old") + git("commit", "-qm", "Track MEMBERS/old", cwd=root) + git("rm", "-q", "--cached", "--", "MEMBERS/old", cwd=root) + return (renamed_with_git(root), + BOTH + "; in HEAD and no longer in the index: `MEMBERS/old` (a " + "file)", + remedy_of(commit_first("MEMBERS/old"), renamed_back(True)), + ["git commit", *two_steps(), "git commit"]) + + +def with_the_free_name_taken(root: Path) -> tuple: + """A file the holder tracks at `members.renaming`: the rename back goes + through `members.renaming-2`.""" + (root / FREE).write_bytes(b"our own notes\n") + git("add", "--", FREE, cwd=root) + git("commit", "-qm", f"A file at {FREE}", cwd=root) + return (renamed_with_git(root), BOTH, + remedy_of(renamed_back(False, f"{FREE}-2")), + two_steps(f"{FREE}-2")) + + +def one_checkout_gone(root: Path) -> tuple: + """The rename committed, then one member's checkout deleted: `git mv` + says `bad source` for it, so it is put back first, from the section that + names `Members/IRSS` (#685's review, MUST-2).""" + commits = renamed_with_git(root, True) + rmtree(root / OTHER_VARIANT) + return (commits, BOTH, + remedy_of(renamed_back(True, gone=(OTHER_VARIANT,))), + [f"{SUBMODULE_BACK} {OTHER_VARIANT}", *two_steps(), "git commit"]) + + +def an_empty_members(root: Path) -> tuple: + """The rename staged, then both checkouts moved out of the holder, + leaving `Members/` empty: both are put back first.""" + commits = renamed_with_git(root) + for name in (VARIANT, OTHER_VARIANT): + os.rename(root / name, root.parent / name.replace("/", "-")) + return (commits, BOTH, + remedy_of(renamed_back(False, gone=(VARIANT, OTHER_VARIANT))), + [f"{SUBMODULE_BACK} {VARIANT} {OTHER_VARIANT}", *two_steps()]) + + +def nothing_of_it_on_disk(root: Path) -> tuple: + """The rename staged, then `Members/` itself moved out of the holder: + both checkouts are put back first, which makes `Members/` again.""" + commits = renamed_with_git(root) + os.rename(root / "Members", root.parent / "Members-elsewhere") + return (commits, BOTH, + remedy_of(renamed_back(False, gone=(VARIANT, OTHER_VARIANT))), + [f"{SUBMODULE_BACK} {VARIANT} {OTHER_VARIANT}", *two_steps()]) + + +def a_committed_file_deleted(root: Path) -> tuple: + """The rename committed with a file of its own under `Members/`, then + that file deleted from disk and not staged: it is checked out again + first.""" + commits, first, _, _ = committed_with_a_file(root) + (root / "Members" / "README").unlink() + return (commits, first, + remedy_of(renamed_back(True, files=("Members/README",))), + ["git checkout -- Members/README", *two_steps(), "git commit"]) + + +def gitmodules_unstaged(root: Path) -> tuple: + """The rename staged, then a change to `.gitmodules` nobody staged, a + comment written as BYTES: `git mv` of a gitlink refuses to run beside it, + so it is staged first (#685's review, SHOULD-1).""" + commits = renamed_with_git(root) + gitmodules_changed(root) + return (commits, BOTH, remedy_of(STAGE_FIRST, renamed_back(False)), + ["git add -- .gitmodules", *two_steps()]) + + +def gitmodules_deleted(root: Path) -> tuple: + """The rename committed, then `.gitmodules` deleted from the work tree + and not staged: it is put back first, never staged, since `git add` of + it would stage the deletion and the commit would then take every + member's section out of HEAD (#685's review, MUST-A).""" + commits = renamed_with_git(root, True) + (root / ".gitmodules").unlink() + return (commits, BOTH, remedy_of(RESTORE_FIRST, renamed_back(True)), + ["git checkout -- .gitmodules", *two_steps(), "git commit"]) + + +#: `{state: how it is made, returning ({row: commit}, the refusal's first +#: line, its remedy, what is typed)}`. +RENAMED = { + "staged": staged, + "committed": committed, + "staged, with core.ignorecase=true": staged_ignoring_case, + "committed, with a file of its own": committed_with_a_file, + "after a pending change in HEAD": after_a_pending_change, + "with the free name taken": with_the_free_name_taken, + "one checkout gone": one_checkout_gone, + "an empty Members/": an_empty_members, + "nothing of it on disk": nothing_of_it_on_disk, + "a committed file deleted from disk": a_committed_file_deleted, + "a change to .gitmodules nobody staged": gitmodules_unstaged, + "a .gitmodules deleted from disk": gitmodules_deleted, +} + + +@pytest.mark.parametrize("state", list(RENAMED)) +def test_members_renamed_with_git_mv_is_renamed_back_as_printed( + family, holder, offline, monkeypatch, tmp_path, state): + """LINE 1: `git mv members Members`, and `add` of another member. The + dry run and the real run refuse it alike, with the holder exactly as it + was, and say to rename `Members` back in two steps through a free name, + after putting back what the disk lacks under it and staging a change to + `.gitmodules` nobody staged, with a commit where one is left to make. + Typed as printed, both members are where their rows say, at the commits + they recorded, with their sections and checkouts, the gate passes in + every mode, the same `add` goes through, and the gate passes again.""" + folding_skip(tmp_path) + commits, first, said, typed = RENAMED[state](holder) + refused = refused_untouched(offline, holder, family["remotes"], + "--dry-run") + assert refused_untouched(offline, holder, family["remotes"]) == refused + assert refused == (first, said) + assert typed_with_local_remotes(monkeypatch, holder, said) == typed + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + assert_passes_in_every_mode(holder, 2) + added(offline, holder, family["remotes"]) + assert_passes_in_every_mode(holder, 3) + + +def test_an_untracked_file_in_it_goes_back_with_it(family, holder, offline, + tmp_path): + """An untracked file under the renamed `Members/` is the rename's to + take, so it is not named on its own: an `mv` of it printed after `git + mv` would find nothing there. Typed as printed, it is at + `members/notes.txt`, untracked, and `add` and the gate pass.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder) + (holder / "Members" / "notes.txt").write_bytes(b"our own notes\n") + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (BOTH, remedy_of(renamed_back(False))) + assert typed_as_printed(holder, said[1]) == two_steps() + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + assert (holder / "members" / "notes.txt").read_bytes() == ( + b"our own notes\n") + assert_passes_in_every_mode(holder, 2) + result = offline.add(holder, SPARE, remotes=family["remotes"]) + assert result.returncode == 0, result.stdout + result.stderr + assert git("status", "--porcelain", cwd=holder).stdout == ( + "?? members/notes.txt\n") + assert_passes_in_every_mode(holder, 3) + + +def test_a_fresh_clone_of_it_is_renamed_back_as_printed(family, holder, + offline, tmp_path): + """The committed rename pushed, and a fresh recursive clone of it, its + checkouts under `Members/`: the same text, and typed as printed the + clone's gate passes, the same `add` goes through, and the gate passes + again.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder, True) + remote = family["remotes"] / f"{NAME}.git" + branch = "w685/renamed-with-git" + git("push", "-q", str(remote), f"HEAD:refs/heads/{branch}", cwd=holder) + clone = cloned_just_pushed(remote, tmp_path / "clone" / NAME, branch) + said = refused_untouched(offline, clone, family["remotes"], "--dry-run") + assert said == (BOTH, remedy_of(renamed_back(True))) + assert typed_as_printed(clone, said[1]) == [*two_steps(), "git commit"] + for row, commit in commits.items(): + registered(clone, family["root"], commit, row) + assert_passes_in_every_mode(clone, 2) + added(offline, clone, family["remotes"]) + assert_passes_in_every_mode(clone, 3) + + +@pytest.mark.parametrize("folding", [False, True], ids=["linux", "folding"]) +def test_every_disk_gets_the_same_rename_back( + family, holder, family_module, offline, monkeypatch, capsys, + tmp_path, folding): + """ONE TEXT ON EVERY DISK. Asked in process of the same layout, a disk + that does not fold case holds no `members/`, and one that folds case + takes `Members` for `members/` itself: both get the two steps, since + neither step's destination is on either disk when it runs.""" + folding_skip(tmp_path) + renamed_with_git(holder) + disk_as(monkeypatch, holder, folding) + assert dry_run_in_process(family_module, monkeypatch, offline, holder, + family["remotes"], capsys) == ( + BOTH, remedy_of(renamed_back(False))) + + +@pytest.mark.parametrize("folding", [False, True], ids=["linux", "folding"]) +def test_a_folding_disk_that_received_it_renames_it_back( + family, holder, family_module, offline, monkeypatch, capsys, + tmp_path, folding): + """THE RENAME RECEIVED: the index holds `Members/IRRS` and + `Members/IRSS`, and the directory on disk is spelled `members`, as a + pull leaves it on a disk that folds case. There `Members` is that very + directory, and the two steps are printed. On Linux `Members` is not on + disk at all, a second spelling `git mv` cannot find, and #646's move + back is printed, as before.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder) + os.rename(holder / "Members", holder / "members") + disk_as(monkeypatch, holder, folding) + move, closing = moved_back({ROW: ([VARIANT], commits[ROW]), + OTHER_ROW: ([OTHER_VARIANT], + commits[OTHER_ROW])}, + commit=False) + assert dry_run_in_process(family_module, monkeypatch, offline, holder, + family["remotes"], capsys) == ( + BOTH, remedy_of(renamed_back(False)) if folding + else remedy_of(move, *closing)) + + +def members_beside_it(root: Path) -> None: + """A `members/` on disk beside `Members/`, holding a file: the second + step would go into it, `members/members.renaming`.""" + (root / "members").mkdir() + (root / "members" / "notes").write_bytes(b"our own notes\n") + + +def head_alone_holds_one(root: Path) -> None: + """The rename committed, then one member's gitlink taken out of the + index and not committed: the rename back would leave it out.""" + git("commit", "-qm", "git mv members Members", cwd=root) + git("rm", "-q", "--cached", "--", OTHER_VARIANT, cwd=root) + + +def unmerged_under_it(root: Path) -> None: + """One member's gitlink under `Members/` unmerged, at stages 1, 2 and 3 + and at no stage 0, with nothing in progress: `git mv` says `source + directory is empty` (#685's review, SHOULD-1).""" + commit = git("rev-parse", f":{OTHER_VARIANT}", cwd=root).stdout.strip() + git("rm", "-q", "--cached", "--", OTHER_VARIANT, cwd=root) + written = subprocess.run( + ["git", "update-index", "--index-info"], cwd=str(root), + input="".join(f"160000 {commit} {stage}\t{OTHER_VARIANT}\n" + for stage in (1, 2, 3)).encode("ascii"), + capture_output=True, check=False) + assert written.returncode == 0, written.stderr + + +def gone_where_no_section_names_it(root: Path) -> None: + """One member's checkout deleted, and its section of `.gitmodules` + giving `members/IRSS`, staged: no `git submodule update` can put + `Members/IRSS` back, so nothing is renamed back (#685's review, MUST-2, + its fallback).""" + data = (root / ".gitmodules").read_bytes() + assert data.count(b"path = Members/IRSS") == 1, data + (root / ".gitmodules").write_bytes( + data.replace(b"path = Members/IRSS", b"path = members/IRSS")) + git("add", "--", ".gitmodules", cwd=root) + rmtree(root / OTHER_VARIANT) + + +def a_file_where_a_checkout_was(root: Path) -> None: + """One member's checkout deleted, and a file, written as BYTES, where + it was: the disk holds the path, and not as the index holds it.""" + rmtree(root / OTHER_VARIANT) + (root / OTHER_VARIANT).write_bytes(b"our own notes\n") + + +def a_directory_where_a_file_is(root: Path) -> None: + """A file `Members/README` in the index, and a directory there on disk: + the disk holds the path, and not as the index holds it.""" + plant(root, "100644", "Members/README") + (root / "Members" / "README").mkdir() + (root / "Members" / "README" / "inner").write_bytes(b"our own notes\n") + + +def no_symlink_on_windows() -> None: + """A symlink needs a privilege Windows runners do not grant.""" + if os.name == "nt": + pytest.skip("a symlink needs a privilege Windows runners do not grant") + + +def members_a_link_to_it(root: Path) -> None: + """A link `members -> Members`: no disk's `members/` itself, and the + second step would say `destination already exists` and leave the holder + half renamed (#685's review, MUST-B).""" + no_symlink_on_windows() + os.symlink("Members", root / "members", target_is_directory=True) + + +def it_a_link_outside(root: Path) -> None: + """`Members` a link to a directory outside the holder, holding the + checkouts: `git mv` of it says `not under version control` (#685's + review, SHOULD-A).""" + no_symlink_on_windows() + os.rename(root / "Members", root.parent / "Members-real") + os.symlink(os.path.join("..", "Members-real"), root / "Members", + target_is_directory=True) + + +#: `{state: how it is laid out after `git mv members Members`, staged}`. +NOT_RENAMED_BACK = { + "a members/ on disk beside it": members_beside_it, + "an entry the index holds under members/": ( + lambda root: plant(root, "100644", "members/notes")), + "a file at members in the index": ( + lambda root: plant(root, "100644", "members")), + "another spelling in the index too": ( + lambda root: plant(root, "100644", "MEMBERS/notes")), + "a case variant of the mount path under it": ( + lambda root: plant(root, "100644", f"Members/{SPARE}")), + "an entry under it a sparse checkout keeps off disk": ( + lambda root: git("update-index", "--skip-worktree", "--", + OTHER_VARIANT, cwd=root)), + "a member's gitlink HEAD alone holds": head_alone_holds_one, + "an entry under it unmerged": unmerged_under_it, + "a checkout gone that no section names there": ( + gone_where_no_section_names_it), + "a file where a checkout was": a_file_where_a_checkout_was, + "a directory where a file is": a_directory_where_a_file_is, + "a link members to Members": members_a_link_to_it, + "Members a link to a directory outside": it_a_link_outside, +} + + +@pytest.mark.parametrize("state", list(NOT_RENAMED_BACK)) +def test_only_members_renamed_whole_is_renamed_back(holder, family_module, + tmp_path, state): + """The rename back is printed only where it types clean and leaves + nothing out: no `members/` on disk but that spelling's own directory, + nothing in the index at or under `members` itself or under a second + spelling, no case variant of the mount path in it, nothing under it + kept off disk or unmerged, no member's gitlink that HEAD alone holds + there, and every entry under it on disk as the index holds it or able + to be put back. Elsewhere each path keeps its own remedy (the move back + of a member's own gitlink, or #574's), never `git mv back`.""" + folding_skip(tmp_path) + renamed_with_git(holder) + NOT_RENAMED_BACK[state](holder) + rows = family_module._rows_paths(family_module.Family.open(holder).members) + found = family_module._members_spellings( + holder, SPARE, family_module._members_on_disk(holder), rows) + assert "git mv back" not in [spelled.remedy for spelled in found] + assert {spelled.remedy for spelled in found + if spelled.kind == "a submodule"} == {"git update-index"} + + +def test_nothing_in_it_a_members_own_keeps_its_own_remedy(family_module, + tmp_path): + """A `Members/` that holds no member's own gitlink is no `members/` + renamed: a committed `Members/README` alone, on disk, keeps #574's + committed `git mv`.""" + root = unborn_holder(tmp_path) + (root / "Members").mkdir() + (root / "Members" / "README").write_bytes(b"our own notes\n") + git("add", "--", "family.yaml", "Members/README", cwd=root) + git("commit", "-qm", "Notes", cwd=root) + found = family_module._members_spellings( + root, SPARE, family_module._members_on_disk(root), frozenset()) + assert [(spelled.target, spelled.remedy) for spelled in found] == [ + ("Members/README", "git mv")] + + +def beside_the_move_back(root: Path) -> str: + """#646's state, IRRS's gitlink moved to `Members/IRRS` in the index + alone, and a change to `.gitmodules` nobody staged: staged first, as + `git rm` of a gitlink refuses beside it too. Returns the remedy.""" + commit = variant(root) + gitmodules_changed(root) + move, closing = moved_back({ROW: ([VARIANT], commit)}) + return remedy_of(STAGE_FIRST, move, *closing) + + +def beside_a_file_alone(root: Path) -> str: + """A file `Members/README` in the index, and a change to `.gitmodules` + nobody staged: `git rm --cached` takes the file out whatever + `.gitmodules` holds, so nothing is said of it.""" + plant(root, "100644", "Members/README") + gitmodules_changed(root) + return remedy_of( + "take what has nothing on disk in that spelling out of the index, " + "which touches no file (`git rm -r --cached -- Members/README`)") + + +def gitmodules_kept_off_disk(root: Path) -> str: + """#646's state, and a `.gitmodules` a sparse checkout keeps off disk: + `git checkout --` of it matches nothing, and it is `gitmodules-sparse`'s + to name, so nothing is said of it here.""" + commit = variant(root) + git("update-index", "--skip-worktree", "--", ".gitmodules", cwd=root) + (root / ".gitmodules").unlink() + move, closing = moved_back({ROW: ([VARIANT], commit)}) + return remedy_of(move, *closing) + + +#: `{state: how it is made, returning the remedy}`. +GITMODULES_STATES = { + "a change beside a gitlink": beside_the_move_back, + "a change beside a file alone": beside_a_file_alone, + "a copy a sparse checkout keeps off disk": gitmodules_kept_off_disk, +} + + +@pytest.mark.parametrize("state", list(GITMODULES_STATES)) +def test_what_gitmodules_needs_first_is_said_beside_a_gitlink( + holder, family_module, state): + """What the work tree's `.gitmodules` needs before `git mv` or `git rm` + of a gitlink is said first wherever the index holds a gitlink the + refusal names, and only what git can type: nothing beside a file alone, + nor for a copy a sparse checkout keeps off disk.""" + said = GITMODULES_STATES[state](holder) + assert refused_in_process(family_module, holder)[1] == said + + +def test_no_gitmodules_in_the_index_adds_nothing_first(family_module, + tmp_path): + """A holder whose index holds no `.gitmodules` at all, and a gitlink + under `Members/`: nothing is said of the file.""" + root = unborn_holder(tmp_path) + plant(root, "160000", "Members/X") + with pytest.raises(family_module.Refusal) as caught: + family_module._checked_mount_path(root, SPARE, MOUNT, frozenset()) + assert str(caught.value).splitlines()[1] == remedy_of( + "take what has nothing on disk in that spelling out of the index, " + "which touches no file (`git rm -r --cached -- Members/X`)") + + +def test_a_missing_gitmodules_is_put_back_before_the_move_back( + family, holder, offline): + """#646's state, and `.gitmodules` deleted from the work tree and not + staged: put back first, never staged. Typed as printed, HEAD keeps + every section, IRRS is where its row says, the gate passes in every + mode, and the same `add` goes through with every member registered + (#685's review, MUST-A).""" + commit = variant(holder) + (holder / ".gitmodules").unlink() + move, closing = moved_back({ROW: ([VARIANT], commit)}) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (in_the_index(VARIANT_SHOWN), + remedy_of(RESTORE_FIRST, move, *closing)) + assert typed_as_printed(holder, said[1]) == [ + "git checkout -- .gitmodules", REMOVE, record(commit), "git commit"] + registered(holder, family["root"], commit) + assert_passes_in_every_mode(holder, 2) + added(offline, holder, family["remotes"]) + assert_passes_in_every_mode(holder, 3) + + +def unmerged_gitmodules(root: Path) -> bytes: + """`.gitmodules` unmerged at stages 1, 2 and 3, the other side adding a + comment, and its work-tree copy holding conflict markers, with nothing + in progress, as a conflicted `git stash pop` leaves it; all written as + BYTES. Returns this side's copy, the resolution the test takes.""" + ours = (root / ".gitmodules").read_bytes() + base = git("rev-parse", ":.gitmodules", cwd=root).stdout.strip() + theirs = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], cwd=str(root), + input=ours + b"# theirs\n", capture_output=True, + check=True).stdout.decode("ascii").strip() + written = subprocess.run( + ["git", "update-index", "--index-info"], cwd=str(root), + input=(f"0 {'0' * 40}\t.gitmodules\n100644 {base} 1\t.gitmodules\n" + f"100644 {base} 2\t.gitmodules\n" + f"100644 {theirs} 3\t.gitmodules\n").encode("ascii"), + capture_output=True, check=False) + assert written.returncode == 0, written.stderr + (root / ".gitmodules").write_bytes( + ours + b"<<<<<<< Updated upstream\n=======\n# theirs\n" + b">>>>>>> Stashed changes\n") + return ours + + +def test_an_unmerged_gitmodules_is_named_first(family, holder, offline, + monkeypatch, tmp_path): + """The rename committed, and `.gitmodules` unmerged, its work-tree copy + holding conflict markers: `gitmodules-unstaged` names it first, with its + own restore, since `git add` of it stages the markers and `git mv` then + says `bad config line` (#685's review, MUST-A). The index is left as it + was. With the conflict resolved to this side and staged as printed, the + rename back is printed and types through: every member is where its + row says, the gate passes in every mode, and the same `add` goes + through.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder, True) + ours = unmerged_gitmodules(holder) + index = index_bytes(holder) + result = offline.add(holder, SPARE, remotes=family["remotes"]) + assert result.returncode == 2, result.stdout + result.stderr + first, said = refusal(result.stderr) + assert first.startswith("REFUSED gitmodules-unstaged: .gitmodules is " + "unmerged in the index.") + assert said == ("Remediation: resolve the conflict in it and stage the " + "result, `git add .gitmodules`, then run the same " + "command again.") + assert index_bytes(holder) == index + (holder / ".gitmodules").write_bytes(ours) + git("add", ".gitmodules", cwd=holder) + refused = refused_untouched(offline, holder, family["remotes"]) + assert refused == (BOTH, remedy_of(renamed_back(True))) + assert typed_with_local_remotes(monkeypatch, holder, refused[1]) == [ + *two_steps(), "git commit"] + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + assert_passes_in_every_mode(holder, 2) + added(offline, holder, family["remotes"]) + assert_passes_in_every_mode(holder, 3) + + +#: `{state: (what holds a name like the free one, the name chosen)}`. +FREE_NAMES = { + "nothing": ((), (), FREE), + "a file on disk": ((FREE,), (), f"{FREE}-2"), + "an entry in the index, in another case": ((), ("MEMBERS.RENAMING/x",), + f"{FREE}-2"), + "both, and the second on disk": ((f"{FREE}-2",), (FREE,), f"{FREE}-3"), +} + + +@pytest.mark.parametrize("state", list(FREE_NAMES)) +def test_the_free_name_is_one_nothing_holds(family_module, tmp_path, state): + """`_free_name`: `members.renaming`, or the first numbered variant that + neither the disk nor the index holds, the index asked in any case.""" + on_disk_names, in_index, chosen = FREE_NAMES[state] + for name in on_disk_names: + (tmp_path / name).write_bytes(b"x\n") + assert family_module._free_name( + tmp_path, {name: [] for name in in_index}) == chosen + + +# --- 2. a folding disk that pulled Members/README ---------------------------- + +def pulled_members_readme(root: Path, ignorecase: str) -> None: + """What a disk that folds case holds after pulling a commit that adds + `Members/README` beside the members: the file in the holder's own + `members/`, as `members/README`, written as BYTES, while the index and + HEAD hold `Members/README`, the blob of those bytes. git there runs with + `core.ignorecase=true`; asked both ways (`DISKS`, as #611's tests ask + it).""" + git("config", "--local", "core.ignorecase", ignorecase, cwd=root) + (root / "members" / "README").write_bytes(b"a file\n") + plant(root, "100644", "Members/README") + git("commit", "-qm", "Members/README, as pulled", cwd=root) + + +@DISKS +def test_a_folding_disk_that_pulled_members_readme_takes_it_out( + family, holder, family_module, offline, monkeypatch, capsys, + ignorecase): + """LINE 2, CLOSED BY THIS TEST: the remedy is `git rm -r --cached -- + Members/README` and a commit, where nothing is on disk in that spelling. + Each disk, emulated in process (`disk_as`), gives the subprocess's text. + Typed as printed, the file stays on disk, byte for byte, at + `members/README`, untracked, the gate passes in every mode, `add` goes + through, and the gate passes again; committing the file then, under + `members/`, still gives `family ok`.""" + pulled_members_readme(holder, ignorecase) + said = refused_untouched(offline, holder, family["remotes"]) + assert said[1] == remedy_of( + "take what has nothing on disk in that spelling out of the index, " + "which touches no file (`git rm -r --cached -- Members/README`), " + "and commit the removal") + for folding in (False, True): + with monkeypatch.context() as patched: + disk_as(patched, holder, folding) + assert dry_run_in_process(family_module, patched, offline, + holder, family["remotes"], + capsys) == said + assert type_remedy(holder, said[1]) == [ + "git rm -r --cached -- Members/README", "git commit"] + assert (holder / "members" / "README").read_bytes() == b"a file\n" + assert git("status", "--porcelain", cwd=holder).stdout == ( + "?? members/README\n") + assert_passes_in_every_mode(holder, 2) + result = offline.add(holder, SPARE, remotes=family["remotes"]) + assert result.returncode == 0, result.stdout + result.stderr + assert git("ls-tree", "HEAD", MOUNT, cwd=holder).stdout.startswith( + "160000 commit ") + assert_passes_in_every_mode(holder, 3) + git("add", "--", "members/README", cwd=holder) + git("commit", "-qm", "members/README", cwd=holder) + assert_passes_in_every_mode(holder, 3) + + +# --- 3. --sparse says the git it needs --------------------------------------- + +def test_the_move_backs_sparse_says_the_git_it_needs(holder, family_module): + """LINE 3, for the move back's `git rm --sparse` (#646's S1): asked on + any git, the text says it needs git 2.34 or later, right after the + command, and only there. (`git mv --sparse`, #574's, which needs 2.38 + for the one entry it names, is asked in + `test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse`.)""" + commit = variant(holder) + git("update-index", "--skip-worktree", "--", VARIANT, cwd=holder) + move, closing = moved_back({ROW: ([VARIANT], commit)}, sparse=True) + assert f"--ignore-unmatch -- {VARIANT}`, {NEEDS_GIT_2_34}, then " in move + assert refused_in_process(family_module, holder)[1] == remedy_of( + move, *closing) diff --git a/tests/test_family_members_case_variants.py b/tests/test_family_members_case_variants.py index 0b97561d..b66cf9fc 100644 --- a/tests/test_family_members_case_variants.py +++ b/tests/test_family_members_case_variants.py @@ -85,6 +85,12 @@ "Remediation: this is a half-finished add or a hand-made directory. " "Remove it (`git rm -r` if it is tracked), then re-run.") +#: What a printed `--sparse` says it needs (#685): `git mv --sparse` of the +#: one entry a sparse checkout keeps off disk says `bad source` before git +#: 2.38, and `git rm --sparse` came with git 2.34. +NEEDS_GIT_2_38 = "which needs git 2.38 or later" +NEEDS_GIT_2_34 = "which needs git 2.34 or later" + #: The part of the remedy for a `members/` a hand renamed `Members/`. MOVE_BACK = ("move `Members` back to members/, since it holds checkouts of " "members git tracks under members/ (`mv -- Members members`)") @@ -104,7 +110,7 @@ def index_part(*, rename=(), sparse=(), unstage=(), commit="") -> str: f"entry ({each('git mv', rename)})") if sparse: said.append("rename what the sparse checkout keeps off disk with " - "`git mv --sparse`, entry by entry " + f"`git mv --sparse`, {NEEDS_GIT_2_38}, entry by entry " f"({each('git mv --sparse', sparse)})") if unstage: said.append("take what has nothing on disk in that spelling out of " @@ -327,21 +333,23 @@ def git_version() -> tuple[int, int]: return int(found.group(1)), int(found.group(2)) -@pytest.mark.skipif(git_version() < (2, 34), - reason="`git mv --sparse` came with git 2.34") def test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse( family, holder, offline): """A SPARSE CHECKOUT (#574's review, S2): an entry the checkout keeps off disk (`skip-worktree`) is refused by `git mv` as `not under version control` and by `git rm --cached` as outside the sparse-checkout definition. `git mv --sparse` renames it, keeping what it holds, and the - rename is committed.""" + rename is committed. The text says that needs git 2.38 or later, on + every git (#685); it is typed only there.""" plant(holder, "100644", "Members/README") git("update-index", "--skip-worktree", "--", "Members/README", cwd=holder) said, remedy = refused_untouched(offline, holder, family["remotes"]) assert said == DIR_VARIANT + "in the index: `Members/README` (a file)" assert remedy == remedy_of(index_part(sparse=["Members/README"], commit="the rename")) + if git_version() < (2, 38): + pytest.skip("`git mv --sparse` of one entry kept off disk says `bad " + "source` before git 2.38") assert type_remedy(holder, remedy) == [ "git mv --sparse -- Members/README renamed-1", "git commit"] added(offline, holder, family["remotes"]) @@ -449,7 +457,7 @@ def index_info(line: bytes) -> None: ("Members/\N{REPLACEMENT CHARACTER}", "a file", "in HEAD and no longer in the index", "Members/\N{REPLACEMENT CHARACTER}", False, "git commit", True, "", - False)] + False, "", False)] # --- the disk (#364) ---------------------------------------------------------