diff --git a/scripts/family.py b/scripts/family.py index 8050172f..1ee47c41 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2642,15 +2642,19 @@ 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. 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 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" +RESTORE = "git update-index" MOVE = "mv" MOVE_BACK = "mv back" -REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, MOVE, MOVE_BACK) +REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, RESTORE, MOVE, + MOVE_BACK) class Spelled(NamedTuple): @@ -2662,7 +2666,15 @@ class Spelled(NamedTuple): acts on, `mount`, whether it is at or under a case variant of the mount path itself, `remedy`, which of `REMEDY_ORDER` acts on it, and `committed`, whether HEAD holds it too, so that taking it out of the - index is a change to commit.""" + index is a change to commit. + + A MEMBER'S OWN GITLINK (`RESTORE`, #646) has its row's path as `target`, + `committed` says whether the move leaves anything to commit, `recorded` + is the commit it records, at which the move records it back, and + `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.""" name: str kind: str @@ -2671,6 +2683,8 @@ class Spelled(NamedTuple): mount: bool remedy: str committed: bool = False + recorded: str = "" + sparse: bool = False def _folded(name: str) -> str: @@ -2894,10 +2908,11 @@ def _tree_entries(root: Path, tree: str, return entries -def _head_spellings(root: Path) -> list[tuple[str, str]]: - """`[(path, kind)]` of every file and gitlink HEAD holds at or under a - spelling of `members` (`members/` itself included), or none in a holder - with NO COMMIT YET (#363). +def _head_spellings(root: Path) -> list[tuple[str, str, str]]: + """`[(path, kind, object)]` of every file and gitlink HEAD holds at or + under a spelling of `members` (`members/` itself included), or none in a + holder with NO COMMIT YET (#363). A gitlink's object is the commit it + records (#646). HEAD's root tree is listed, and each entry there whose name folds to `members` is listed through by its object, so nothing else of HEAD is @@ -2909,26 +2924,43 @@ def _head_spellings(root: Path) -> list[tuple[str, str]]: cwd=root) except Refusal: return [] - held: list[tuple[str, str]] = [] + held: list[tuple[str, str, str]] = [] for name, kind, oid in _tree_entries(root, tree): if _folded(name) != MEMBERS_DIR: continue if kind != "a tree": - held.append((name, kind)) + held.append((name, kind, oid)) continue - held.extend((f"{name}/{below}", what) - for below, what, _ in _tree_entries(root, oid, True)) + held.extend((f"{name}/{below}", what, at) + for below, what, at in _tree_entries(root, oid, True)) return held def _members_spellings(root: Path, project: str, - on_disk: dict[str, tuple[str, list[str] | None]] - ) -> list[Spelled]: + on_disk: dict[str, tuple[str, list[str] | None]], + rows: frozenset[str] = frozenset()) -> list[Spelled]: """Every path the holder has at or under a spelling of `members/` other than `members/` itself, or at or under a case variant of the mount path `members/`: in the index, in HEAD and no longer in the index, and on disk untracked (#362, #363, #364), each with the remedy that acts - on it. `on_disk` is `_members_on_disk`'s listing. + on it. `on_disk` is `_members_on_disk`'s listing, and `rows` the path of + each `members:` row (`_rows_paths`). + + A MEMBER'S OWN GITLINK HELD IN THE WRONG CASE (#646) is moved back, never + taken out or renamed. Where the index holds nothing at or under a row's + path, a gitlink at a case variant of that path, in the index or in HEAD + alone, is that member's (`Members/

` for the row `members/

`, #363's + state after the fact). `git rm --cached` of it, or a committed `git mv` + to another name, took it away from its row, and `git mv` to the row's + path fails or misplaces it (`bad source`; `members/

/

`; `renaming + ... failed`), so it is `RESTORE`: taken out of the index and recorded at + the row's path, at the commit it records there, by `git update-index`, + which reads no disk, as `validate-family.py` gives the same move (#611). + An unmerged one is recorded at its stage 2, and is no member's own where + it has no gitlink there. Where the index does hold something at or + under the row's path, a gitlink at a variant is a second one, which + 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. 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` @@ -2946,17 +2978,29 @@ def _members_spellings(root: Path, project: str, remedies act on one path. """ canon = _members_dir_on_disk(root, on_disk) - indexed: dict[str, int] = { - name.decode("utf-8", "surrogateescape"): int(records[0].split()[0], 8) - for name, records in _index_records(root).items()} + listed = {name.decode("utf-8", "surrogateescape"): + [record.split() for record in records] + for name, records in _index_records(root).items()} + indexed: dict[str, int] = {name: int(fields[0][0], 8) + for name, fields in listed.items()} gitlinks = {name for name, mode in indexed.items() if mode == 0o160000} occupied = frozenset( _folded(rest.partition("/")[0]) for first, _, rest in (name.partition("/") for name in indexed) if first == MEMBERS_DIR and rest) head = _head_spellings(root) - in_head = {name for name, _ in head} + in_head = {name: oid for name, _, oid in head} + # Each row's path the index holds nothing at or under, by its folded + # spelling: a gitlink at another spelling of it is that member's own. + # The move would replace a file there without a word, and a file under + # it would stop the move's second line after the first had taken the + # variant out (`appears as both a file and as a directory`; #646's + # review, N3). + strays = {_folded(row): row for row in rows + if not any(name == row or name.startswith(row + "/") + for name in indexed)} found: list[Spelled] = [] + restored: list[str] = [] sparse: set[str] | None = None for name, mode in indexed.items(): spelled = _spelled_target(name, project, occupied) @@ -2964,6 +3008,26 @@ def _members_spellings(root: Path, project: str, continue target, mount = spelled kind = INDEX_ENTRY_KINDS.get(mode, "a file") + # The record it is moved back at: its own, or, unmerged, the one at + # stage 2, the holder's own side, where git lists stage 1, the merge + # base, first (#646's review, N2). With no gitlink there, it is no + # member's own. + ours = next((fields for fields in listed[name] + if fields[2] in (b"0", b"2")), None) + own = (strays.get(_folded(name)) + if ours is not None and ours[0] == b"160000" else None) + if own is not None: + # The move leaves nothing to commit only where HEAD holds no + # such variant and records the gitlink at the row's path + # already, at this commit: a move to the variant, staged alone. + recorded = ours[1].decode("ascii") + if sparse is None: + sparse = _skip_worktree(root) + found.append(Spelled(_shown(name), kind, IN_INDEX, _shown(own), + mount, RESTORE, + name in in_head or in_head.get(own) + != recorded, recorded, name in sparse)) + continue if target != canon and _on_disk_as_named( root, target, target != name or mode == 0o160000): remedy = RENAME @@ -2974,17 +3038,27 @@ def _members_spellings(root: Path, project: str, target = name if name in sparse else target found.append(Spelled(_shown(name), kind, IN_INDEX, _shown(target), mount, remedy, name in in_head)) - for name, kind in head: + for name, kind, oid in head: spelled = _spelled_target(name, project, occupied) - if spelled is not None and name not in indexed: + if spelled is None or name in indexed: + continue + own = (strays.get(_folded(name)) + if kind == INDEX_ENTRY_KINDS[0o160000] else None) + if own is not None: + restored.append(name) found.append(Spelled(_shown(name), kind, IN_HEAD_ONLY, - _shown(spelled[0]), spelled[1], COMMIT, - True)) + _shown(own), spelled[1], RESTORE, True, + oid)) + continue + found.append(Spelled(_shown(name), kind, IN_HEAD_ONLY, + _shown(spelled[0]), spelled[1], COMMIT, True)) + # 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. 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): + for name in [*indexed, *restored]): continue found.append(Spelled(_shown(path), kind, ON_DISK, _shown(path), _spelled_target(path, project)[1], remedy)) @@ -3012,9 +3086,27 @@ 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 `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 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). + + 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 + variant, which touches no file, and is no error where only HEAD holds + one, with `--sparse` where a sparse checkout keeps one off disk, since + without it git passes that one over, exit 0, and the index ends with two + gitlinks; a `git update-index --add --cacheinfo` per row, at the commit + the variant records, the first found where a row has two, the index's + before HEAD's; and the commit, where it leaves one to make. Its closing + sentences, after every other remedy, so that a `members/` moved back has + taken the checkout with it, are the validator's too: where nothing is + checked out at the row's path, as in a fresh clone, `git submodule + update --init --recursive` checks the member out there; on a + case-sensitive disk the directory left at the variant is moved out of + the holder, an `mv` with nothing to fill in, or this refusal names it + on the next run; on macOS and Windows it is the row's own directory, + and is left. The text is the same on every disk. """ mount = any(spelled.mount for spelled in found) groups = [] @@ -3070,6 +3162,49 @@ def each(command: str, targets: list[str]) -> str: "the rename" if renamed else "the removal") if index: parts.append(", ".join(index)) + # 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 + if spelled.remedy == RESTORE and spelled.target == row] + for row in by[RESTORE]} + variants = sorted(spelled.name for held in moved.values() + for spelled in held) + closing: list[str] = [] + if moved: + pairs = " and ".join(f"`{name}` for the row {row}" for row in moved + for name in sorted(spelled.name + for spelled in moved[row])) + recorded = " and ".join( + "`git update-index --add --cacheinfo " + f"{shell_quoted(f'160000,{held[0].recorded},{row}', 'posix')}`" + for row, held in moved.items()) + taken_out = " ".join(shell_quoted(name, "posix") for name in variants) + # `--sparse` only where a variant needs it, so that everywhere else + # the line is the validator's word for word (#646's review, S1). + flag = " --sparse" if any(spelled.sparse for held in moved.values() + for spelled in held) else "" + parts.append( + "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})" + + (", and commit that" if by[COMMIT] or any( + spelled.committed for held in moved.values() + for spelled in held) else "")) + out = " and ".join( + f"`mv -- {shell_quoted(name, 'posix')} " + f"{shell_quoted('../' + name.replace('/', '-'), 'posix')}`" + for name in variants) + closing = [ + f"where nothing is checked out at {' or '.join(moved)}, as in a " + "fresh clone, `git submodule update --init --recursive` then " + "checks the member out there", + "on a case-sensitive disk, as Linux's usually is, a directory " + f"left at {' or '.join(variants)} stays there, untracked, and add " + "refuses it as family-members-dir-variant: move each one that is " + f"there out of the holder, {out}", + "on macOS and Windows it is the row's own directory, so leave it"] if by[MOVE]: parts.append("move what is on disk and untracked out of the way " f"({each(MOVE, by[MOVE])})") @@ -3078,6 +3213,8 @@ def each(command: str, targets: list[str]) -> str: f"move `{target}` back to {MEMBERS_DIR}/, since it holds " f"checkouts of members git tracks under {MEMBERS_DIR}/ (`mv -- " f"{shell_quoted(target, 'posix')} {MEMBERS_DIR}`)") + # Last, so that a `members/` moved back has taken the checkout with it. + parts.extend(closing) said = "; ".join(parts) return Refusal( "member-path-taken" if mount else "family-members-dir-variant", @@ -3190,9 +3327,27 @@ def _members_is_a_file(root: Path, path: str) -> Refusal: + ", and run the same command again.") -def _checked_mount_path(root: Path, project: str, path: str) -> None: +def _rows_paths(rows: list[dict]) -> frozenset[str]: + """The path of every `members:` row that has one of its own (#646): the + one path `_member_path` gives it, `members/`. A row it refuses + is `member-path`'s, and has none to be moved back to.""" + paths = set() + for row in rows: + project = row.get("project") + if not isinstance(project, str): + continue + try: + paths.add(_member_path(project, row)) + except Refusal: + continue + return frozenset(paths) + + +def _checked_mount_path(root: Path, project: str, path: str, + rows: frozenset[str] = frozenset()) -> None: """Every refusal about the MOUNT PATH, made before the member is read or - anything is mounted (#242). + anything is mounted (#242). `rows` is `_rows_paths` of the holder's + `members:` rows. `git submodule add` makes each of these refusals on its own. But the mount passes `--force`, to get past the operator's ignore rules (see @@ -3228,7 +3383,10 @@ def _checked_mount_path(root: Path, project: str, path: str) -> None: clone of it is made on the other two; the cost is that such a holder renames it first. `member-path-taken` where a path is a case variant of the mount path, `family-members-dir-variant` where it is under a - spelling of `members/` alone. + spelling of `members/` alone. A member's own gitlink held only under + another spelling of its row's path keeps that code, since the add is + refused for it as for any path there, and its remedy moves it back to + the row's path instead (#646). - A FILE NAMED `members` (#612, 574/5). Git's own dry run, below, does not refuse it (measured on git 2.43), and the mount's `git submodule add --force` then dies cloning into it (`could not create leading @@ -3326,7 +3484,7 @@ def _checked_mount_path(root: Path, project: str, path: str) -> None: f"spelling other than the {path} that {GITMODULES} and " f"{MANIFEST} say, or both; then ") + "re-run.") - spelled = _members_spellings(root, project, on_disk) + spelled = _members_spellings(root, project, on_disk, rows) if spelled: raise _spellings_refusal(path, spelled) # A DIRECTORY NO LISTING COULD READ hides nothing: `lexists` asks the @@ -3452,7 +3610,8 @@ def _checked_add_target(family: Family, repository: str, project: str, "`remove` takes it out. Adding it twice is not a way to move it.") # Everything the mount's `--force` would otherwise let past git, but the # cached store, which is asked below (#242). - _checked_mount_path(family.root, project, path) + _checked_mount_path(family.root, project, path, + _rows_paths(family.members)) # Then a work tree's `.gitmodules` that is not the index's copy (#407): # `git submodule add` writes the member's section into it and stages all # of it, so a change nobody staged went into the commit too. Asked after diff --git a/tests/test_family_add_member_variant.py b/tests/test_family_add_member_variant.py new file mode 100644 index 00000000..d90877be --- /dev/null +++ b/tests/test_family_add_member_variant.py @@ -0,0 +1,770 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`family.py add`'s refusal of a member's own gitlink held in the wrong case +moves it back to its row's path, and never takes it away from the row (#646). + +THE STATE: a member's ONLY gitlink is at `Members/IRRS`, while its row and +`.gitmodules` say `members/IRRS`, as a holder committed in #363's state +holds it. `add` of ANOTHER member is refused, as #574 rules +(`family-members-dir-variant`), and its remedy used to be #574's for any +path under another spelling of `members/`: `git rm -r --cached -- Members/IRRS` +where nothing is on disk in that spelling, `git mv -- Members/IRRS ` +where something is, and `git commit` where only HEAD holds it. Each one, +typed as printed, took the member's gitlink away from its row, and the +holder's validator then said `member-gitlink-absent`. + +THE REMEDY NOW is the index-only move `validate-family.py` gives the same +state (#611), which Brett Heap accepted on 2026-10-16 ("Accept; merge #639 +after #637") in place of the "committed `git mv`" of his "Refuse on every +disk" ruling, since `git mv` to the row's path fails or misplaces the +gitlink: `git rm -r --cached --ignore-unmatch` of the variant, `git +update-index --add --cacheinfo 160000,,members/IRRS` at the commit +the variant records, and a commit. Then, as the validator says, `git +submodule update --init --recursive` where nothing is checked out at the +row's path, an `mv` out of the holder of what a case-sensitive disk leaves at +the variant, and nothing to do on macOS and Windows, where that directory is +the row's own. + +EVERY PRINTED COMMAND IS TYPED AS PRINTED (`typed_as_printed`), in each +layout rv611b's review of #639 used, and the holder's gate then passes, the +member is still where its row says, and the same `add` goes through. The +text is the same on every disk: asked in process of an emulated folding disk +and of a Linux one (`disk_as`), it does not change. Layouts that need two +directories differing only in case are made on a disk that does not fold +case alone. +""" + +from __future__ import annotations + +import os +import re +import shlex +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from conftest import cloned_just_pushed, git, rmtree +from test_family import NAME, ORG, SPARE, family, validate # noqa: F401 +from test_family_add_refusals import ( # noqa: F401 + 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, +) + +#: The member whose gitlink is moved into the wrong case, its row's path, +#: the variant, and the other member. +MEMBER = "IRRS" +ROW = f"members/{MEMBER}" +VARIANT = f"Members/{MEMBER}" +OTHER_ROW = "members/IRSS" +OTHER_VARIANT = "Members/IRSS" + +#: The typed lines of the move, as the remedy prints them: the removal, +#: with `--sparse` where a sparse checkout keeps the variant off disk. +REMOVE = f"git rm -r --cached --ignore-unmatch -- {VARIANT}" +REMOVE_SPARSE = f"git rm -r --cached --sparse --ignore-unmatch -- {VARIANT}" +SUBMODULE = "git submodule update --init --recursive" +MOVE_OUT = f"mv -- {VARIANT} ../Members-{MEMBER}" + + +def record(commit: str, row: str = ROW) -> str: + """The `git update-index` line that records `row`'s gitlink.""" + return f"git update-index --add --cacheinfo 160000,{commit},{row}" + + +def moved_back(rows: dict, commit: bool = True, + sparse: bool = False) -> tuple[str, list[str]]: + """`(the part that moves each gitlink back, the closing parts)` of the + remedy, for `rows`, `{row path: ([its variants], the commit named)}`; + `sparse` where a sparse checkout keeps a variant off disk.""" + variants = sorted(name for names, _ in rows.values() for name in names) + pairs = " and ".join(f"`{name}` for the row {row}" + for row, (names, _) in sorted(rows.items()) + for name in sorted(names)) + lines = " and ".join(f"`{record(at, row)}`" + for row, (_, at) in sorted(rows.items())) + move = ("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{' --sparse' if sparse else ''} --ignore-unmatch -- " + f"{' '.join(variants)}`, then {lines})" + + (", and commit that" if commit else "")) + moved = " and ".join(f"`mv -- {name} ../{name.replace('/', '-')}`" + for name in variants) + return move, [ + f"where nothing is checked out at {' or '.join(sorted(rows))}, as in " + f"a fresh clone, `{SUBMODULE}` then checks the member out there", + "on a case-sensitive disk, as Linux's usually is, a directory left " + f"at {' or '.join(variants)} stays there, untracked, and add refuses " + "it as family-members-dir-variant: move each one that is there out " + f"of the holder, {moved}", + "on macOS and Windows it is the row's own directory, so leave it"] + + +def remedy(commit: str) -> str: + """The whole remedy for the member's gitlink alone, at `commit`.""" + move, closing = moved_back({ROW: ([VARIANT], commit)}) + return remedy_of(move, *closing) + + +def in_the_index(*shown: str) -> str: + """The refusal's first line, naming what the index holds.""" + return DIR_VARIANT + "in the index: " + ", ".join(shown) + + +VARIANT_SHOWN = f"`{VARIANT}` (a submodule)" + +#: Where the refusal finds the gitlink. +IN_INDEX = "in the index" +HEAD_ONLY = "in HEAD and no longer in the index" + + +def variant(root: Path, row: str = ROW, spelled: str = VARIANT, + commit: str | None = None) -> str: + """`row`'s gitlink moved, in the index alone, to `spelled`, at the + commit it records (or `commit`), and committed: #363's state after the + fact, its row and its `.gitmodules` section saying `row`. The checkout + stays where it is. Returns the commit recorded.""" + commit = commit or git("rev-parse", f"HEAD:{row}", cwd=root).stdout.strip() + git("rm", "-q", "--cached", "--", row, cwd=root) + git("update-index", "--add", "--cacheinfo", f"160000,{commit},{spelled}", + cwd=root) + git("commit", "-qm", f"{row} at {spelled}", cwd=root) + return commit + + +def on_disk_as_named(root: Path, name: str) -> bool: + """Is `name` on disk in that very spelling, component by component?""" + here = root + for part in name.split("/"): + if not here.is_dir() or part not in os.listdir(here): + return False + here = here / part + return True + + +def typed_as_printed(root: Path, said: str) -> list[str]: + """EVERY COMMAND `said` PRINTS, TYPED AS PRINTED, in its order, and the + commit each part asks for, as `type_remedy` types #574's: `` + filled in `renamed-N` for a `git mv` and `../moved-N` for a `mv`. Typed + where its sentence says so, and only there: `git submodule update` where + nothing is checked out at the row's path, and an `mv` with nothing to + fill in where its source is on disk in that spelling and the disk does + not fold case (on macOS and Windows the text says to leave it). A local + member is cloned with `protocol.file.allow`, the suite's one concession + to bare repositories as origins. Returns what was typed.""" + folds = disk_folds(root.parent) + typed: list[str] = [] + count = {"git": 0, "mv": 0} + for part in said.split(" on every disk. ", 1)[1].split("; "): + for command in re.findall(r"`([^`]*)`", part): + argv = shlex.split(command) + if command == "git commit": + git("commit", "-qm", "The pending change", cwd=root) + elif command == SUBMODULE: + rows = re.search(r"checked out at (.+?), as in", part) + if all((root / row / ".git").exists() + for row in rows.group(1).split(" or ")): + continue + git("-c", "protocol.file.allow=always", *argv[1:], cwd=root) + elif "" in command: + tool = argv[0] + count[tool] += 1 + argv[-1] = (f"renamed-{count['git']}" if tool == "git" + else f"../moved-{count['mv']}") + if tool == "git": + git(*argv[1:], cwd=root) + else: + os.rename(root / argv[2], root / argv[3]) + command = shlex.join(argv) + elif argv[0] == "mv": + if folds or not on_disk_as_named(root, argv[2]): + continue + os.rename(root / argv[2], root / argv[3]) + elif argv[0] == "git" and (" -- " in command + or argv[1] == "update-index"): + git(*argv[1:], cwd=root) + else: + continue + typed.append(command) + if re.search(r", and commit (the rename|the removal|both|that)$", + part): + git("commit", "-qm", "The remedy", cwd=root) + typed.append("git commit") + return typed + + +def registered(root: Path, before: Path, commit: str, row: str = ROW) -> None: + """The member is where its row says: HEAD and the index record its + gitlink at `row`, at `commit`; nothing at or under another spelling of + `members/` is left in either; and its row and its `.gitmodules` section + are HEAD's bytes in `before`, the holder the test began with.""" + assert git("ls-tree", "HEAD", row, cwd=root).stdout == ( + f"160000 commit {commit}\t{row}\n") + assert git("ls-files", "-s", row, cwd=root).stdout == ( + f"160000 {commit} 0\t{row}\n") + for listed in (git("ls-tree", "-r", "--name-only", "HEAD", cwd=root), + git("ls-files", cwd=root)): + assert [name for name in listed.stdout.splitlines() + if name.split("/")[0].lower() == "members" + and not name.startswith("members/")] == [] + for name in ("family.yaml", ".gitmodules"): + assert git("show", f"HEAD:{name}", cwd=root).stdout == git( + "show", f"HEAD:{name}", cwd=before).stdout + + +def gate_passes(root: Path) -> None: + """The holder's own gate, `validate-family.py`, every member asked.""" + gate = validate(root) + assert gate.returncode == 0, gate.stdout + gate.stderr + + +# --- each layout, typed as printed ------------------------------------------ + +def nothing_there(root: Path, remotes: Path, commit: str) -> None: + """Nothing on disk in that spelling: the checkout stays at the row's + path, where it was.""" + + +def both_directories(root: Path, remotes: Path, commit: str) -> None: + """The checkout at the row's path, and a second one at the variant.""" + cloned_just_pushed(remotes / f"{MEMBER}.git", root / VARIANT, + recurse=False) + git("checkout", "-q", "--detach", commit, cwd=root / VARIANT) + + +def checkout_at_the_variant(root: Path, remotes: Path, commit: str) -> None: + """The member's only checkout at `Members/IRRS`, a clone of its own, + and none at the row's path, whose object store stays in + `.git/modules`.""" + rmtree(root / ROW) + both_directories(root, remotes, commit) + + +def removal_staged(root: Path, remotes: Path, commit: str) -> None: + """Half repaired: the variant taken out of the index, staged and not + committed, so HEAD alone holds it (#639's review, N1).""" + git("rm", "-q", "--cached", "--", VARIANT, cwd=root) + + +#: `{layout: (how it is laid out after the move, whether it needs two +#: directories differing only in case, where the refusal finds the gitlink, +#: what is typed after the move and its commit)}`. +LAYOUTS = { + "nothing on disk in that spelling": (nothing_there, False, IN_INDEX, []), + "the only checkout at the variant": (checkout_at_the_variant, True, + IN_INDEX, [SUBMODULE, MOVE_OUT]), + "both directories": (both_directories, True, IN_INDEX, [MOVE_OUT]), + "the removal staged, not committed": (removal_staged, False, HEAD_ONLY, + []), +} + + +@pytest.mark.parametrize("layout", list(LAYOUTS)) +def test_each_layout_moves_the_gitlink_back_as_printed(family, holder, + offline, tmp_path, + layout): + """THE ISSUE (#646): `add` of another member is refused, the dry run + and the real run alike, with the holder exactly as it was, and the + remedy moves the member's gitlink back to its row's path, where #574's + `git rm --cached`, `git mv` or `git commit` took it away. Typed as + printed, the member is where its row says, at the commit it recorded, + the holder's gate passes, and the same `add` goes through.""" + lay_out, two_spellings, where, after = LAYOUTS[layout] + if two_spellings and disk_folds(tmp_path): + pytest.skip("two directories differing only in case need a disk " + "that does not fold case") + commit = variant(holder) + lay_out(holder, family["remotes"], commit) + said = refused_untouched(offline, holder, family["remotes"], "--dry-run") + assert refused_untouched(offline, holder, family["remotes"]) == said + assert said == (DIR_VARIANT + f"{where}: {VARIANT_SHOWN}", + remedy(commit)) + assert typed_as_printed(holder, said[1]) == [ + REMOVE, record(commit), "git commit", *after] + registered(holder, family["root"], commit) + gate_passes(holder) + added(offline, holder, family["remotes"]) + + +def test_a_fresh_clone_moves_the_gitlink_back_as_printed(family, holder, + offline, tmp_path): + """A FRESH RECURSIVE CLONE of a holder whose HEAD records the gitlink + only at `Members/IRRS`: the clone leaves an empty `Members/IRRS` and + nothing at the row's path. The same text; typed as printed, `git + submodule update` checks the member out at its row's path, the empty + directory leaves the holder on a case-sensitive disk (on macOS and + Windows it is the row's own, and stays), and the clone's gate passes. + The branch is pushed to the fixture's remote under a name no other test + uses.""" + commit = variant(holder) + remote = family["remotes"] / f"{NAME}.git" + branch = "w646/fresh-clone" + git("push", "-q", str(remote), f"HEAD:refs/heads/{branch}", cwd=holder) + clone = cloned_just_pushed(remote, tmp_path / "clone" / NAME, branch) + assert (clone / VARIANT).is_dir() + assert not (clone / ROW / ".git").exists() + said = refused_untouched(offline, clone, family["remotes"], "--dry-run") + assert said == (in_the_index(VARIANT_SHOWN), remedy(commit)) + assert typed_as_printed(clone, said[1]) == [ + REMOVE, record(commit), "git commit", SUBMODULE, + *([] if disk_folds(tmp_path) else [MOVE_OUT])] + registered(clone, family["root"], commit) + gate_passes(clone) + added(offline, clone, family["remotes"]) + + +# --- one answer on every disk ----------------------------------------------- + +def dry_run_in_process(module, monkeypatch, offline, root: Path, + remotes: Path, capsys) -> tuple[str, str]: + """`add --dry-run` of the spare member IN THIS PROCESS, where `disk_as` + reaches it; the refusal's two lines, and nothing on stdout.""" + monkeypatch.setattr(tempfile, "tempdir", str(offline.temp)) + monkeypatch.setenv("PATH", offline.env["PATH"]) + code = module.main(["add", "--family-root", str(root), "--member", + f"{ORG}/{SPARE}", "--local-remote-dir", str(remotes), + "--dry-run"]) + out, err = capsys.readouterr() + assert code == 2, out + err + assert out == "" + return refusal(err) + + +@pytest.mark.parametrize("folding", [False, True], ids=["linux", "folding"]) +@pytest.mark.parametrize("layout", list(LAYOUTS)) +def test_every_disk_gives_the_same_answer(family, holder, family_module, + offline, monkeypatch, capsys, + tmp_path, layout, folding): + """ON EVERY DISK ("Refuse on every disk"): each layout asked in process + of a disk that folds case and of one that does not, emulated below the + holder (`disk_as`), gives the one text the subprocess gives. The move + reads the index and HEAD, never the disk, so the disk cannot change + it.""" + lay_out, two_spellings, where, _ = LAYOUTS[layout] + if two_spellings and disk_folds(tmp_path): + pytest.skip("two directories differing only in case need a disk " + "that does not fold case") + commit = variant(holder) + lay_out(holder, family["remotes"], commit) + disk_as(monkeypatch, holder, folding) + assert dry_run_in_process(family_module, monkeypatch, offline, holder, + family["remotes"], capsys) == ( + DIR_VARIANT + f"{where}: {VARIANT_SHOWN}", remedy(commit)) + + +# --- with the other remedies, each in its order ----------------------------- + +def two_rows(root: Path) -> dict: + """Both members' gitlinks in the wrong case.""" + return {ROW: ([VARIANT], variant(root)), + OTHER_ROW: ([OTHER_VARIANT], variant(root, OTHER_ROW, + OTHER_VARIANT))} + + +def staged_only(root: Path) -> str: + """The move to the variant staged and not committed, so HEAD still + records the gitlink at the row's path, at the same commit: the move + back leaves nothing to commit. Returns the commit.""" + commit = git("rev-parse", f"HEAD:{ROW}", cwd=root).stdout.strip() + git("rm", "-q", "--cached", "--", ROW, cwd=root) + git("update-index", "--add", "--cacheinfo", f"160000,{commit},{VARIANT}", + cwd=root) + return commit + + +def beside_a_file(root: Path, remotes: Path) -> tuple: + """`Members/README` staged beside the gitlink: #574's remedy for it, + then the move back.""" + commit = variant(root) + plant(root, "100644", "Members/README") + move, closing = moved_back({ROW: ([VARIANT], commit)}) + return (in_the_index(VARIANT_SHOWN, "`Members/README` (a file)"), + remedy_of(index_part(unstage=["Members/README"]), move, + *closing), + ["git rm -r --cached -- Members/README", REMOVE, record(commit), + "git commit"], commit) + + +def after_a_pending_change(root: Path, remotes: Path) -> tuple: + """A pending change in HEAD (`MEMBERS/old` taken out of the index, not + committed) beside a staged move to the variant: the pending change is + committed first, so the move 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) + commit = staged_only(root) + move, closing = moved_back({ROW: ([VARIANT], commit)}) + return (in_the_index(VARIANT_SHOWN) + "; in HEAD and no longer in the " + "index: `MEMBERS/old` (a file)", + remedy_of(commit_first("MEMBERS/old"), move, *closing), + ["git commit", REMOVE, record(commit), "git commit"], commit) + + +def nothing_to_commit(root: Path, remotes: Path) -> tuple: + """The staged move alone: no commit is printed, since none is left to + make, and a printed one would fail as typed.""" + commit = staged_only(root) + move, closing = moved_back({ROW: ([VARIANT], commit)}, commit=False) + return (in_the_index(VARIANT_SHOWN), remedy_of(move, *closing), + [REMOVE, record(commit)], commit) + + +def members_renamed_by_hand(root: Path, remotes: Path) -> tuple: + """The holder's `members/` renamed `Members` by hand on Linux, the other + member's checkout in it, and the member's gitlink at `Members/IRRS` + with no checkout anywhere: the move back of the directory comes after + the gitlink's, and the closing sentences after both, so `git submodule + update` checks the member out in the `members/` moved back, and nothing + is left at the variant to move out.""" + commit = variant(root) + rmtree(root / ROW) + renamed_members(root) + move, closing = moved_back({ROW: ([VARIANT], commit)}) + return (in_the_index(VARIANT_SHOWN) + "; on disk and untracked: " + "`Members` (a directory)", + remedy_of(move, MOVE_BACK, *closing), + [REMOVE, record(commit), "git commit", "mv -- Members members", + SUBMODULE], commit) + + +def head_only_and_on_disk(root: Path, remotes: Path) -> tuple: + """HEAD alone holds the gitlink at the variant, and the member's only + checkout is there: the directory is the move's to name, never a second + time as untracked.""" + commit = variant(root) + git("rm", "-q", "--cached", "--", VARIANT, cwd=root) + checkout_at_the_variant(root, remotes, commit) + return (DIR_VARIANT + f"{HEAD_ONLY}: {VARIANT_SHOWN}", remedy(commit), + [REMOVE, record(commit), "git commit", SUBMODULE, MOVE_OUT], + commit) + + +def head_holding_both(root: Path, remotes: Path) -> tuple: + """HEAD records the gitlink at the row's path AND at the variant, at one + commit, and the index has the row's removal staged beside the variant: + HEAD's gitlink at the row's path is the move's commit already, but HEAD + still holds the variant, so the commit is printed. Without it `D + Members/IRRS` stays staged, and the next `add` refuses it as a pending + change (#646's review, S2).""" + commit = git("rev-parse", f"HEAD:{ROW}", cwd=root).stdout.strip() + git("update-index", "--add", "--cacheinfo", f"160000,{commit},{VARIANT}", + cwd=root) + git("commit", "-qm", "Both spellings", cwd=root) + git("rm", "-q", "--cached", "--", ROW, cwd=root) + return (in_the_index(VARIANT_SHOWN), remedy(commit), + [REMOVE, record(commit), "git commit"], commit) + + +def kept_off_disk(root: Path, remotes: Path) -> tuple: + """The variant kept off disk by a sparse checkout (`skip-worktree`), as + #574's sparse case keeps its entry: `git rm --cached --ignore-unmatch` + alone passes it over, exit 0, and the move then left two gitlinks in + the index, so the removal says `--sparse` (#646's review, S1). Skipped + on the git versions #574's sparse case is skipped on.""" + if git_version() < (2, 34): + pytest.skip("git's `--sparse` options are asked of git 2.34 or later") + commit = variant(root) + git("update-index", "--skip-worktree", "--", VARIANT, cwd=root) + move, closing = moved_back({ROW: ([VARIANT], commit)}, sparse=True) + return (in_the_index(VARIANT_SHOWN), remedy_of(move, *closing), + [REMOVE_SPARSE, record(commit), "git commit"], commit) + + +def file_blob(root: Path) -> str: + """A blob for a file at the variant, written as BYTES.""" + return subprocess.run( + ["git", "hash-object", "-w", "--stdin"], cwd=str(root), + input=b"a file\n", capture_output=True, + check=True).stdout.decode("ascii").strip() + + +def unmerged_at(root: Path, stages: dict[int, str], + files: tuple[int, ...] = ()) -> None: + """The variant at each of `stages`, `{stage: object}`, and at no stage + 0, written as bytes by `update-index --index-info`: a file, whose blob + it names, at each stage in `files`, and a gitlink, at the commit it + names, at the others.""" + written = subprocess.run( + ["git", "update-index", "--index-info"], cwd=str(root), + input="".join(f"{'100644' if stage in files else '160000'} {at} " + f"{stage}\t{VARIANT}\n" + for stage, at in stages.items()).encode("ascii"), + capture_output=True, check=False) + assert written.returncode == 0, written.stderr + + +def unmerged(root: Path, remotes: Path) -> tuple: + """The variant UNMERGED, at stages 1, 2 and 3 and at no stage 0, with no + merge or stash in progress, as a conflicted `git stash pop` leaves it: + moved back at stage 2's commit, the holder's own side and what HEAD + records there, never at stage 1's, the merge base, which git lists + first (#646's review, N2). The removal takes out every stage.""" + commit = variant(root) + first = first_commit(root) + git("rm", "-q", "--cached", "--", VARIANT, cwd=root) + unmerged_at(root, {1: first, 2: commit, 3: first}) + return (in_the_index(VARIANT_SHOWN), remedy(commit), + [REMOVE, record(commit), "git commit"], commit) + + +#: `{state: (how it is made, from the holder and the remotes, returning +#: (the refusal's two lines, what is typed, the member's commit), whether it +#: needs two directories differing only in case)}`. +WITH_OTHERS = { + "beside a file under Members/": (beside_a_file, False), + "after a pending change in HEAD": (after_a_pending_change, False), + "with nothing left to commit": (nothing_to_commit, False), + "members/ renamed by hand": (members_renamed_by_hand, True), + "HEAD alone, the checkout at the variant": (head_only_and_on_disk, True), + "HEAD holding both, the row's removal staged": (head_holding_both, + False), + "kept off disk by a sparse checkout": (kept_off_disk, False), + "unmerged, with nothing in progress": (unmerged, False), +} + + +@pytest.mark.parametrize("state", list(WITH_OTHERS)) +def test_with_the_other_remedies_each_runs_in_its_order(family, holder, + offline, tmp_path, + state): + """The move back among #574's remedies, each kept for its own path: + the pending change committed first, what the index holds under + `Members/` next, then the move back and its commit where one is left to + make, then the disk, and the move back's closing sentences last. Typed + as printed, in that order, the member is where its row says, the gate + passes and the same `add` goes through.""" + make, two_spellings = WITH_OTHERS[state] + if two_spellings and disk_folds(tmp_path): + pytest.skip("two directories differing only in case need a disk " + "that does not fold case") + first, said, typed, commit = make(holder, family["remotes"]) + assert refused_untouched(offline, holder, family["remotes"]) == (first, + said) + assert typed_as_printed(holder, said) == typed + registered(holder, family["root"], commit) + gate_passes(holder) + added(offline, holder, family["remotes"]) + + +def test_every_member_held_in_the_wrong_case_is_moved_back(family, holder, + offline): + """TWO ROWS, both gitlinks in the wrong case: one removal naming both, + one `git update-index` per row, one commit; typed as printed, both are + where their rows say.""" + rows = two_rows(holder) + move, closing = moved_back(rows) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (in_the_index(VARIANT_SHOWN, + f"`{OTHER_VARIANT}` (a submodule)"), + remedy_of(move, *closing)) + assert typed_as_printed(holder, said[1]) == [ + f"git rm -r --cached --ignore-unmatch -- {VARIANT} {OTHER_VARIANT}", + record(rows[ROW][1]), record(rows[OTHER_ROW][1], OTHER_ROW), + "git commit"] + for row, (_, commit) in rows.items(): + registered(holder, family["root"], commit, row) + gate_passes(holder) + added(offline, holder, family["remotes"]) + + +# --- which gitlink is the member's own, and at which commit ----------------- + +def refused_in_process(module, root: Path) -> tuple[str, str]: + """`_checked_mount_path` of the spare member's path, asked as `add` + asks it, with the holder's rows: the refusal's two lines.""" + rows = module._rows_paths(module.Family.open(root).members) + with pytest.raises(module.Refusal) as caught: + module._checked_mount_path(root, SPARE, MOUNT, rows) + return tuple(str(caught.value).splitlines()) + + +def first_commit(root: Path) -> str: + """The member's commit before the one its row pins.""" + return git("rev-parse", "HEAD~1", cwd=root / ROW).stdout.strip() + + +def at_another_commit(root: Path) -> tuple[list[str], str]: + """The gitlink at the variant records a commit the row does not pin: + the move keeps the commit, a move and not a bump.""" + first = first_commit(root) + variant(root, commit=first) + return [VARIANT], first + + +def twice_in_the_index(root: Path) -> tuple[list[str], str]: + """Two variants in the index at two commits: both are taken out, and + the one the index lists first is recorded.""" + first = first_commit(root) + variant(root) + git("update-index", "--add", "--cacheinfo", + f"160000,{first},MEMBERS/{MEMBER}", cwd=root) + git("commit", "-qm", "A second variant", cwd=root) + return [f"MEMBERS/{MEMBER}", VARIANT], first + + +def in_the_index_and_head(root: Path) -> tuple[list[str], str]: + """One variant that HEAD alone holds, at one commit, and another the + index holds, at the row's: the index's commit is recorded, since the + next commit records the index.""" + pinned = git("rev-parse", f"HEAD:{ROW}", cwd=root).stdout.strip() + variant(root, spelled=f"MEMBERS/{MEMBER}", commit=first_commit(root)) + git("rm", "-q", "--cached", "--", f"MEMBERS/{MEMBER}", cwd=root) + git("update-index", "--add", "--cacheinfo", + f"160000,{pinned},{VARIANT}", cwd=root) + return [VARIANT, f"MEMBERS/{MEMBER}"], pinned + + +def staged_at_another_commit(root: Path) -> tuple[list[str], str]: + """The move to the variant staged at another commit, HEAD still + recording the row's gitlink at its own: the index's commit is recorded, + never HEAD's, and the move has a commit to make, since HEAD's gitlink + at the row's path is another.""" + first = first_commit(root) + git("rm", "-q", "--cached", "--", ROW, cwd=root) + git("update-index", "--add", "--cacheinfo", f"160000,{first},{VARIANT}", + cwd=root) + return [VARIANT], first + + +COMMITS = { + "the variant at a commit the row does not pin": at_another_commit, + "two variants in the index": twice_in_the_index, + "one in the index, one in HEAD alone": in_the_index_and_head, + "staged at another commit, HEAD keeping the row's": ( + staged_at_another_commit), +} + + +@pytest.mark.parametrize("state", list(COMMITS)) +def test_the_gitlink_is_recorded_at_the_commit_it_records(holder, + family_module, + state): + """THE COMMIT NAMED is the one the variant records, never another: a + move, not a bump. Where one row has two variants, both are taken out + and the first found is recorded, the index's before HEAD's.""" + names, commit = COMMITS[state](holder) + move, closing = moved_back({ROW: (names, commit)}) + assert refused_in_process(family_module, holder)[1] == remedy_of( + move, *closing) + + +def test_a_commit_the_row_does_not_pin_is_moved_not_bumped(family, holder, + offline): + """THE VARIANT AT A COMMIT THE ROW DOES NOT PIN, its checkout at that + commit, as the holder records it, typed as printed: the member is at its + row's path at the commit it recorded, and nothing else changed, a move + and not a bump. The gate then names the drift the holder really has, + `member-gitlink-mismatch`, exit 1, whose own remedy is `bump`; and `add` + of another member still goes through (#646's review, S3).""" + _, commit = at_another_commit(holder) + git("checkout", "-q", "--detach", commit, cwd=holder / ROW) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (in_the_index(VARIANT_SHOWN), remedy(commit)) + assert typed_as_printed(holder, said[1]) == [REMOVE, record(commit), + "git commit"] + registered(holder, family["root"], commit) + gate = validate(holder) + assert gate.returncode == 1, gate.stdout + gate.stderr + assert re.findall(r"^FINDING ([a-z-]+):", gate.stderr, re.M) == [ + "member-gitlink-mismatch"] + assert f"{ROW}: gitlink {commit} != family.yaml pin " in gate.stderr + added(offline, holder, family["remotes"]) + + +def head_file(root: Path) -> None: + """A FILE at the variant that HEAD alone holds: a pending change.""" + plant(root, "100644", VARIANT) + git("commit", "-qm", "A file at the variant", cwd=root) + git("rm", "-q", "--cached", "--", VARIANT, cwd=root) + + +def beside_a_file_at(path: str): + """The variant's gitlink with a FILE at `path`, the row's path or under + it, in the index: the move would replace the one, and stop on the other + (`appears as both a file and as a directory`) once the variant was out + (#646's review, N3).""" + def make(root: Path) -> None: + plant(root, "100644", path) + plant(root, "160000", VARIANT) + return make + + +def unmerged_theirs(root: Path) -> None: + """The variant unmerged with no gitlink at stage 2, the holder's own + side: no member's own (#646's review, N2).""" + unmerged_at(root, {1: "1" * 40, 3: "1" * 40}) + + +def unmerged_file_ours(root: Path) -> None: + """The variant unmerged with a gitlink at stage 1, a FILE at stage 2 and + a gitlink at stage 3: git lists stage 1 first, and a gitlink there is + not the holder's own side, which is a file, so no member's own (#646's + review, NIT-3; a rule that read the first record listed would move the + file's blob back as a gitlink).""" + unmerged_at(root, {1: "1" * 40, 2: file_blob(root), 3: "1" * 40}, + files=(2,)) + + +#: `{state: (how it is made after the row's gitlink leaves the index, what +#: `_members_spellings` gives the variant: where, target, remedy)}`. The +#: index never holds the row's gitlink but in the first. +OWN = { + "a second gitlink beside the row's": ( + lambda root: plant(root, "160000", VARIANT), + (IN_INDEX, VARIANT, "git rm -r --cached")), + "a file at the variant": ( + lambda root: plant(root, "100644", VARIANT), + (IN_INDEX, VARIANT, "git rm -r --cached")), + "a gitlink under the variant": ( + lambda root: plant(root, "160000", f"{VARIANT}/x"), + (IN_INDEX, VARIANT, "git rm -r --cached")), + "a file at the variant in HEAD alone": ( + head_file, (HEAD_ONLY, VARIANT, "git commit")), + "a file at the row's path": ( + beside_a_file_at(ROW), (IN_INDEX, VARIANT, "git rm -r --cached")), + "a file under the row's path": ( + beside_a_file_at(f"{ROW}/x"), + (IN_INDEX, VARIANT, "git rm -r --cached")), + "unmerged, with no gitlink at stage 2": ( + unmerged_theirs, (IN_INDEX, VARIANT, "git rm -r --cached")), + "unmerged, with a file at stage 2 and gitlinks at 1 and 3": ( + unmerged_file_ours, (IN_INDEX, VARIANT, "git rm -r --cached")), + "the member's own gitlink": ( + lambda root: plant(root, "160000", VARIANT), + (IN_INDEX, ROW, "git update-index")), +} + + +@pytest.mark.parametrize("state", list(OWN)) +def test_only_the_members_own_gitlink_is_moved_back(holder, family_module, + state): + """WHICH PATH IS THE MEMBER'S OWN: a gitlink at a case variant of a + row's path, where the index holds nothing at or under that path. A + second gitlink beside the row's, a file at the variant, a gitlink under + it, a file at or under the row's path, or an unmerged variant with no + gitlink at stage 2 (none, or a file there, whatever stages 1 and 3 + hold) keeps #574's remedy.""" + make, (where, target, remedy_said) = OWN[state] + if state != "a second gitlink beside the row's": + git("rm", "-q", "--cached", "--", ROW, cwd=holder) + make(holder) + rows = family_module._rows_paths(family_module.Family.open(holder).members) + assert [(spelled.where, spelled.target, spelled.remedy) + for spelled in family_module._members_spellings( + holder, SPARE, family_module._members_on_disk(holder), + rows)] == [(where, target, remedy_said)] + + +def test_the_rows_that_have_a_path_of_their_own(family_module): + """`_rows_paths`: `members/` for every row with that path, or + no `path:` at all; none for a row whose `path:` is another, or whose + project is no single directory name, which `member-path` refuses.""" + assert family_module._rows_paths([ + {"project": "IRRS"}, {"project": "IRSS", "path": OTHER_ROW}, + {"project": "Elsewhere", "path": "elsewhere/Elsewhere"}, + {"project": ".."}, {"project": 7}, {}]) == frozenset( + {ROW, OTHER_ROW}) diff --git a/tests/test_family_add_refusals.py b/tests/test_family_add_refusals.py index 7a037a25..ee4623d7 100644 --- a/tests/test_family_add_refusals.py +++ b/tests/test_family_add_refusals.py @@ -1441,7 +1441,7 @@ def test_a_gitlink_git_stages_under_another_spelling_is_named_not_hidden( cwd=family["remotes"] / f"{SPARE}.git").stdout.strip() before, index = snapshot(holder), index_bytes(holder) monkeypatch.setattr(family_module, "_members_spellings", - lambda root, project, on_disk: []) + lambda root, project, on_disk, rows: []) code = add_in_process(family_module, monkeypatch, offline, holder, SPARE, family["remotes"]) diff --git a/tests/test_family_members_case_variants.py b/tests/test_family_members_case_variants.py index fb2af770..0b97561d 100644 --- a/tests/test_family_members_case_variants.py +++ b/tests/test_family_members_case_variants.py @@ -448,7 +448,8 @@ def index_info(line: bytes) -> None: holder, SPARE, family_module._members_on_disk(holder)) == [ ("Members/\N{REPLACEMENT CHARACTER}", "a file", "in HEAD and no longer in the index", - "Members/\N{REPLACEMENT CHARACTER}", False, "git commit", True)] + "Members/\N{REPLACEMENT CHARACTER}", False, "git commit", True, "", + False)] # --- the disk (#364) ---------------------------------------------------------