From 2216e1c35b025a44b6f2da90a366d578c7786b3d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:11:20 +0000 Subject: [PATCH 1/4] family.py: `add`'s refusal of a member's own gitlink held only under another spelling of its row's path moves it back to that path in the index alone, so the printed remedy no longer takes the member's gitlink away from its row (#646) A member's only gitlink at `Members/

`, while its row and `.gitmodules` say `members/

` (#363's state after the fact), made `add` of ANOTHER member refuse as #574 rules, with #574's remedy for any path under another spelling of `members/`: `git rm -r --cached -- Members/

` where nothing is on disk in that spelling, `git mv -- Members/

` where something is, and `git commit` where only HEAD holds it. Each, typed as printed, left no gitlink at the row's path, and the holder's validator then refused `member-gitlink-absent`. `_members_spellings` now takes the rows' own paths (`_rows_paths`, the one path `_member_path` gives a row). Where the index holds no gitlink at a row's path, a gitlink at a case variant of it, in the index or in HEAD alone, is that member's: remedy `RESTORE`, placed in `REMEDY_ORDER` after the index's own remedies. Its remedy is the index-only move `validate-family.py` prints for the same state on #639, 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 -- `, which touches no file and is no error where HEAD alone holds the variant; - `git update-index --add --cacheinfo 160000,,members/

`, one per row, at the commit the variant entry records (the first found where a row has two, the index's before HEAD's), so it is a move and not a bump; - a commit, printed only where one is left to make: HEAD holds a variant, HEAD's gitlink at the row's path is another commit or none, or a pending change is committed first. After every other remedy come the validator's own sentences for what the disk holds afterwards: `git submodule update --init --recursive` where nothing is checked out at the row's path, as in a fresh clone; on a case-sensitive disk, `mv -- Members/

../Members-

` for the directory left at the variant, which the next `add` would otherwise refuse as `family-members-dir-variant`; on macOS and Windows, leave it, since it is the row's own directory. They come last so that a `members/` moved back (`mv -- Members members`) has taken the checkout home first. A directory on disk at a gitlink moved back from HEAD is the move's to name, as one at an index entry is the index's. The refusal code stays `family-members-dir-variant` (or `member-path-taken` where a path is also a variant of the new member's mount path): the add is still refused for the path, as for any path under another spelling of `members/`, and only its remedy changes. A gitlink at a variant beside one at the row's path, a file at the variant, and anything under it keep #574's remedies, which take nothing from the row. The text reads the index and HEAD alone, so it is the same on a disk that folds case and on one that does not, and in the dry run and the real run. Tests: `tests/test_family_add_member_variant.py` (new, 28) types every printed command in each layout of rv611b's review of #639 (nothing on disk in that spelling; the only checkout at the variant; both directories; the removal staged; a fresh recursive clone) and among #574's other remedies; the holder's gate passes after it, the member is where its row says at its commit, and the same `add` goes through. It asks each layout of an emulated folding disk and a Linux one (`disk_as`). Two existing lines follow the new shapes: a seven-field `Spelled` compared in `test_family_members_case_variants.py`, and the three-argument stand-in for `_members_spellings` in `test_family_add_refusals.py`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 194 +++++-- tests/test_family_add_member_variant.py | 617 +++++++++++++++++++++ tests/test_family_add_refusals.py | 2 +- tests/test_family_members_case_variants.py | 2 +- 4 files changed, 781 insertions(+), 34 deletions(-) create mode 100644 tests/test_family_add_member_variant.py diff --git a/scripts/family.py b/scripts/family.py index 2f79cf68..36d5c27a 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2454,15 +2454,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): @@ -2474,7 +2478,12 @@ 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, and + `recorded` is the commit it records, at which the move records it back; + `recorded` is empty for every other remedy.""" name: str kind: str @@ -2483,6 +2492,7 @@ class Spelled(NamedTuple): mount: bool remedy: str committed: bool = False + recorded: str = "" def _folded(name: str) -> str: @@ -2706,10 +2716,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 @@ -2721,26 +2732,41 @@ 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 no gitlink at 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). Where + the index does hold a gitlink at the row's path, one at a variant is a + second gitlink, and taking it out leaves the member where it is: 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` @@ -2758,17 +2784,22 @@ 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"): records[0].split() + for name, records in _index_records(root).items()} + indexed: dict[str, int] = {name: int(fields[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 no gitlink at, by its folded spelling: + # a gitlink at another spelling of it is that member's own. + strays = {_folded(row): row for row in rows if row not in gitlinks} found: list[Spelled] = [] + restored: list[str] = [] sparse: set[str] | None = None for name, mode in indexed.items(): spelled = _spelled_target(name, project, occupied) @@ -2776,6 +2807,17 @@ def _members_spellings(root: Path, project: str, continue target, mount = spelled kind = INDEX_ENTRY_KINDS.get(mode, "a file") + own = strays.get(_folded(name)) if mode == 0o160000 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 = listed[name][1].decode("ascii") + found.append(Spelled(_shown(name), kind, IN_INDEX, _shown(own), + mount, RESTORE, + name in in_head or in_head.get(own) + != recorded, recorded)) + continue if target != canon and _on_disk_as_named( root, target, target != name or mode == 0o160000): remedy = RENAME @@ -2786,17 +2828,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)) @@ -2824,9 +2876,25 @@ 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; 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 = [] @@ -2882,6 +2950,44 @@ 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) + 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 --ignore-unmatch -- {taken_out}`, then {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])})") @@ -2890,6 +2996,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", @@ -3002,9 +3110,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 @@ -3040,7 +3166,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 @@ -3138,7 +3267,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 @@ -3264,7 +3393,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..29566153 --- /dev/null +++ b/tests/test_family_add_member_variant.py @@ -0,0 +1,617 @@ +# 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 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, + 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 three typed lines of the move, as the remedy prints them. +REMOVE = f"git rm -r --cached --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) -> 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)}`.""" + 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 --ignore-unmatch -- {' '.join(variants)}`, then " + f"{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) + + +#: `{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), +} + + +@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 + + +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, +} + + +@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 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) + + +#: `{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")), + "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 none at that path. A second gitlink + beside the row's, a file there, or a gitlink under it keeps #574's + remedy, which takes nothing away from the row.""" + 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..7decae3d 100644 --- a/tests/test_family_members_case_variants.py +++ b/tests/test_family_members_case_variants.py @@ -448,7 +448,7 @@ 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, "")] # --- the disk (#364) --------------------------------------------------------- From 79754b996d5576c9c975d2f28712cec170edefed Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:37:20 +0000 Subject: [PATCH 2/4] tests/test_family_add_member_variant.py: a move to the variant staged at another commit, with HEAD keeping the row's gitlink at its own, names the index's commit and prints the commit, so the two conditions no other state held are each held by a test (#646) Two mutation probes of the fix survived the new file: `committed` taken from "HEAD holds the variant" alone, and the commit read from HEAD's gitlink at the row's path before the index's. Neither changes the answer in any state the file held: wherever HEAD lacked the variant, it lacked the row's gitlink too, or held it at the same commit. This state holds the row's gitlink in HEAD at one commit and the variant in the index at another, and nothing in HEAD at the variant. The remedy must name the index's commit, and must print the commit, since HEAD's gitlink at the row's path is another. Each probe now fails it. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- tests/test_family_add_member_variant.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_family_add_member_variant.py b/tests/test_family_add_member_variant.py index 29566153..a93fce1c 100644 --- a/tests/test_family_add_member_variant.py +++ b/tests/test_family_add_member_variant.py @@ -540,10 +540,24 @@ def in_the_index_and_head(root: Path) -> tuple[list[str], str]: 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), } From 2104a3d01e939bef01a2f1f90c68448362d54800 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 14:07:42 +0000 Subject: [PATCH 3/4] family.py: `add`'s move back of a member's own gitlink takes a variant a sparse checkout keeps off disk out with `--sparse`, records an unmerged one at stage 2, and is printed only where the index holds nothing at or under the row's path, so its lines no longer leave two gitlinks, record the merge base or replace a tracked file (#646) Lane 3's review RV670 of #670 at 79754b99, READY with three SHOULDs and six NITs. This commit takes S1, S2, S3, N2 and N3; N1, N4, N5 and N6 are the PR body's. - S1. A variant marked skip-worktree, as a sparse checkout leaves it: `git rm -r --cached --ignore-unmatch` passed it over with exit 0, and `update-index --add` then put a second gitlink beside it. `Spelled` gains `sparse`, and the removal says `--sparse` where any variant needs it, and only there, so the line stays the validator's everywhere else. - N2. An unmerged variant was moved back at the first record git lists, stage 1, the merge base. It is moved back at stage 2, the holder's own side; an unmerged variant with no gitlink at stage 2 is no member's own and keeps #574's remedy. - N3. The member's own gitlink was asked only where the index held no GITLINK at the row's path. A file there was replaced by the move without a word, and a file under it stopped the move's second line after the first had taken the variant out. Now the index must hold nothing at or under the row's path; otherwise #574's remedy is kept. - S2 and S3 add tests only: HEAD holding the gitlink in both spellings at one commit with the row's removal staged, which tells `name in in_head or` apart; and the variant at a commit the row does not pin, typed as printed, after which the gate exits 1 with `member-gitlink-mismatch` alone and another member's `add` goes through. The commit the move names is unchanged, the variant entry's: the question of the row's pin is back with Brett Heap. tests/test_family_members_case_variants.py's seven-field tuple gains the new field's `False`. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 81 +++++++++---- tests/test_family_add_member_variant.py | 132 +++++++++++++++++++-- tests/test_family_members_case_variants.py | 3 +- 3 files changed, 180 insertions(+), 36 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 49c2aabe..41df81d2 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2587,9 +2587,12 @@ class Spelled(NamedTuple): 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, and - `recorded` is the commit it records, at which the move records it back; - `recorded` is empty for every other remedy.""" + `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 @@ -2599,6 +2602,7 @@ class Spelled(NamedTuple): remedy: str committed: bool = False recorded: str = "" + sparse: bool = False def _folded(name: str) -> str: @@ -2861,18 +2865,20 @@ def _members_spellings(root: Path, project: str, 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 no gitlink at 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). Where - the index does hold a gitlink at the row's path, one at a variant is a - second gitlink, and taking it out leaves the member where it is: it keeps - the remedy below. + 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` @@ -2890,9 +2896,10 @@ def _members_spellings(root: Path, project: str, remedies act on one path. """ canon = _members_dir_on_disk(root, on_disk) - listed = {name.decode("utf-8", "surrogateescape"): records[0].split() + 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], 8) + 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( @@ -2901,9 +2908,15 @@ def _members_spellings(root: Path, project: str, if first == MEMBERS_DIR and rest) head = _head_spellings(root) in_head = {name: oid for name, _, oid in head} - # Each row's path the index holds no gitlink at, by its folded spelling: - # a gitlink at another spelling of it is that member's own. - strays = {_folded(row): row for row in rows if row not in gitlinks} + # 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 @@ -2913,16 +2926,25 @@ def _members_spellings(root: Path, project: str, continue target, mount = spelled kind = INDEX_ENTRY_KINDS.get(mode, "a file") - own = strays.get(_folded(name)) if mode == 0o160000 else None + # 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 = listed[name][1].decode("ascii") + 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)) + != recorded, recorded, name in sparse)) continue if target != canon and _on_disk_as_named( root, target, target != name or mode == 0o160000): @@ -2990,8 +3012,10 @@ def _spellings_refusal(path: str, found: list[Spelled]) -> Refusal: 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; 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 + 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 @@ -3073,11 +3097,16 @@ def each(command: str, targets: list[str]) -> str: 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 --ignore-unmatch -- {taken_out}`, then {recorded})" + 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 "")) diff --git a/tests/test_family_add_member_variant.py b/tests/test_family_add_member_variant.py index a93fce1c..109ba50e 100644 --- a/tests/test_family_add_member_variant.py +++ b/tests/test_family_add_member_variant.py @@ -38,6 +38,7 @@ import os import re import shlex +import subprocess import tempfile from pathlib import Path @@ -50,7 +51,7 @@ ) from test_family_members_case_variants import ( DIR_VARIANT, MOUNT, MOVE_BACK, added, commit_first, disk_as, disk_folds, - index_part, refused_untouched, remedy_of, renamed_members, + git_version, index_part, refused_untouched, remedy_of, renamed_members, ) #: The member whose gitlink is moved into the wrong case, its row's path, @@ -61,8 +62,10 @@ OTHER_ROW = "members/IRSS" OTHER_VARIANT = "Members/IRSS" -#: The three typed lines of the move, as the remedy prints them. +#: 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}" @@ -72,9 +75,11 @@ def record(commit: str, row: str = ROW) -> str: return f"git update-index --add --cacheinfo 160000,{commit},{row}" -def moved_back(rows: dict, commit: bool = True) -> tuple[str, list[str]]: +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)}`.""" + 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()) @@ -84,8 +89,9 @@ def moved_back(rows: dict, commit: bool = True) -> tuple[str, list[str]]: 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 --ignore-unmatch -- {' '.join(variants)}`, then " - f"{lines})" + (", and commit that" if commit else "")) + 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, [ @@ -437,6 +443,62 @@ def head_only_and_on_disk(root: Path, remotes: Path) -> tuple: 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 unmerged_at(root: Path, stages: dict[int, str]) -> None: + """The variant's gitlink at each of `stages`, `{stage: commit}`, and at + no stage 0, written as bytes by `update-index --index-info`.""" + written = subprocess.run( + ["git", "update-index", "--index-info"], cwd=str(root), + input="".join(f"160000 {at} {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)}`. @@ -446,6 +508,10 @@ def head_only_and_on_disk(root: Path, remotes: Path) -> tuple: "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), } @@ -574,6 +640,29 @@ def test_the_gitlink_is_recorded_at_the_commit_it_records(holder, 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) @@ -581,6 +670,23 @@ def head_file(root: Path) -> None: 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}) + + #: `{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. @@ -596,6 +702,13 @@ def head_file(root: Path) -> None: (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")), "the member's own gitlink": ( lambda root: plant(root, "160000", VARIANT), (IN_INDEX, ROW, "git update-index")), @@ -606,9 +719,10 @@ def head_file(root: Path) -> None: 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 none at that path. A second gitlink - beside the row's, a file there, or a gitlink under it keeps #574's - remedy, which takes nothing away from the row.""" + 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 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) diff --git a/tests/test_family_members_case_variants.py b/tests/test_family_members_case_variants.py index 7decae3d..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) --------------------------------------------------------- From a837a0232766ad097b165301c6cc0ed0a0a16d4f Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 18:43:35 +0000 Subject: [PATCH 4/4] tests/test_family_add_member_variant.py: `OWN` holds an unmerged variant with gitlinks at stages 1 and 3 and a file at stage 2, so a rule that reads the first record git lists and not stage 2 no longer passes unseen, and `add` keeps #574's remedy there (#646) rv670b's mutant R1 replaced `ours[0] == b"160000"` in `_members_spellings` with the first record's mode. It survived all 36 tests: in this state git lists the stage-1 gitlink first, so R1 moved the file's blob back as a gitlink at the row's path, while the real rule sees that the holder's own side, stage 2, is a file and keeps #574's `git rm -r --cached`. The new `OWN` state pins that. `unmerged_at` takes the stages that hold a file, and `file_blob` writes its blob as bytes. No code changes. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_family_add_member_variant.py | 35 +++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/test_family_add_member_variant.py b/tests/test_family_add_member_variant.py index 109ba50e..d90877be 100644 --- a/tests/test_family_add_member_variant.py +++ b/tests/test_family_add_member_variant.py @@ -474,12 +474,24 @@ def kept_off_disk(root: Path, remotes: Path) -> tuple: [REMOVE_SPARSE, record(commit), "git commit"], commit) -def unmerged_at(root: Path, stages: dict[int, str]) -> None: - """The variant's gitlink at each of `stages`, `{stage: commit}`, and at - no stage 0, written as bytes by `update-index --index-info`.""" +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"160000 {at} {stage}\t{VARIANT}\n" + 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 @@ -687,6 +699,16 @@ def unmerged_theirs(root: Path) -> None: 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. @@ -709,6 +731,8 @@ def unmerged_theirs(root: Path) -> None: (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")), @@ -722,7 +746,8 @@ def test_only_the_members_own_gitlink_is_moved_back(holder, family_module, 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 keeps #574's remedy.""" + 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)