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) ---------------------------------------------------------