From 2216e1c35b025a44b6f2da90a366d578c7786b3d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:11:20 +0000 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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) From 70307d200dd312fb32550cada66cf1ebdeb93167 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 00:00:00 +0000 Subject: [PATCH 5/7] family.py add: a `members/` renamed with `git mv` is renamed back with `git mv -- Members members`, and a printed `--sparse` says it needs git 2.34 or later, so neither remedy fails as typed (#685) #685's three #574 lines in `_spellings_refusal`, on #646's move back (#670, a837a023). 1. `git mv members Members`, staged or committed, renames each member's checkout, gitlink and `.gitmodules` section (`path = Members/IRRS`). The remedy was #646's move back of each member's own gitlink, which leaves the section naming `Members/IRRS`: typed as printed, `git submodule update --init --recursive` failed (`No url found for submodule path 'members/IRRS' in .gitmodules`) and the gate refused (`member-uninitialized` staged, `gitmodules-path-unnamed` committed and in a fresh clone). Before #646 it was #574's `git mv -- Members/IRRS `. Now, where no `members/` is on this disk, the index holds nothing under `members/` itself, everything it holds under another spelling is under one spelling on disk as a directory, none of it a variant of the mount path or kept off disk, some of it a member's own gitlink, and HEAD holds no such gitlink the index no longer does, the remedy is `git mv -- Members members` (`RENAME_BACK`), and a commit where one is left to make. What the disk holds under that spelling goes back with it and is not named. On a disk that folds case `Members` is `members/` itself and `git mv` would rename it into itself, so the move back is printed there, as before. Measured on Linux, staged, committed and in a fresh recursive clone: both `git mv -- Members members` and `mkdir members` with a `git mv` per member type clean and leave `add` and the gate passing; the directory's rename is one line, keeps every checkout, section and gitdir link, leaves no empty `Members/`, and is #574's `mv -- Members members` done with git. 2. A folding disk that pulled `Members/README` keeps its remedy, `git rm -r --cached -- Members/README` and a commit: typed as printed on the emulated disk (`DISKS`, `disk_as`) it touches no file, the file stays at `members/README`, untracked, and `add` and the gate pass in every mode. 3. `git mv --sparse` and the move back's `git rm --sparse` say "which needs git 2.34 or later" (`SPARSE_GIT`). The older way, `git update-index --no-skip-worktree` then `git mv`, says `bad source` on git 2.43, and in a cone-mode sparse checkout the matching `git rm --cached` removes nothing. tests/test_family_add_spellings_remedies.py is new (19 tests). The sparse sentence's helpers in tests/test_family_members_case_variants.py and tests/test_family_add_member_variant.py say the git it needs, and #574's sparse test asserts the text on every git and types it on 2.34 or later. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 120 ++++++- tests/test_family_add_member_variant.py | 8 +- tests/test_family_add_spellings_remedies.py | 369 ++++++++++++++++++++ tests/test_family_members_case_variants.py | 13 +- 4 files changed, 489 insertions(+), 21 deletions(-) create mode 100644 tests/test_family_add_spellings_remedies.py diff --git a/scripts/family.py b/scripts/family.py index 41df81d2..d26f5f4e 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2560,19 +2560,28 @@ def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: #: with `git mv` where it is on disk in its own spelling, with `git mv #: --sparse` where a sparse checkout keeps it off disk, and otherwise taken #: out of the index, which touches no file, as #318 takes out a variant of -#: the mount path. A member's own gitlink, held only under another spelling -#: of its row's path, is moved back to that path in the index alone (#646). -#: What is on disk and untracked is moved out of the way, and a `members/` a -#: hand renamed is moved back. +#: the mount path. A `members/` renamed with `git mv` is renamed back with +#: it, whole (#685). A member's own gitlink, held only under another +#: spelling of its row's path, is moved back to that path in the index alone +#: (#646). What is on disk and untracked is moved out of the way, and a +#: `members/` a hand renamed is moved back. COMMIT = "git commit" RENAME = "git mv" RENAME_SPARSE = "git mv --sparse" UNSTAGE = "git rm -r --cached" +RENAME_BACK = "git mv back" RESTORE = "git update-index" MOVE = "mv" MOVE_BACK = "mv back" -REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, RESTORE, MOVE, - MOVE_BACK) +REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, RENAME_BACK, RESTORE, + MOVE, MOVE_BACK) + +#: What a `--sparse` the remedy prints needs: git older than 2.34 has none, +#: and has no other way that types clean (#685). Clearing the entry's +#: `skip-worktree` bit first, `git update-index --no-skip-worktree`, leaves +#: `git mv` saying `bad source`, since the file is still off disk, and in a +#: cone-mode sparse checkout `git rm --cached` of it still removes nothing. +SPARSE_GIT = "which needs git 2.34 or later" class Spelled(NamedTuple): @@ -2592,7 +2601,11 @@ class Spelled(NamedTuple): `sparse` whether a sparse checkout keeps it off disk (`skip-worktree`), which `git rm --cached --ignore-unmatch` passes over without a word unless it is given `--sparse` too; `recorded` is empty, and `sparse` - false, for every other remedy.""" + false, for every other remedy. + + WHAT A `members/` RENAMED WITH `git mv` HOLDS (`RENAME_BACK`, #685) has + that spelling as `target`, and `committed` says whether renaming it back + leaves anything to commit.""" name: str kind: str @@ -2854,6 +2867,58 @@ def _head_spellings(root: Path) -> list[tuple[str, str, str]]: return held +def _renamed_with_git(root: Path, found: list[Spelled], + indexed: dict[str, int], + canon: str | None) -> str | None: + """The spelling a `git mv members ` renamed `members/` to, + where what `_members_spellings` found in the index is that and nothing + else (#685), or None. `indexed` is every name the index holds, and + `canon` is `_members_dir_on_disk`'s answer. + + `git mv` of `members/` renames each member's checkout, its gitlink and + its section of `.gitmodules` (`path = Members/

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

'`, and the gate finds `gitmodules-path-unnamed`. `git mv + -- members` takes all of it back, and is the one remedy, where + all of these hold: + + - NO `members/` IS ON THIS DISK (`canon` is None), so the rename is not + into it (`members/Members`). On macOS and Windows the other spelling + IS `members/`, and `git mv` takes `members` for that directory, so it + renames it into itself, which the disk refuses: there the move back is + printed, as before. + - The index holds nothing at or under `members` itself. + - Everything the index holds under another spelling is under this one + spelling, none of it a case variant of the mount path, and some of it + is a member's own gitlink (`RESTORE`), which nothing at that spelling + itself can sit beside. + - HEAD holds no member's gitlink under another spelling that the index + no longer does, which the rename would leave out. + - The disk holds that spelling as a directory in that spelling, and no + sparse checkout keeps anything under it off disk, which `git mv` + refuses without `--sparse`. + """ + if canon is not None or any( + name == MEMBERS_DIR or name.startswith(MEMBERS_DIR + "/") + for name in indexed): + return None + held = [spelled for spelled in found if spelled.where == IN_INDEX] + spellings = {spelled.name.partition("/")[0] for spelled in held} + if (len(spellings) != 1 + or not any(spelled.remedy == RESTORE for spelled in held) + or any(spelled.mount for spelled in held) + or any(spelled.remedy == RESTORE for spelled in found + if spelled.where == IN_HEAD_ONLY)): + return None + (spelling,) = spellings + if not _on_disk_as_named(root, spelling, True) or any( + name.startswith(spelling + "/") for name in _skip_worktree(root)): + return None + return spelling + + def _members_spellings(root: Path, project: str, on_disk: dict[str, tuple[str, list[str] | None]], rows: frozenset[str] = frozenset()) -> list[Spelled]: @@ -2880,6 +2945,12 @@ def _members_spellings(root: Path, project: str, taking out leaves the member where it is, and the move would replace a file there or fail on one under it: it keeps the remedy below. + A `members/` RENAMED WITH `git mv` (#685), staged or committed, is + renamed back with it, whole (`RENAME_BACK`, `_renamed_with_git`), in + place of the move back, which would leave each member's section of + `.gitmodules` naming the other spelling; what the disk holds under that + spelling goes back with it, so it is not named. + THE INDEX, the whole of it, as `_index_entries` reads it (#362). Each entry's remedy acts on `_spelled_target`'s path: renamed with `git mv` where that path is on disk in its own spelling (`_on_disk_as_named`) and @@ -2970,13 +3041,21 @@ def _members_spellings(root: Path, project: str, continue found.append(Spelled(_shown(name), kind, IN_HEAD_ONLY, _shown(spelled[0]), spelled[1], COMMIT, True)) + renamed = _renamed_with_git(root, found, indexed, canon) + if renamed is not None: + found = [spelled._replace(target=renamed, remedy=RENAME_BACK, + recorded="", sparse=False) + if spelled.where == IN_INDEX else spelled + for spelled in found] # What the disk holds at a gitlink moved back from HEAD is the move's to - # name, as what it holds at an index entry is the index's. + # name, as what it holds at an index entry is the index's, and what it + # holds under a `members/` renamed with `git mv` goes back with it. for path, kind, remedy in _disk_spellings(root, on_disk, project, canon, gitlinks): if any(name == path or path.startswith(name + "/") or (remedy == MOVE and name.startswith(path + "/")) - for name in [*indexed, *restored]): + for name in [*indexed, *restored, + *([renamed] if renamed else [])]): continue found.append(Spelled(_shown(path), kind, ON_DISK, _shown(path), _spelled_target(path, project)[1], remedy)) @@ -3004,10 +3083,12 @@ def _spellings_refusal(path: str, found: list[Spelled]) -> Refusal: no command takes another's path from under it: `git commit` first for what HEAD holds and the index no longer does, since the add's own commit names three paths; then `git mv`, `git mv --sparse` and `git rm -r - --cached` for what the index holds, and a commit; then the move of a + --cached` for what the index holds, and a commit; then `git mv` back for + a `members/` renamed with it (#685), 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). + Every path is quoted for a POSIX shell (#532). A `--sparse` says it + needs git 2.34 or later (`SPARSE_GIT`, #685). 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 @@ -3060,7 +3141,7 @@ def each(command: str, targets: list[str]) -> str: f"entry ({each(RENAME, by[RENAME])})") if by[RENAME_SPARSE]: index.append("rename what the sparse checkout keeps off disk with " - "`git mv --sparse`, entry by entry " + f"`git mv --sparse`, {SPARSE_GIT}, entry by entry " f"({each(RENAME_SPARSE, by[RENAME_SPARSE])})") if by[UNSTAGE]: quoted = " ".join(shell_quoted(target, "posix") @@ -3080,6 +3161,17 @@ def each(command: str, targets: list[str]) -> str: "the rename" if renamed else "the removal") if index: parts.append(", ".join(index)) + # A `members/` RENAMED WITH `git mv` (#685), one spelling at most. + for target in by[RENAME_BACK]: + parts.append( + f"rename `{target}` back to {MEMBERS_DIR}/, since it holds the " + f"gitlinks of members whose rows place them under {MEMBERS_DIR}/, " + f"as a `git mv` of {MEMBERS_DIR}/ leaves them: `git mv` takes " + f"each checkout and its section of {GITMODULES} back with it " + f"(`{RENAME} -- {shell_quoted(target, 'posix')} {MEMBERS_DIR}`)" + + (", and commit the rename" if by[COMMIT] or any( + spelled.committed for spelled in found + if spelled.remedy == RENAME_BACK) else "")) # A MEMBER'S OWN GITLINK (#646), per row: every variant found, the # index's before HEAD's, the first of which names the commit. moved = {row: [spelled for spelled in found @@ -3105,8 +3197,8 @@ def each(command: str, targets: list[str]) -> str: "move each member's gitlink held only under another spelling of " f"its row's path, {pairs}, to that path, at the commit it " "records, in the index alone, which touches no file (`git rm -r " - f"--cached{flag} --ignore-unmatch -- {taken_out}`, then " - f"{recorded})" + f"--cached{flag} --ignore-unmatch -- {taken_out}`, " + + (f"{SPARSE_GIT}, " if flag else "") + f"then {recorded})" + (", and commit that" if by[COMMIT] or any( spelled.committed for held in moved.values() for spelled in held) else "")) diff --git a/tests/test_family_add_member_variant.py b/tests/test_family_add_member_variant.py index d90877be..0b879ffb 100644 --- a/tests/test_family_add_member_variant.py +++ b/tests/test_family_add_member_variant.py @@ -50,8 +50,9 @@ family_module, holder, offline, plant, refusal, ) from test_family_members_case_variants import ( - DIR_VARIANT, MOUNT, MOVE_BACK, added, commit_first, disk_as, disk_folds, - git_version, index_part, refused_untouched, remedy_of, renamed_members, + DIR_VARIANT, MOUNT, MOVE_BACK, NEEDS_GIT_2_34, added, commit_first, + disk_as, disk_folds, git_version, index_part, refused_untouched, + remedy_of, renamed_members, ) #: The member whose gitlink is moved into the wrong case, its row's path, @@ -90,7 +91,8 @@ def moved_back(rows: dict, commit: bool = True, f"its row's path, {pairs}, to that path, at the commit it " "records, in the index alone, which touches no file (`git rm -r " f"--cached{' --sparse' if sparse else ''} --ignore-unmatch -- " - f"{' '.join(variants)}`, then {lines})" + f"{' '.join(variants)}`, " + + (f"{NEEDS_GIT_2_34}, " if sparse else "") + f"then {lines})" + (", and commit that" if commit else "")) moved = " and ".join(f"`mv -- {name} ../{name.replace('/', '-')}`" for name in variants) diff --git a/tests/test_family_add_spellings_remedies.py b/tests/test_family_add_spellings_remedies.py new file mode 100644 index 00000000..c4ddf4d2 --- /dev/null +++ b/tests/test_family_add_spellings_remedies.py @@ -0,0 +1,369 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Three of #574's remedies in `family.py add`'s refusal of another spelling +of `members/`, each typed as printed in the state it describes (#685). + +1. A `members/` RENAMED WITH `git mv` (`git mv members Members`), staged or + committed. git renames each member's checkout, its gitlink and its + section of `.gitmodules` (`path = Members/IRRS`). The remedy was the move + back of each member's own gitlink (#646), which puts the gitlink back at + `members/IRRS` and leaves the section where it is: typed as printed, `git + submodule update` said `No url found for submodule path 'members/IRRS'`, + and the gate found `gitmodules-path-unnamed`. (Before #646 it was #574's + `git mv -- Members/IRRS `.) It is `git mv -- Members members` + now, which takes every checkout, gitlink and section back, where no + `members/` is on the disk. On a disk that folds case `Members` IS + `members/`, and `git mv` would rename it into itself, so the move back + is printed there, as before. +2. A disk that FOLDS CASE, holding `members/`, that pulled a commit adding + `Members/README`: the file is on disk as `members/README`, and the + remedy takes `Members/README` out of the index (`git rm -r --cached`), + not a `git mv`. Typed as printed, on that disk as the family tests + emulate it, it touches no file, the file stays at `members/README`, + untracked, and `add` and the gate pass. The remedy is not changed: this + test is the line's evidence. +3. A `--sparse` the remedy prints says it needs git 2.34 or later, on every + git, since older git has none and no other way that types clean. + +States that need `git mv members Members` are made on a disk that does not +fold case alone; what a folding disk answers is asked in process +(`disk_as`), as #574's and #646's tests ask it. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from conftest import cloned_just_pushed, git +from test_family import NAME, SPARE, family # noqa: F401 +from test_family_add_member_variant import ( + OTHER_ROW, OTHER_VARIANT, ROW, VARIANT, VARIANT_SHOWN, dry_run_in_process, + in_the_index, moved_back, refused_in_process, registered, + typed_as_printed, variant, +) +from test_family_add_refusals import ( # noqa: F401 + family_module, holder, offline, plant, unborn_holder, +) +from test_family_head_lost_gitmodules import ( + DISKS, assert_passes_in_every_mode, +) +from test_family_members_case_variants import ( + MOUNT, NEEDS_GIT_2_34, added, commit_first, disk_as, disk_folds, + refused_untouched, remedy_of, type_remedy, +) + +#: The refusal's first line for both members' gitlinks under `Members/`. +BOTH = in_the_index(VARIANT_SHOWN, f"`{OTHER_VARIANT}` (a submodule)") + +#: The one command the rename back prints. +RENAME_BACK = "git mv -- Members members" + + +def renamed_back(commit: bool) -> str: + """The part of the remedy that renames `Members` back with `git mv`.""" + return ("rename `Members` back to members/, since it holds the gitlinks " + "of members whose rows place them under members/, as a `git mv` " + "of members/ leaves them: `git mv` takes each checkout and its " + "section of .gitmodules back with it (`git mv -- Members " + "members`)" + (", and commit the rename" if commit else "")) + + +def renamed_with_git(root: Path, committed: bool = False) -> dict: + """`git mv members Members`, and its commit where `committed`. Returns + `{row: the commit HEAD records it at}`, as the holder began.""" + commits = {row: git("rev-parse", f"HEAD:{row}", cwd=root).stdout.strip() + for row in (ROW, OTHER_ROW)} + git("mv", "members", "Members", cwd=root) + if committed: + git("commit", "-qm", "git mv members Members", cwd=root) + return commits + + +def folding_skip(tmp_path: Path) -> None: + """`git mv members Members` renames `members/` into itself where the + disk folds case, so these states are made on one that does not.""" + if disk_folds(tmp_path): + pytest.skip("on a disk that folds case `git mv members Members` " + "renames members/ into itself") + + +# --- 1. a members/ renamed with git mv --------------------------------------- + +def staged(root: Path) -> tuple: + """The rename staged: renamed back, nothing is left to commit.""" + return (renamed_with_git(root), BOTH, remedy_of(renamed_back(False)), + [RENAME_BACK]) + + +def committed(root: Path) -> tuple: + """The rename committed: renamed back, and that committed.""" + return (renamed_with_git(root, True), BOTH, + remedy_of(renamed_back(True)), [RENAME_BACK, "git commit"]) + + +def committed_with_a_file(root: Path) -> tuple: + """The rename committed with a file of its own under `Members/`, written + as BYTES: it goes back with the rest, to `members/README`.""" + commits = renamed_with_git(root) + (root / "Members" / "README").write_bytes(b"our own notes\n") + git("add", "--", "Members/README", cwd=root) + git("commit", "-qm", "git mv members Members, and a README", cwd=root) + return (commits, BOTH + ", `Members/README` (a file)", + remedy_of(renamed_back(True)), [RENAME_BACK, "git commit"]) + + +def after_a_pending_change(root: Path) -> tuple: + """A pending change in HEAD (`MEMBERS/old` taken out of the index, not + committed) beside the staged rename: committed first, which commits the + rename too, so renaming it back has a commit to make after it.""" + plant(root, "100644", "MEMBERS/old") + git("commit", "-qm", "Track MEMBERS/old", cwd=root) + git("rm", "-q", "--cached", "--", "MEMBERS/old", cwd=root) + return (renamed_with_git(root), + BOTH + "; in HEAD and no longer in the index: `MEMBERS/old` (a " + "file)", + remedy_of(commit_first("MEMBERS/old"), renamed_back(True)), + ["git commit", RENAME_BACK, "git commit"]) + + +#: `{state: how it is made, returning ({row: commit}, the refusal's first +#: line, its remedy, what is typed)}`. +RENAMED = { + "staged": staged, + "committed": committed, + "committed, with a file of its own": committed_with_a_file, + "after a pending change in HEAD": after_a_pending_change, +} + + +@pytest.mark.parametrize("state", list(RENAMED)) +def test_members_renamed_with_git_mv_is_renamed_back_as_printed( + family, holder, offline, tmp_path, state): + """LINE 1: `git mv members Members`, and `add` of another member. The + dry run and the real run refuse it alike, with the holder exactly as it + was, and say `git mv -- Members members`, with a commit where one is + left to make. Typed as printed, both members are where their rows say, + at the commits they recorded, with their sections and checkouts, the + gate passes in every mode, and the same `add` goes through.""" + folding_skip(tmp_path) + commits, first, said, typed = RENAMED[state](holder) + refused = refused_untouched(offline, holder, family["remotes"], + "--dry-run") + assert refused_untouched(offline, holder, family["remotes"]) == refused + assert refused == (first, said) + assert typed_as_printed(holder, said) == typed + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + assert_passes_in_every_mode(holder, 2) + added(offline, holder, family["remotes"]) + assert_passes_in_every_mode(holder, 3) + + +def test_an_untracked_file_in_it_goes_back_with_it(family, holder, offline, + tmp_path): + """An untracked file under the renamed `Members/` is the rename's to + take, so it is not named on its own: an `mv` of it printed after `git + mv` would find nothing there. Typed as printed, it is at + `members/notes.txt`, untracked, and `add` and the gate pass.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder) + (holder / "Members" / "notes.txt").write_bytes(b"our own notes\n") + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (BOTH, remedy_of(renamed_back(False))) + assert typed_as_printed(holder, said[1]) == [RENAME_BACK] + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + assert (holder / "members" / "notes.txt").read_bytes() == ( + b"our own notes\n") + assert_passes_in_every_mode(holder, 2) + result = offline.add(holder, SPARE, remotes=family["remotes"]) + assert result.returncode == 0, result.stdout + result.stderr + assert git("status", "--porcelain", cwd=holder).stdout == ( + "?? members/notes.txt\n") + assert_passes_in_every_mode(holder, 3) + + +def test_a_fresh_clone_of_it_is_renamed_back_as_printed(family, holder, + offline, tmp_path): + """The committed rename pushed, and a fresh recursive clone of it, its + checkouts under `Members/`: the same text, and typed as printed the + clone's gate passes and the same `add` goes through.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder, True) + remote = family["remotes"] / f"{NAME}.git" + branch = "w685/renamed-with-git" + git("push", "-q", str(remote), f"HEAD:refs/heads/{branch}", cwd=holder) + clone = cloned_just_pushed(remote, tmp_path / "clone" / NAME, branch) + said = refused_untouched(offline, clone, family["remotes"], "--dry-run") + assert said == (BOTH, remedy_of(renamed_back(True))) + assert typed_as_printed(clone, said[1]) == [RENAME_BACK, "git commit"] + for row, commit in commits.items(): + registered(clone, family["root"], commit, row) + assert_passes_in_every_mode(clone, 2) + added(offline, clone, family["remotes"]) + + +@pytest.mark.parametrize("folding", [False, True], ids=["linux", "folding"]) +def test_only_a_disk_with_no_members_renames_it_back( + family, holder, family_module, offline, monkeypatch, capsys, + tmp_path, folding): + """THE DISK DECIDES. Asked in process of the same layout, a disk that + does not fold case holds no `members/` and renames `Members` back. One + that folds case takes `Members` for `members/` itself, where `git mv -- + Members members` would rename it into itself (`members/Members`), so it + gets #646's move back, as it did before.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder) + disk_as(monkeypatch, holder, folding) + rows = {ROW: ([VARIANT], commits[ROW]), + OTHER_ROW: ([OTHER_VARIANT], commits[OTHER_ROW])} + move, closing = moved_back(rows, commit=False) + assert dry_run_in_process(family_module, monkeypatch, offline, holder, + family["remotes"], capsys) == ( + BOTH, remedy_of(move, *closing) if folding + else remedy_of(renamed_back(False))) + + +def members_beside_it(root: Path) -> None: + """A `members/` on disk beside `Members/`, holding a file: the rename + would go into it, `members/Members`.""" + (root / "members").mkdir() + (root / "members" / "notes").write_bytes(b"our own notes\n") + + +def moved_out_of_the_holder(root: Path) -> None: + """`Members/` gone from disk, as if moved out of the holder: `git mv` + of it says `bad source`.""" + os.rename(root / "Members", root.parent / "Members-elsewhere") + + +def head_alone_holds_one(root: Path) -> None: + """The rename committed, then one member's gitlink taken out of the + index and not committed: the rename back would leave it out.""" + git("commit", "-qm", "git mv members Members", cwd=root) + git("rm", "-q", "--cached", "--", OTHER_VARIANT, cwd=root) + + +#: `{state: how it is laid out after `git mv members Members`, staged}`. +NOT_RENAMED_BACK = { + "a members/ on disk beside it": members_beside_it, + "an entry the index holds under members/": ( + lambda root: plant(root, "100644", "members/notes")), + "another spelling in the index too": ( + lambda root: plant(root, "100644", "MEMBERS/notes")), + "a case variant of the mount path under it": ( + lambda root: plant(root, "100644", f"Members/{SPARE}")), + "an entry under it a sparse checkout keeps off disk": ( + lambda root: git("update-index", "--skip-worktree", "--", + OTHER_VARIANT, cwd=root)), + "a member's gitlink HEAD alone holds": head_alone_holds_one, + "nothing of it on disk": moved_out_of_the_holder, +} + + +@pytest.mark.parametrize("state", list(NOT_RENAMED_BACK)) +def test_only_members_renamed_whole_is_renamed_back(holder, family_module, + tmp_path, state): + """`git mv -- Members members` is printed only where it types clean and + leaves nothing out: no `members/` on disk, nothing in the index under + `members/` itself or under a second spelling, no case variant of the + mount path in it, nothing kept off disk under it, no member's gitlink + that HEAD alone holds there, and the directory on disk. Elsewhere each + path keeps its own remedy (the move back of a member's own gitlink, or + #574's), never `git mv back`.""" + folding_skip(tmp_path) + renamed_with_git(holder) + NOT_RENAMED_BACK[state](holder) + rows = family_module._rows_paths(family_module.Family.open(holder).members) + found = family_module._members_spellings( + holder, SPARE, family_module._members_on_disk(holder), rows) + assert "git mv back" not in [spelled.remedy for spelled in found] + assert {spelled.remedy for spelled in found + if spelled.kind == "a submodule"} == {"git update-index"} + + +def test_nothing_in_it_a_members_own_keeps_its_own_remedy(family_module, + tmp_path): + """A `Members/` that holds no member's own gitlink is no `members/` + renamed: a committed `Members/README` alone, on disk, keeps #574's + committed `git mv`.""" + root = unborn_holder(tmp_path) + (root / "Members").mkdir() + (root / "Members" / "README").write_bytes(b"our own notes\n") + git("add", "--", "family.yaml", "Members/README", cwd=root) + git("commit", "-qm", "Notes", cwd=root) + found = family_module._members_spellings( + root, SPARE, family_module._members_on_disk(root), frozenset()) + assert [(spelled.target, spelled.remedy) for spelled in found] == [ + ("Members/README", "git mv")] + + +# --- 2. a folding disk that pulled Members/README ---------------------------- + +def pulled_members_readme(root: Path, ignorecase: str) -> None: + """What a disk that folds case holds after pulling a commit that adds + `Members/README` beside the members: the file in the holder's own + `members/`, as `members/README`, written as BYTES, while the index and + HEAD hold `Members/README`, the blob of those bytes. git there runs with + `core.ignorecase=true`; asked both ways (`DISKS`, as #611's tests ask + it).""" + git("config", "--local", "core.ignorecase", ignorecase, cwd=root) + (root / "members" / "README").write_bytes(b"a file\n") + plant(root, "100644", "Members/README") + git("commit", "-qm", "Members/README, as pulled", cwd=root) + + +@DISKS +def test_a_folding_disk_that_pulled_members_readme_takes_it_out( + family, holder, family_module, offline, monkeypatch, capsys, + ignorecase): + """LINE 2, CLOSED BY THIS TEST: the remedy is `git rm -r --cached -- + Members/README` and a commit, where nothing is on disk in that spelling. + Each disk, emulated in process (`disk_as`), gives the subprocess's text. + Typed as printed, the file stays on disk, byte for byte, at + `members/README`, untracked, the gate passes in every mode, `add` goes + through, and the gate passes again; committing the file then, under + `members/`, still gives `family ok`.""" + pulled_members_readme(holder, ignorecase) + said = refused_untouched(offline, holder, family["remotes"]) + assert said[1] == remedy_of( + "take what has nothing on disk in that spelling out of the index, " + "which touches no file (`git rm -r --cached -- Members/README`), " + "and commit the removal") + for folding in (False, True): + with monkeypatch.context() as patched: + disk_as(patched, holder, folding) + assert dry_run_in_process(family_module, patched, offline, + holder, family["remotes"], + capsys) == said + assert type_remedy(holder, said[1]) == [ + "git rm -r --cached -- Members/README", "git commit"] + assert (holder / "members" / "README").read_bytes() == b"a file\n" + assert git("status", "--porcelain", cwd=holder).stdout == ( + "?? members/README\n") + assert_passes_in_every_mode(holder, 2) + result = offline.add(holder, SPARE, remotes=family["remotes"]) + assert result.returncode == 0, result.stdout + result.stderr + assert git("ls-tree", "HEAD", MOUNT, cwd=holder).stdout.startswith( + "160000 commit ") + assert_passes_in_every_mode(holder, 3) + git("add", "--", "members/README", cwd=holder) + git("commit", "-qm", "members/README", cwd=holder) + assert_passes_in_every_mode(holder, 3) + + +# --- 3. --sparse needs git 2.34 or later ------------------------------------- + +def test_the_move_backs_sparse_says_the_git_it_needs(holder, family_module): + """LINE 3, for the move back's `git rm --sparse` (#646's S1): asked on + any git, the text says it needs git 2.34 or later, right after the + command, and only there. (`git mv --sparse`, #574's, is asked in + `test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse`.)""" + commit = variant(holder) + git("update-index", "--skip-worktree", "--", VARIANT, cwd=holder) + move, closing = moved_back({ROW: ([VARIANT], commit)}, sparse=True) + assert f"--ignore-unmatch -- {VARIANT}`, {NEEDS_GIT_2_34}, then " in move + assert refused_in_process(family_module, holder)[1] == remedy_of( + move, *closing) diff --git a/tests/test_family_members_case_variants.py b/tests/test_family_members_case_variants.py index 0b97561d..243d298e 100644 --- a/tests/test_family_members_case_variants.py +++ b/tests/test_family_members_case_variants.py @@ -85,6 +85,10 @@ "Remediation: this is a half-finished add or a hand-made directory. " "Remove it (`git rm -r` if it is tracked), then re-run.") +#: What a printed `--sparse` says it needs: git older than 2.34 has none +#: (#685). +NEEDS_GIT_2_34 = "which needs git 2.34 or later" + #: The part of the remedy for a `members/` a hand renamed `Members/`. MOVE_BACK = ("move `Members` back to members/, since it holds checkouts of " "members git tracks under members/ (`mv -- Members members`)") @@ -104,7 +108,7 @@ def index_part(*, rename=(), sparse=(), unstage=(), commit="") -> str: f"entry ({each('git mv', rename)})") if sparse: said.append("rename what the sparse checkout keeps off disk with " - "`git mv --sparse`, entry by entry " + f"`git mv --sparse`, {NEEDS_GIT_2_34}, entry by entry " f"({each('git mv --sparse', sparse)})") if unstage: said.append("take what has nothing on disk in that spelling out of " @@ -327,21 +331,22 @@ def git_version() -> tuple[int, int]: return int(found.group(1)), int(found.group(2)) -@pytest.mark.skipif(git_version() < (2, 34), - reason="`git mv --sparse` came with git 2.34") def test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse( family, holder, offline): """A SPARSE CHECKOUT (#574's review, S2): an entry the checkout keeps off disk (`skip-worktree`) is refused by `git mv` as `not under version control` and by `git rm --cached` as outside the sparse-checkout definition. `git mv --sparse` renames it, keeping what it holds, and the - rename is committed.""" + rename is committed. The text says that needs git 2.34 or later, on + every git (#685); it is typed only there.""" plant(holder, "100644", "Members/README") git("update-index", "--skip-worktree", "--", "Members/README", cwd=holder) said, remedy = refused_untouched(offline, holder, family["remotes"]) assert said == DIR_VARIANT + "in the index: `Members/README` (a file)" assert remedy == remedy_of(index_part(sparse=["Members/README"], commit="the rename")) + if git_version() < (2, 34): + pytest.skip("`git mv --sparse` came with git 2.34") assert type_remedy(holder, remedy) == [ "git mv --sparse -- Members/README renamed-1", "git commit"] added(offline, holder, family["remotes"]) From 215e8102947332957b076026aa8a07bb04f4685c Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 01:05:04 +0000 Subject: [PATCH 6/7] family.py add: the rename back of a `members/` renamed with `git mv` goes through a free name in two steps, after what the disk lacks under it is put back, and `git mv --sparse` says it needs git 2.38, so no printed line stops at `bad source` or renames a directory into itself (#685) rv685's review of 70307d20 (2 MUST, 3 SHOULD, 6 NIT), in one commit. - MUST-1. `git mv --sparse` of the one entry a sparse checkout keeps off disk says `bad source` before git 2.38: v2.34.0 to v2.37.7 have the same `builtin/mv.c`, and 2.38.0 first looks a single skip-worktree source up. The sentence says 2.38 (`SPARSE_MV_GIT`); the move back's `git rm --sparse` keeps 2.34 (`SPARSE_RM_GIT`). #574's sparse test types it only on 2.38 or later. Read from git's source; only 2.43 is here. - MUST-2. `git mv` of a directory says `bad source` for any entry under it that is not on disk (an empty `Members/`, a checkout gone, a committed file deleted), and the refusal repeated. Each such entry is now put back first: a file with `git checkout --`, a gitlink with `git submodule update --init --recursive --`, where a section with a url gives its path. Where none can be put back, or the disk holds the path in another form, the move back is printed, as before. - SHOULD-1. An entry under the spelling at a stage other than 0 keeps the move back. A change to `.gitmodules` nobody staged, which `git mv` and `git rm` of a gitlink refuse to run beside, is staged first (`git add -- .gitmodules`) wherever the index holds a gitlink the refusal names; `_checked_mount_path` passes `git ls-files -m`'s answer in, and no refusal moves. - SHOULD-2. The comment on the older way says what was measured: git 2.43 alone. - SHOULD-3. The rename back is `git mv -- Members members.renaming`, then `git mv -- members.renaming members` (`_free_name` finds a free name), one text on every disk: on macOS and Windows `Members` is `members/`, where a one-step rename goes into itself, and neither step's destination exists when it runs. A `members/` on disk is allowed where it is that spelling's own directory (`samefile`). - NITs: a file `members` in the index is a tested exclusion; the fresh clone's gate runs after `add`; one state types with `core.ignorecase=true`; the sentence's cause no longer says only `git mv` made the state. `Spelled` gains `through` and `put_back`. tests/test_family_add_spellings_remedies.py has 38 tests. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 236 +++++++++---- tests/test_family_add_spellings_remedies.py | 360 ++++++++++++++++---- tests/test_family_members_case_variants.py | 17 +- 3 files changed, 483 insertions(+), 130 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index d26f5f4e..91d95467 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2561,10 +2561,11 @@ def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: #: --sparse` where a sparse checkout keeps it off disk, and otherwise taken #: out of the index, which touches no file, as #318 takes out a variant of #: the mount path. A `members/` renamed with `git mv` is renamed back with -#: it, whole (#685). A member's own gitlink, held only under another -#: spelling of its row's path, is moved back to that path in the index alone -#: (#646). What is on disk and untracked is moved out of the way, and a -#: `members/` a hand renamed is moved back. +#: it, whole, in two steps through a free name (#685). A member's own +#: gitlink, held only under another spelling of its row's path, is moved +#: back to that path in the index alone (#646). What is on disk and +#: untracked is moved out of the way, and a `members/` a hand renamed is +#: moved back. COMMIT = "git commit" RENAME = "git mv" RENAME_SPARSE = "git mv --sparse" @@ -2576,12 +2577,26 @@ def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, RENAME_BACK, RESTORE, MOVE, MOVE_BACK) -#: What a `--sparse` the remedy prints needs: git older than 2.34 has none, -#: and has no other way that types clean (#685). Clearing the entry's -#: `skip-worktree` bit first, `git update-index --no-skip-worktree`, leaves -#: `git mv` saying `bad source`, since the file is still off disk, and in a -#: cone-mode sparse checkout `git rm --cached` of it still removes nothing. -SPARSE_GIT = "which needs git 2.34 or later" +#: What a `--sparse` the remedy prints needs (#685). `git rm --sparse` came +#: with git 2.34. `git mv` took `--sparse` in 2.34 too, but there it still +#: says `bad source` for one entry a sparse checkout keeps off disk, which +#: is the one entry the remedy names: its `builtin/mv.c` is the same from +#: 2.34.0 to 2.37.7, and 2.38.0 is the first to look that entry up in the +#: index, and the first whose tests move one (`can move out-of-cone file +#: with --sparse`). Read from git's source, not run. +#: +#: NO OTHER WAY IS NAMED. On git 2.43, the only git run here, clearing the +#: entry's `skip-worktree` bit first (`git update-index +#: --no-skip-worktree`) leaves `git mv` saying `bad source`, since the file +#: is still off disk, and in a cone-mode sparse checkout `git rm --cached` +#: then still removes nothing. An older git may differ (2.33's `mv.c` has +#: no sparse code at all), but none was run. +SPARSE_MV_GIT = "which needs git 2.38 or later" +SPARSE_RM_GIT = "which needs git 2.34 or later" + +#: The name a `members/` renamed with `git mv` is renamed back through, or +#: the first of its numbered variants that nothing holds (#685). +RENAMING = "members.renaming" class Spelled(NamedTuple): @@ -2604,8 +2619,12 @@ class Spelled(NamedTuple): false, for every other remedy. WHAT A `members/` RENAMED WITH `git mv` HOLDS (`RENAME_BACK`, #685) has - that spelling as `target`, and `committed` says whether renaming it back - leaves anything to commit.""" + that spelling as `target`, `committed` says whether renaming it back + leaves anything to commit, `through` is the free name it is renamed + back through, and `put_back` says whether the disk lacks this entry, + which is put back first, since `git mv` of a directory refuses any + entry under it that is not on disk (`bad source`); `through` is empty, + and `put_back` false, for every other remedy.""" name: str kind: str @@ -2616,6 +2635,8 @@ class Spelled(NamedTuple): committed: bool = False recorded: str = "" sparse: bool = False + through: str = "" + put_back: bool = False def _folded(name: str) -> str: @@ -2867,28 +2888,42 @@ def _head_spellings(root: Path) -> list[tuple[str, str, str]]: return held +def _free_name(root: Path, listed: dict[str, list]) -> str: + """`RENAMING`, or else the first of `RENAMING-2`, `RENAMING-3` and on, + that nothing the index holds (`listed`) is at or under, in any case, and + that the disk does not hold, as it resolves a name.""" + folded = {_folded(name) for name in listed} + count = 1 + while True: + name = RENAMING if count == 1 else f"{RENAMING}-{count}" + if not os.path.lexists(root / name) and not any( + held == name or held.startswith(name + "/") + for held in folded): + return name + count += 1 + + def _renamed_with_git(root: Path, found: list[Spelled], - indexed: dict[str, int], - canon: str | None) -> str | None: - """The spelling a `git mv members ` renamed `members/` to, - where what `_members_spellings` found in the index is that and nothing - else (#685), or None. `indexed` is every name the index holds, and - `canon` is `_members_dir_on_disk`'s answer. + listed: dict[str, list], canon: str | None + ) -> tuple[str, str, frozenset[str]] | None: + """`(the spelling, the free name, the entries to put back first)` where + `members/` was renamed to that spelling with `git mv`, and what + `_members_spellings` found in the index is that and nothing else + (#685), or None. `listed` is every index entry's records, and `canon` + is `_members_dir_on_disk`'s answer. `git mv` of `members/` renames each member's checkout, its gitlink and its section of `.gitmodules` (`path = Members/

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

'`, and the gate finds `gitmodules-path-unnamed`. `git mv - -- members` takes all of it back, and is the one remedy, where - all of these hold: - - - NO `members/` IS ON THIS DISK (`canon` is None), so the rename is not - into it (`members/Members`). On macOS and Windows the other spelling - IS `members/`, and `git mv` takes `members` for that directory, so it - renames it into itself, which the disk refuses: there the move back is - printed, as before. + 'members/

'`, and the gate finds `gitmodules-path-unnamed`. `git mv` + of the spelling to a free name, then of that to `members`, takes all of + it back, on every disk: on macOS and Windows the spelling IS + `members/`, and a `git mv -- Members members` would take `members` for + that directory and rename it into itself (`members/Members`), which the + disk refuses. It is the remedy where all of these hold: + - The index holds nothing at or under `members` itself. - Everything the index holds under another spelling is under this one spelling, none of it a case variant of the mount path, and some of it @@ -2896,13 +2931,21 @@ def _renamed_with_git(root: Path, found: list[Spelled], itself can sit beside. - HEAD holds no member's gitlink under another spelling that the index no longer does, which the rename would leave out. - - The disk holds that spelling as a directory in that spelling, and no - sparse checkout keeps anything under it off disk, which `git mv` - refuses without `--sparse`. + - No `members/` is on this disk, or the one there is that spelling's own + directory (`samefile`), as on macOS and Windows: a second directory + would take the second step into it. + - Every entry under it is at stage 0 (`git mv` says `source directory + is empty` for an unmerged gitlink, and `conflicted` for a file), and + no sparse checkout keeps one off disk, which `git mv` refuses without + `--sparse`. + - Every entry under it is on disk as the index holds it, as the disk + resolves the name, or can be put back first: `git mv` of a directory + says `bad source` for any entry under it that is not. A file is put + back with `git checkout --`; a gitlink with `git submodule update`, + where a section of `.gitmodules` with a url gives its path. """ - if canon is not None or any( - name == MEMBERS_DIR or name.startswith(MEMBERS_DIR + "/") - for name in indexed): + if any(name == MEMBERS_DIR or name.startswith(MEMBERS_DIR + "/") + for name in listed): return None held = [spelled for spelled in found if spelled.where == IN_INDEX] spellings = {spelled.name.partition("/")[0] for spelled in held} @@ -2913,10 +2956,41 @@ def _renamed_with_git(root: Path, found: list[Spelled], if spelled.where == IN_HEAD_ONLY)): return None (spelling,) = spellings - if not _on_disk_as_named(root, spelling, True) or any( - name.startswith(spelling + "/") for name in _skip_worktree(root)): + if canon is not None: + try: + if not (os.path.isdir(root / spelling) and os.path.samefile( + root / spelling, root / canon)): + return None + except OSError: + return None + under = [name for name in listed if name.startswith(spelling + "/")] + if any(fields[2] != b"0" for name in under for fields in listed[name]): return None - return spelling + skipped = _skip_worktree(root) + sections: set[str] | None = None + put_back = set() + for name in under: + if name in skipped: + return None + here = root / name + gitlink = listed[name][0][0] == b"160000" + # As the index holds it: a gitlink a directory, anything else not. + if (os.path.isdir(here) if gitlink else os.path.lexists(here) and ( + os.path.islink(here) or not os.path.isdir(here))): + continue + if os.path.lexists(here): + return None + if gitlink: + if sections is None: + try: + sections = {path for path, url, _ + in _submodule_paths(root).values() if url} + except Refusal: + return None + if name not in sections: + return None + put_back.add(name) + return spelling, _free_name(root, listed), frozenset(put_back) def _members_spellings(root: Path, project: str, @@ -2946,10 +3020,11 @@ def _members_spellings(root: Path, project: str, file there or fail on one under it: it keeps the remedy below. A `members/` RENAMED WITH `git mv` (#685), staged or committed, is - renamed back with it, whole (`RENAME_BACK`, `_renamed_with_git`), in - place of the move back, which would leave each member's section of - `.gitmodules` naming the other spelling; what the disk holds under that - spelling goes back with it, so it is not named. + renamed back with it, whole, in two steps through a free name + (`RENAME_BACK`, `_renamed_with_git`), in place of the move back, which + would leave each member's section of `.gitmodules` naming the other + spelling; what the disk holds under that spelling goes back with it, so + it is not named. THE INDEX, the whole of it, as `_index_entries` reads it (#362). Each entry's remedy acts on `_spelled_target`'s path: renamed with `git mv` @@ -3041,10 +3116,13 @@ def _members_spellings(root: Path, project: str, continue found.append(Spelled(_shown(name), kind, IN_HEAD_ONLY, _shown(spelled[0]), spelled[1], COMMIT, True)) - renamed = _renamed_with_git(root, found, indexed, canon) + renamed = _renamed_with_git(root, found, listed, canon) if renamed is not None: - found = [spelled._replace(target=renamed, remedy=RENAME_BACK, - recorded="", sparse=False) + spelling, through, missing = renamed + gone = {_shown(name) for name in missing} + found = [spelled._replace(target=spelling, remedy=RENAME_BACK, + recorded="", sparse=False, through=through, + put_back=spelled.name in gone) if spelled.where == IN_INDEX else spelled for spelled in found] # What the disk holds at a gitlink moved back from HEAD is the move's to @@ -3055,7 +3133,7 @@ def _members_spellings(root: Path, project: str, if any(name == path or path.startswith(name + "/") or (remedy == MOVE and name.startswith(path + "/")) for name in [*indexed, *restored, - *([renamed] if renamed else [])]): + *([renamed[0]] if renamed else [])]): continue found.append(Spelled(_shown(path), kind, ON_DISK, _shown(path), _spelled_target(path, project)[1], remedy)) @@ -3067,7 +3145,8 @@ def _members_spellings(root: Path, project: str, SPELLED_ORDER = (IN_INDEX, IN_HEAD_ONLY, ON_DISK) -def _spellings_refusal(path: str, found: list[Spelled]) -> Refusal: +def _spellings_refusal(path: str, found: list[Spelled], + gitmodules_unstaged: bool = False) -> Refusal: """The refusal for what `_members_spellings` found: every path named in its exact spelling, at most five in each place, with where it was found, and every remedy (Brett Heap's ruling of 2026-10-14, "Refuse on every @@ -3083,12 +3162,21 @@ 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 `git mv` back for - a `members/` renamed with it (#685), and a commit; then the move of a - member's own gitlink back to its row's path, and a commit; then `mv` for - what is on disk and untracked, and `mv` back for a renamed `members/`. - Every path is quoted for a POSIX shell (#532). A `--sparse` says it - needs git 2.34 or later (`SPARSE_GIT`, #685). + --cached` for what the index holds, and a commit; then, for a + `members/` renamed with `git mv` (#685), what puts back an entry under + it that the disk lacks, the `git mv` of it to a free name and of that + to `members`, and a commit; then the move of a member's own gitlink back + to its row's path, and a commit; then `mv` for what is on disk and + untracked, and `mv` back for a renamed `members/`. Every path is quoted + for a POSIX shell (#532). A `--sparse` says the git it needs + (`SPARSE_MV_GIT`, `SPARSE_RM_GIT`, #685). + + A CHANGE TO `.gitmodules` NOBODY HAS STAGED (`gitmodules_unstaged`, as + `git ls-files -m` lists it) is staged first, wherever the index holds a + gitlink this refusal names: `git mv` and `git rm` of a gitlink refuse to + run beside one (`Please stage your changes to .gitmodules or stash them + to proceed`), and `gitmodules-unstaged`, which says so, is asked after + this refusal (#685). 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 @@ -3127,6 +3215,14 @@ def each(command: str, targets: list[str]) -> str: for target in targets) parts = [] + if gitmodules_unstaged and any( + spelled.where == IN_INDEX + and spelled.kind == INDEX_ENTRY_KINDS[0o160000] + for spelled in found): + parts.append( + f"stage the change to {GITMODULES} that nobody has staged, or " + "set it aside, since `git mv` and `git rm` of a gitlink refuse " + f"to run beside one (`git add -- {GITMODULES}`)") if by[COMMIT]: named = ", ".join(f"`{target}`" for target in by[COMMIT]) parts.append( @@ -3141,7 +3237,7 @@ def each(command: str, targets: list[str]) -> str: f"entry ({each(RENAME, by[RENAME])})") if by[RENAME_SPARSE]: index.append("rename what the sparse checkout keeps off disk with " - f"`git mv --sparse`, {SPARSE_GIT}, entry by entry " + f"`git mv --sparse`, {SPARSE_MV_GIT}, entry by entry " f"({each(RENAME_SPARSE, by[RENAME_SPARSE])})") if by[UNSTAGE]: quoted = " ".join(shell_quoted(target, "posix") @@ -3161,17 +3257,33 @@ def each(command: str, targets: list[str]) -> str: "the rename" if renamed else "the removal") if index: parts.append(", ".join(index)) - # A `members/` RENAMED WITH `git mv` (#685), one spelling at most. + # A `members/` RENAMED WITH `git mv` (#685), one spelling at most: what + # puts back an entry under it the disk lacks, then the two `git mv`s. for target in by[RENAME_BACK]: + held = [spelled for spelled in found if spelled.remedy == RENAME_BACK] + through = shell_quoted(held[0].through, "posix") + quoted = shell_quoted(target, "posix") + gitlink = INDEX_ENTRY_KINDS[0o160000] + back = [f"`{command} -- {' '.join(names)}`" for command, names in ( + ("git submodule update --init --recursive", sorted( + shell_quoted(spelled.name, "posix") for spelled in held + if spelled.put_back and spelled.kind == gitlink)), + ("git checkout", sorted( + shell_quoted(spelled.name, "posix") for spelled in held + if spelled.put_back and spelled.kind != gitlink))) + if names] parts.append( - f"rename `{target}` back to {MEMBERS_DIR}/, since it holds the " + (f"put back what the index holds under `{target}` and the disk " + f"does not, {' and '.join(back)}, then " if back else "") + + f"rename `{target}` back to {MEMBERS_DIR}/, since it holds the " f"gitlinks of members whose rows place them under {MEMBERS_DIR}/, " - f"as a `git mv` of {MEMBERS_DIR}/ leaves them: `git mv` takes " - f"each checkout and its section of {GITMODULES} back with it " - f"(`{RENAME} -- {shell_quoted(target, 'posix')} {MEMBERS_DIR}`)" + "in two steps through a name nothing holds, as macOS and Windows " + f"take `{target}` for {MEMBERS_DIR}/ itself: `git mv` takes each " + f"checkout, and each section of {GITMODULES} that names it, with " + f"it (`{RENAME} -- {quoted} {through}`, then `{RENAME} -- " + f"{through} {MEMBERS_DIR}`)" + (", and commit the rename" if by[COMMIT] or any( - spelled.committed for spelled in found - if spelled.remedy == RENAME_BACK) else "")) + spelled.committed for spelled in held) else "")) # A MEMBER'S OWN GITLINK (#646), per row: every variant found, the # index's before HEAD's, the first of which names the commit. moved = {row: [spelled for spelled in found @@ -3198,7 +3310,7 @@ def each(command: str, targets: list[str]) -> str: 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}`, " - + (f"{SPARSE_GIT}, " if flag else "") + f"then {recorded})" + + (f"{SPARSE_RM_GIT}, " if flag else "") + f"then {recorded})" + (", and commit that" if by[COMMIT] or any( spelled.committed for held in moved.values() for spelled in held) else "")) @@ -3496,7 +3608,9 @@ def _checked_mount_path(root: Path, project: str, path: str, + "re-run.") spelled = _members_spellings(root, project, on_disk, rows) if spelled: - raise _spellings_refusal(path, spelled) + raise _spellings_refusal( + path, spelled, GITMODULES.encode() in _git_bytes( + ["ls-files", "-m", "-z"], root).split(b"\0")) # A DIRECTORY NO LISTING COULD READ hides nothing: `lexists` asks the # path itself, as it always did, after every listing has answered. if os.path.lexists(root / path): diff --git a/tests/test_family_add_spellings_remedies.py b/tests/test_family_add_spellings_remedies.py index c4ddf4d2..ab58b8ea 100644 --- a/tests/test_family_add_spellings_remedies.py +++ b/tests/test_family_add_spellings_remedies.py @@ -9,11 +9,12 @@ `members/IRRS` and leaves the section where it is: typed as printed, `git submodule update` said `No url found for submodule path 'members/IRRS'`, and the gate found `gitmodules-path-unnamed`. (Before #646 it was #574's - `git mv -- Members/IRRS `.) It is `git mv -- Members members` - now, which takes every checkout, gitlink and section back, where no - `members/` is on the disk. On a disk that folds case `Members` IS - `members/`, and `git mv` would rename it into itself, so the move back - is printed there, as before. + `git mv -- Members/IRRS `.) It is now `git mv -- Members + members.renaming`, then `git mv -- members.renaming members`, which takes + every checkout, gitlink and section back, on every disk: on macOS and + Windows `Members` IS `members/`, where a one-step `git mv -- Members + members` renames it into itself. An entry under it that the disk lacks, + which `git mv` refuses (`bad source`), is put back first. 2. A disk that FOLDS CASE, holding `members/`, that pulled a commit adding `Members/README`: the file is on disk as `members/README`, and the remedy takes `Members/README` out of the index (`git rm -r --cached`), @@ -21,22 +22,23 @@ emulate it, it touches no file, the file stays at `members/README`, untracked, and `add` and the gate pass. The remedy is not changed: this test is the line's evidence. -3. A `--sparse` the remedy prints says it needs git 2.34 or later, on every - git, since older git has none and no other way that types clean. +3. A `--sparse` the remedy prints says the git it needs, on every git: + 2.38 for `git mv --sparse` of one entry, 2.34 for `git rm --sparse`. States that need `git mv members Members` are made on a disk that does not -fold case alone; what a folding disk answers is asked in process -(`disk_as`), as #574's and #646's tests ask it. +fold case alone, where that command works; what a folding disk answers is +asked in process (`disk_as`), as #574's and #646's tests ask it. """ from __future__ import annotations import os +import subprocess from pathlib import Path import pytest -from conftest import cloned_just_pushed, git +from conftest import cloned_just_pushed, git, rmtree from test_family import NAME, SPARE, family # noqa: F401 from test_family_add_member_variant import ( OTHER_ROW, OTHER_VARIANT, ROW, VARIANT, VARIANT_SHOWN, dry_run_in_process, @@ -56,18 +58,51 @@ #: The refusal's first line for both members' gitlinks under `Members/`. BOTH = in_the_index(VARIANT_SHOWN, f"`{OTHER_VARIANT}` (a submodule)") +#: The same, and a file of the holder's own under `Members/`. +BOTH_AND_A_FILE = BOTH + ", `Members/README` (a file)" -#: The one command the rename back prints. -RENAME_BACK = "git mv -- Members members" +#: The free name the rename back goes through, and its two steps. +FREE = "members.renaming" -def renamed_back(commit: bool) -> str: - """The part of the remedy that renames `Members` back with `git mv`.""" - return ("rename `Members` back to members/, since it holds the gitlinks " - "of members whose rows place them under members/, as a `git mv` " - "of members/ leaves them: `git mv` takes each checkout and its " - "section of .gitmodules back with it (`git mv -- Members " - "members`)" + (", and commit the rename" if commit else "")) +def two_steps(free: str = FREE) -> list[str]: + """The two `git mv` lines of the rename back, through `free`.""" + return [f"git mv -- Members {free}", f"git mv -- {free} members"] + + +#: What puts back a gitlink the disk lacks. +SUBMODULE_BACK = "git submodule update --init --recursive --" + + +def renamed_back(commit: bool, free: str = FREE, gone: tuple = (), + files: tuple = ()) -> str: + """The part of the remedy that renames `Members` back, through `free`, + putting back first each gitlink in `gone` and each file in `files`.""" + back = [f"`{command} {' '.join(names)}`" for command, names in ( + (SUBMODULE_BACK, gone), ("git checkout --", files)) if names] + first, second = two_steps(free) + return ((f"put back what the index holds under `Members` and the disk " + f"does not, {' and '.join(back)}, then " if back else "") + + "rename `Members` back to members/, since it holds the " + "gitlinks of members whose rows place them under members/, in " + "two steps through a name nothing holds, as macOS and Windows " + "take `Members` for members/ itself: `git mv` takes each " + "checkout, and each section of .gitmodules that names it, with " + f"it (`{first}`, then `{second}`)" + + (", and commit the rename" if commit else "")) + + +#: The part of the remedy for a change to `.gitmodules` nobody staged. +STAGE_FIRST = ("stage the change to .gitmodules that nobody has staged, or " + "set it aside, since `git mv` and `git rm` of a gitlink refuse " + "to run beside one (`git add -- .gitmodules`)") + + +def gitmodules_changed(root: Path) -> None: + """A change to `.gitmodules` nobody staged, a comment written as + BYTES.""" + with (root / ".gitmodules").open("ab") as out: + out.write(b"# our own note\n") def renamed_with_git(root: Path, committed: bool = False) -> dict: @@ -89,18 +124,37 @@ def folding_skip(tmp_path: Path) -> None: "renames members/ into itself") +def typed_with_local_remotes(monkeypatch, root: Path, said: str) -> list: + """`typed_as_printed`, with `protocol.file.allow=always` for the typing + alone: a `git submodule update --recursive` the remedy prints may clone + a member's legs, whose urls are the fixture's local paths.""" + with monkeypatch.context() as patched: + count = int(os.environ.get("GIT_CONFIG_COUNT", "0")) + patched.setenv("GIT_CONFIG_COUNT", str(count + 1)) + patched.setenv(f"GIT_CONFIG_KEY_{count}", "protocol.file.allow") + patched.setenv(f"GIT_CONFIG_VALUE_{count}", "always") + return typed_as_printed(root, said) + + # --- 1. a members/ renamed with git mv --------------------------------------- def staged(root: Path) -> tuple: """The rename staged: renamed back, nothing is left to commit.""" return (renamed_with_git(root), BOTH, remedy_of(renamed_back(False)), - [RENAME_BACK]) + two_steps()) def committed(root: Path) -> tuple: """The rename committed: renamed back, and that committed.""" return (renamed_with_git(root, True), BOTH, - remedy_of(renamed_back(True)), [RENAME_BACK, "git commit"]) + remedy_of(renamed_back(True)), [*two_steps(), "git commit"]) + + +def staged_ignoring_case(root: Path) -> tuple: + """The rename staged with `core.ignorecase=true`, as git on macOS and + Windows runs: the same text, typed the same.""" + git("config", "--local", "core.ignorecase", "true", cwd=root) + return staged(root) def committed_with_a_file(root: Path) -> tuple: @@ -110,8 +164,8 @@ def committed_with_a_file(root: Path) -> tuple: (root / "Members" / "README").write_bytes(b"our own notes\n") git("add", "--", "Members/README", cwd=root) git("commit", "-qm", "git mv members Members, and a README", cwd=root) - return (commits, BOTH + ", `Members/README` (a file)", - remedy_of(renamed_back(True)), [RENAME_BACK, "git commit"]) + return (commits, BOTH_AND_A_FILE, remedy_of(renamed_back(True)), + [*two_steps(), "git commit"]) def after_a_pending_change(root: Path) -> tuple: @@ -125,7 +179,71 @@ def after_a_pending_change(root: Path) -> tuple: BOTH + "; in HEAD and no longer in the index: `MEMBERS/old` (a " "file)", remedy_of(commit_first("MEMBERS/old"), renamed_back(True)), - ["git commit", RENAME_BACK, "git commit"]) + ["git commit", *two_steps(), "git commit"]) + + +def with_the_free_name_taken(root: Path) -> tuple: + """A file the holder tracks at `members.renaming`: the rename back goes + through `members.renaming-2`.""" + (root / FREE).write_bytes(b"our own notes\n") + git("add", "--", FREE, cwd=root) + git("commit", "-qm", f"A file at {FREE}", cwd=root) + return (renamed_with_git(root), BOTH, + remedy_of(renamed_back(False, f"{FREE}-2")), + two_steps(f"{FREE}-2")) + + +def one_checkout_gone(root: Path) -> tuple: + """The rename committed, then one member's checkout deleted: `git mv` + says `bad source` for it, so it is put back first, from the section that + names `Members/IRSS` (#685's review, MUST-2).""" + commits = renamed_with_git(root, True) + rmtree(root / OTHER_VARIANT) + return (commits, BOTH, + remedy_of(renamed_back(True, gone=(OTHER_VARIANT,))), + [f"{SUBMODULE_BACK} {OTHER_VARIANT}", *two_steps(), "git commit"]) + + +def an_empty_members(root: Path) -> tuple: + """The rename staged, then both checkouts moved out of the holder, + leaving `Members/` empty: both are put back first.""" + commits = renamed_with_git(root) + for name in (VARIANT, OTHER_VARIANT): + os.rename(root / name, root.parent / name.replace("/", "-")) + return (commits, BOTH, + remedy_of(renamed_back(False, gone=(VARIANT, OTHER_VARIANT))), + [f"{SUBMODULE_BACK} {VARIANT} {OTHER_VARIANT}", *two_steps()]) + + +def nothing_of_it_on_disk(root: Path) -> tuple: + """The rename staged, then `Members/` itself moved out of the holder: + both checkouts are put back first, which makes `Members/` again.""" + commits = renamed_with_git(root) + os.rename(root / "Members", root.parent / "Members-elsewhere") + return (commits, BOTH, + remedy_of(renamed_back(False, gone=(VARIANT, OTHER_VARIANT))), + [f"{SUBMODULE_BACK} {VARIANT} {OTHER_VARIANT}", *two_steps()]) + + +def a_committed_file_deleted(root: Path) -> tuple: + """The rename committed with a file of its own under `Members/`, then + that file deleted from disk and not staged: it is checked out again + first.""" + commits, first, _, _ = committed_with_a_file(root) + (root / "Members" / "README").unlink() + return (commits, first, + remedy_of(renamed_back(True, files=("Members/README",))), + ["git checkout -- Members/README", *two_steps(), "git commit"]) + + +def gitmodules_unstaged(root: Path) -> tuple: + """The rename staged, then a change to `.gitmodules` nobody staged, a + comment written as BYTES: `git mv` of a gitlink refuses to run beside it, + so it is staged first (#685's review, SHOULD-1).""" + commits = renamed_with_git(root) + gitmodules_changed(root) + return (commits, BOTH, remedy_of(STAGE_FIRST, renamed_back(False)), + ["git add -- .gitmodules", *two_steps()]) #: `{state: how it is made, returning ({row: commit}, the refusal's first @@ -133,27 +251,36 @@ def after_a_pending_change(root: Path) -> tuple: RENAMED = { "staged": staged, "committed": committed, + "staged, with core.ignorecase=true": staged_ignoring_case, "committed, with a file of its own": committed_with_a_file, "after a pending change in HEAD": after_a_pending_change, + "with the free name taken": with_the_free_name_taken, + "one checkout gone": one_checkout_gone, + "an empty Members/": an_empty_members, + "nothing of it on disk": nothing_of_it_on_disk, + "a committed file deleted from disk": a_committed_file_deleted, + "a change to .gitmodules nobody staged": gitmodules_unstaged, } @pytest.mark.parametrize("state", list(RENAMED)) def test_members_renamed_with_git_mv_is_renamed_back_as_printed( - family, holder, offline, tmp_path, state): + family, holder, offline, monkeypatch, tmp_path, state): """LINE 1: `git mv members Members`, and `add` of another member. The dry run and the real run refuse it alike, with the holder exactly as it - was, and say `git mv -- Members members`, with a commit where one is - left to make. Typed as printed, both members are where their rows say, - at the commits they recorded, with their sections and checkouts, the - gate passes in every mode, and the same `add` goes through.""" + was, and say to rename `Members` back in two steps through a free name, + after putting back what the disk lacks under it and staging a change to + `.gitmodules` nobody staged, with a commit where one is left to make. + Typed as printed, both members are where their rows say, at the commits + they recorded, with their sections and checkouts, the gate passes in + every mode, the same `add` goes through, and the gate passes again.""" folding_skip(tmp_path) commits, first, said, typed = RENAMED[state](holder) refused = refused_untouched(offline, holder, family["remotes"], "--dry-run") assert refused_untouched(offline, holder, family["remotes"]) == refused assert refused == (first, said) - assert typed_as_printed(holder, said) == typed + assert typed_with_local_remotes(monkeypatch, holder, said) == typed for row, commit in commits.items(): registered(holder, family["root"], commit, row) assert_passes_in_every_mode(holder, 2) @@ -172,7 +299,7 @@ def test_an_untracked_file_in_it_goes_back_with_it(family, holder, offline, (holder / "Members" / "notes.txt").write_bytes(b"our own notes\n") said = refused_untouched(offline, holder, family["remotes"]) assert said == (BOTH, remedy_of(renamed_back(False))) - assert typed_as_printed(holder, said[1]) == [RENAME_BACK] + assert typed_as_printed(holder, said[1]) == two_steps() for row, commit in commits.items(): registered(holder, family["root"], commit, row) assert (holder / "members" / "notes.txt").read_bytes() == ( @@ -189,7 +316,8 @@ def test_a_fresh_clone_of_it_is_renamed_back_as_printed(family, holder, offline, tmp_path): """The committed rename pushed, and a fresh recursive clone of it, its checkouts under `Members/`: the same text, and typed as printed the - clone's gate passes and the same `add` goes through.""" + clone's gate passes, the same `add` goes through, and the gate passes + again.""" folding_skip(tmp_path) commits = renamed_with_git(holder, True) remote = family["remotes"] / f"{NAME}.git" @@ -198,47 +326,61 @@ def test_a_fresh_clone_of_it_is_renamed_back_as_printed(family, holder, clone = cloned_just_pushed(remote, tmp_path / "clone" / NAME, branch) said = refused_untouched(offline, clone, family["remotes"], "--dry-run") assert said == (BOTH, remedy_of(renamed_back(True))) - assert typed_as_printed(clone, said[1]) == [RENAME_BACK, "git commit"] + assert typed_as_printed(clone, said[1]) == [*two_steps(), "git commit"] for row, commit in commits.items(): registered(clone, family["root"], commit, row) assert_passes_in_every_mode(clone, 2) added(offline, clone, family["remotes"]) + assert_passes_in_every_mode(clone, 3) + + +@pytest.mark.parametrize("folding", [False, True], ids=["linux", "folding"]) +def test_every_disk_gets_the_same_rename_back( + family, holder, family_module, offline, monkeypatch, capsys, + tmp_path, folding): + """ONE TEXT ON EVERY DISK. Asked in process of the same layout, a disk + that does not fold case holds no `members/`, and one that folds case + takes `Members` for `members/` itself: both get the two steps, since + neither step's destination is on either disk when it runs.""" + folding_skip(tmp_path) + renamed_with_git(holder) + disk_as(monkeypatch, holder, folding) + assert dry_run_in_process(family_module, monkeypatch, offline, holder, + family["remotes"], capsys) == ( + BOTH, remedy_of(renamed_back(False))) @pytest.mark.parametrize("folding", [False, True], ids=["linux", "folding"]) -def test_only_a_disk_with_no_members_renames_it_back( +def test_a_folding_disk_that_received_it_renames_it_back( family, holder, family_module, offline, monkeypatch, capsys, tmp_path, folding): - """THE DISK DECIDES. Asked in process of the same layout, a disk that - does not fold case holds no `members/` and renames `Members` back. One - that folds case takes `Members` for `members/` itself, where `git mv -- - Members members` would rename it into itself (`members/Members`), so it - gets #646's move back, as it did before.""" + """THE RENAME RECEIVED: the index holds `Members/IRRS` and + `Members/IRSS`, and the directory on disk is spelled `members`, as a + pull leaves it on a disk that folds case. There `Members` is that very + directory, and the two steps are printed. On Linux `Members` is not on + disk at all, a second spelling `git mv` cannot find, and #646's move + back is printed, as before.""" folding_skip(tmp_path) commits = renamed_with_git(holder) + os.rename(holder / "Members", holder / "members") disk_as(monkeypatch, holder, folding) - rows = {ROW: ([VARIANT], commits[ROW]), - OTHER_ROW: ([OTHER_VARIANT], commits[OTHER_ROW])} - move, closing = moved_back(rows, commit=False) + move, closing = moved_back({ROW: ([VARIANT], commits[ROW]), + OTHER_ROW: ([OTHER_VARIANT], + commits[OTHER_ROW])}, + commit=False) assert dry_run_in_process(family_module, monkeypatch, offline, holder, family["remotes"], capsys) == ( - BOTH, remedy_of(move, *closing) if folding - else remedy_of(renamed_back(False))) + BOTH, remedy_of(renamed_back(False)) if folding + else remedy_of(move, *closing)) def members_beside_it(root: Path) -> None: - """A `members/` on disk beside `Members/`, holding a file: the rename - would go into it, `members/Members`.""" + """A `members/` on disk beside `Members/`, holding a file: the second + step would go into it, `members/members.renaming`.""" (root / "members").mkdir() (root / "members" / "notes").write_bytes(b"our own notes\n") -def moved_out_of_the_holder(root: Path) -> None: - """`Members/` gone from disk, as if moved out of the holder: `git mv` - of it says `bad source`.""" - os.rename(root / "Members", root.parent / "Members-elsewhere") - - def head_alone_holds_one(root: Path) -> None: """The rename committed, then one member's gitlink taken out of the index and not committed: the rename back would leave it out.""" @@ -246,11 +388,55 @@ def head_alone_holds_one(root: Path) -> None: git("rm", "-q", "--cached", "--", OTHER_VARIANT, cwd=root) +def unmerged_under_it(root: Path) -> None: + """One member's gitlink under `Members/` unmerged, at stages 1, 2 and 3 + and at no stage 0, with nothing in progress: `git mv` says `source + directory is empty` (#685's review, SHOULD-1).""" + commit = git("rev-parse", f":{OTHER_VARIANT}", cwd=root).stdout.strip() + git("rm", "-q", "--cached", "--", OTHER_VARIANT, cwd=root) + written = subprocess.run( + ["git", "update-index", "--index-info"], cwd=str(root), + input="".join(f"160000 {commit} {stage}\t{OTHER_VARIANT}\n" + for stage in (1, 2, 3)).encode("ascii"), + capture_output=True, check=False) + assert written.returncode == 0, written.stderr + + +def gone_where_no_section_names_it(root: Path) -> None: + """One member's checkout deleted, and its section of `.gitmodules` + giving `members/IRSS`, staged: no `git submodule update` can put + `Members/IRSS` back, so nothing is renamed back (#685's review, MUST-2, + its fallback).""" + data = (root / ".gitmodules").read_bytes() + assert data.count(b"path = Members/IRSS") == 1, data + (root / ".gitmodules").write_bytes( + data.replace(b"path = Members/IRSS", b"path = members/IRSS")) + git("add", "--", ".gitmodules", cwd=root) + rmtree(root / OTHER_VARIANT) + + +def a_file_where_a_checkout_was(root: Path) -> None: + """One member's checkout deleted, and a file, written as BYTES, where + it was: the disk holds the path, and not as the index holds it.""" + rmtree(root / OTHER_VARIANT) + (root / OTHER_VARIANT).write_bytes(b"our own notes\n") + + +def a_directory_where_a_file_is(root: Path) -> None: + """A file `Members/README` in the index, and a directory there on disk: + the disk holds the path, and not as the index holds it.""" + plant(root, "100644", "Members/README") + (root / "Members" / "README").mkdir() + (root / "Members" / "README" / "inner").write_bytes(b"our own notes\n") + + #: `{state: how it is laid out after `git mv members Members`, staged}`. NOT_RENAMED_BACK = { "a members/ on disk beside it": members_beside_it, "an entry the index holds under members/": ( lambda root: plant(root, "100644", "members/notes")), + "a file at members in the index": ( + lambda root: plant(root, "100644", "members")), "another spelling in the index too": ( lambda root: plant(root, "100644", "MEMBERS/notes")), "a case variant of the mount path under it": ( @@ -259,20 +445,25 @@ def head_alone_holds_one(root: Path) -> None: lambda root: git("update-index", "--skip-worktree", "--", OTHER_VARIANT, cwd=root)), "a member's gitlink HEAD alone holds": head_alone_holds_one, - "nothing of it on disk": moved_out_of_the_holder, + "an entry under it unmerged": unmerged_under_it, + "a checkout gone that no section names there": ( + gone_where_no_section_names_it), + "a file where a checkout was": a_file_where_a_checkout_was, + "a directory where a file is": a_directory_where_a_file_is, } @pytest.mark.parametrize("state", list(NOT_RENAMED_BACK)) def test_only_members_renamed_whole_is_renamed_back(holder, family_module, tmp_path, state): - """`git mv -- Members members` is printed only where it types clean and - leaves nothing out: no `members/` on disk, nothing in the index under - `members/` itself or under a second spelling, no case variant of the - mount path in it, nothing kept off disk under it, no member's gitlink - that HEAD alone holds there, and the directory on disk. Elsewhere each - path keeps its own remedy (the move back of a member's own gitlink, or - #574's), never `git mv back`.""" + """The rename back is printed only where it types clean and leaves + nothing out: no `members/` on disk but that spelling's own directory, + nothing in the index at or under `members` itself or under a second + spelling, no case variant of the mount path in it, nothing under it + kept off disk or unmerged, no member's gitlink that HEAD alone holds + there, and every entry under it on disk as the index holds it or able + to be put back. Elsewhere each path keeps its own remedy (the move back + of a member's own gitlink, or #574's), never `git mv back`.""" folding_skip(tmp_path) renamed_with_git(holder) NOT_RENAMED_BACK[state](holder) @@ -300,6 +491,50 @@ def test_nothing_in_it_a_members_own_keeps_its_own_remedy(family_module, ("Members/README", "git mv")] +@pytest.mark.parametrize("gitlink", [True, False], + ids=["beside a gitlink", "beside a file alone"]) +def test_a_change_to_gitmodules_is_staged_first_only_beside_a_gitlink( + holder, family_module, gitlink): + """A change to `.gitmodules` nobody staged is staged first wherever the + index holds a gitlink the refusal names, as #646's move back of a + member's own gitlink is (`git rm` of a gitlink refuses beside it too), + and not where it names a file alone, which `git rm --cached` takes out + whatever `.gitmodules` holds.""" + if gitlink: + commit = variant(holder) + move, closing = moved_back({ROW: ([VARIANT], commit)}) + said = remedy_of(STAGE_FIRST, move, *closing) + else: + plant(holder, "100644", "Members/README") + said = remedy_of( + "take what has nothing on disk in that spelling out of the " + "index, which touches no file (`git rm -r --cached -- " + "Members/README`)") + gitmodules_changed(holder) + assert refused_in_process(family_module, holder)[1] == said + + +#: `{state: (what holds a name like the free one, the name chosen)}`. +FREE_NAMES = { + "nothing": ((), (), FREE), + "a file on disk": ((FREE,), (), f"{FREE}-2"), + "an entry in the index, in another case": ((), ("MEMBERS.RENAMING/x",), + f"{FREE}-2"), + "both, and the second on disk": ((f"{FREE}-2",), (FREE,), f"{FREE}-3"), +} + + +@pytest.mark.parametrize("state", list(FREE_NAMES)) +def test_the_free_name_is_one_nothing_holds(family_module, tmp_path, state): + """`_free_name`: `members.renaming`, or the first numbered variant that + neither the disk nor the index holds, the index asked in any case.""" + on_disk_names, in_index, chosen = FREE_NAMES[state] + for name in on_disk_names: + (tmp_path / name).write_bytes(b"x\n") + assert family_module._free_name( + tmp_path, {name: [] for name in in_index}) == chosen + + # --- 2. a folding disk that pulled Members/README ---------------------------- def pulled_members_readme(root: Path, ignorecase: str) -> None: @@ -354,12 +589,13 @@ def test_a_folding_disk_that_pulled_members_readme_takes_it_out( assert_passes_in_every_mode(holder, 3) -# --- 3. --sparse needs git 2.34 or later ------------------------------------- +# --- 3. --sparse says the git it needs --------------------------------------- def test_the_move_backs_sparse_says_the_git_it_needs(holder, family_module): """LINE 3, for the move back's `git rm --sparse` (#646's S1): asked on any git, the text says it needs git 2.34 or later, right after the - command, and only there. (`git mv --sparse`, #574's, is asked in + command, and only there. (`git mv --sparse`, #574's, which needs 2.38 + for the one entry it names, is asked in `test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse`.)""" commit = variant(holder) git("update-index", "--skip-worktree", "--", VARIANT, cwd=holder) diff --git a/tests/test_family_members_case_variants.py b/tests/test_family_members_case_variants.py index 243d298e..b66cf9fc 100644 --- a/tests/test_family_members_case_variants.py +++ b/tests/test_family_members_case_variants.py @@ -85,8 +85,10 @@ "Remediation: this is a half-finished add or a hand-made directory. " "Remove it (`git rm -r` if it is tracked), then re-run.") -#: What a printed `--sparse` says it needs: git older than 2.34 has none -#: (#685). +#: What a printed `--sparse` says it needs (#685): `git mv --sparse` of the +#: one entry a sparse checkout keeps off disk says `bad source` before git +#: 2.38, and `git rm --sparse` came with git 2.34. +NEEDS_GIT_2_38 = "which needs git 2.38 or later" NEEDS_GIT_2_34 = "which needs git 2.34 or later" #: The part of the remedy for a `members/` a hand renamed `Members/`. @@ -108,7 +110,7 @@ def index_part(*, rename=(), sparse=(), unstage=(), commit="") -> str: f"entry ({each('git mv', rename)})") if sparse: said.append("rename what the sparse checkout keeps off disk with " - f"`git mv --sparse`, {NEEDS_GIT_2_34}, entry by entry " + f"`git mv --sparse`, {NEEDS_GIT_2_38}, entry by entry " f"({each('git mv --sparse', sparse)})") if unstage: said.append("take what has nothing on disk in that spelling out of " @@ -337,7 +339,7 @@ def test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse( off disk (`skip-worktree`) is refused by `git mv` as `not under version control` and by `git rm --cached` as outside the sparse-checkout definition. `git mv --sparse` renames it, keeping what it holds, and the - rename is committed. The text says that needs git 2.34 or later, on + rename is committed. The text says that needs git 2.38 or later, on every git (#685); it is typed only there.""" plant(holder, "100644", "Members/README") git("update-index", "--skip-worktree", "--", "Members/README", cwd=holder) @@ -345,8 +347,9 @@ def test_an_entry_a_sparse_checkout_keeps_off_disk_takes_git_mv_sparse( assert said == DIR_VARIANT + "in the index: `Members/README` (a file)" assert remedy == remedy_of(index_part(sparse=["Members/README"], commit="the rename")) - if git_version() < (2, 34): - pytest.skip("`git mv --sparse` came with git 2.34") + if git_version() < (2, 38): + pytest.skip("`git mv --sparse` of one entry kept off disk says `bad " + "source` before git 2.38") assert type_remedy(holder, remedy) == [ "git mv --sparse -- Members/README renamed-1", "git commit"] added(offline, holder, family["remotes"]) @@ -454,7 +457,7 @@ def index_info(line: bytes) -> None: ("Members/\N{REPLACEMENT CHARACTER}", "a file", "in HEAD and no longer in the index", "Members/\N{REPLACEMENT CHARACTER}", False, "git commit", True, "", - False)] + False, "", False)] # --- the disk (#364) --------------------------------------------------------- From bbb961f89259eb5b7181fea5c77a0d41d2fd1a65 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 01:35:16 +0000 Subject: [PATCH 7/7] family.py add: what a spelling remedy says of `.gitmodules` first is told apart as `gitmodules-unstaged` tells it, a missing copy put back and an unmerged one named first, and a link is never renamed back, so no printed line stages the file's deletion or leaves the holder half renamed (#685) rv685's re-verify of 215e8102 (2 MUST, 1 SHOULD, NITs), in one commit. - MUST-A. The "stage the change to .gitmodules" part fired wherever `git ls-files -m` listed the file, which lists a deleted and an unmerged copy too. Typed as printed, `git add -- .gitmodules` staged the deletion, the printed commit took every member's section out of HEAD, and `add` then left only the new member registered; an unmerged copy's conflict markers were staged and `git mv` said `bad config line`. `_gitmodules_beside` now tells the copy apart as `_checked_gitmodules_as_staged` does: unmerged (a stage other than 0), missing (stage 0, no work-tree copy), modified (stage 0, both present, listed by `ls-files -m`), else nothing (none in the index, or one a sparse checkout keeps off disk). A missing copy is put back first (`git checkout -- .gitmodules`), for the rename back and #646's move back alike; an unmerged one is `gitmodules-unstaged`'s to name first, with its own restore, raised by `_checked_mount_path` before this refusal in that state alone. - MUST-B and SHOULD-A. The rename back is not printed where the spelling or `members` is a link: with `members -> Members` the second step said `destination already exists` and left the holder half renamed, and with `Members` a link to a directory outside, the first step said `not under version control` and the refusal repeated. Both get #646's move back, as at 70307d20. tests/test_family_add_spellings_remedies.py has 45 tests. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 85 ++++++-- tests/test_family_add_spellings_remedies.py | 213 +++++++++++++++++--- 2 files changed, 260 insertions(+), 38 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 91d95467..bcb5e326 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2934,6 +2934,11 @@ def _renamed_with_git(root: Path, found: list[Spelled], - No `members/` is on this disk, or the one there is that spelling's own directory (`samefile`), as on macOS and Windows: a second directory would take the second step into it. + - Neither that spelling nor `members` is a link, which is never + `members/` itself (`_members_dir_on_disk`): `git mv` of a link to a + directory says `not under version control`, and with a link `members + -> Members` the second step says `destination already exists` and + leaves the holder half renamed (#685's review, MUST-B). - Every entry under it is at stage 0 (`git mv` says `source directory is empty` for an unmerged gitlink, and `conflicted` for a file), and no sparse checkout keeps one off disk, which `git mv` refuses without @@ -2956,6 +2961,9 @@ def _renamed_with_git(root: Path, found: list[Spelled], if spelled.where == IN_HEAD_ONLY)): return None (spelling,) = spellings + if os.path.islink(root / spelling) or ( + canon is not None and os.path.islink(root / canon)): + return None if canon is not None: try: if not (os.path.isdir(root / spelling) and os.path.samefile( @@ -3140,13 +3148,43 @@ def _members_spellings(root: Path, project: str, return found +#: How the work tree's `.gitmodules` stands beside the index's copy, as +#: `_gitmodules_beside` answers it (#685). +GITMODULES_MODIFIED = "modified" +GITMODULES_MISSING = "missing" +GITMODULES_UNMERGED = "unmerged" + + +def _gitmodules_beside(root: Path) -> str: + """How the work tree's `.gitmodules` stands beside the index's copy, told + apart as `_checked_gitmodules_as_staged` tells them apart, for the + remedies `_spellings_refusal` prints where the index holds a gitlink + (#685): `GITMODULES_UNMERGED` where the index holds it at a stage other + than 0; `GITMODULES_MISSING` where the index holds it at stage 0 and the + work tree has none; `GITMODULES_MODIFIED` where both hold one and git + lists it as modified (`git ls-files -m`, which lists a missing one too); + else empty: none in the index, one a sparse checkout keeps off disk + (`gitmodules-sparse`'s), or one as the index holds it.""" + records = _gitmodules_in_index(root) + if any(stage for _, _, stage in records): + return GITMODULES_UNMERGED + if not records or records[0][0].upper() == "S": + return "" + if not os.path.lexists(root / GITMODULES): + return GITMODULES_MISSING + if GITMODULES.encode() in _git_bytes(["ls-files", "-m", "-z"], + root).split(b"\0"): + return GITMODULES_MODIFIED + return "" + + #: The order `_spellings_refusal` names its groups in. Its remedies run in #: `REMEDY_ORDER`: a pending change in HEAD is committed first. SPELLED_ORDER = (IN_INDEX, IN_HEAD_ONLY, ON_DISK) def _spellings_refusal(path: str, found: list[Spelled], - gitmodules_unstaged: bool = False) -> Refusal: + gitmodules: str = "") -> Refusal: """The refusal for what `_members_spellings` found: every path named in its exact spelling, at most five in each place, with where it was found, and every remedy (Brett Heap's ruling of 2026-10-14, "Refuse on every @@ -3171,12 +3209,20 @@ def _spellings_refusal(path: str, found: list[Spelled], for a POSIX shell (#532). A `--sparse` says the git it needs (`SPARSE_MV_GIT`, `SPARSE_RM_GIT`, #685). - A CHANGE TO `.gitmodules` NOBODY HAS STAGED (`gitmodules_unstaged`, as - `git ls-files -m` lists it) is staged first, wherever the index holds a - gitlink this refusal names: `git mv` and `git rm` of a gitlink refuse to - run beside one (`Please stage your changes to .gitmodules or stash them - to proceed`), and `gitmodules-unstaged`, which says so, is asked after - this refusal (#685). + THE WORK TREE'S `.gitmodules` (`gitmodules`, `_gitmodules_beside`'s + answer, which `_checked_mount_path` passes only where the index holds a + gitlink this refusal names) comes first (#685): + - A change to it nobody has staged (`GITMODULES_MODIFIED`) is staged: + `git mv` and `git rm` of a gitlink refuse to run beside one (`Please + stage your changes to .gitmodules or stash them to proceed`). + - A copy the work tree lacks (`GITMODULES_MISSING`) is put back from the + index: `git mv` rewrites a moved member's section only in the work + tree's copy and passes over it where there is none, and a `git add` + of the file would stage its deletion, which the printed commit would + then take out of HEAD with every member's section (#685's review, + MUST-A). + `gitmodules-unstaged`, which says the same of the file, is asked after + this refusal; an unmerged copy is its to name first. THE MOVE BACK (#646) is `validate-family.py`'s for the same state (#611), in one line: `git rm -r --cached --ignore-unmatch` of every @@ -3215,14 +3261,16 @@ def each(command: str, targets: list[str]) -> str: for target in targets) parts = [] - if gitmodules_unstaged and any( - spelled.where == IN_INDEX - and spelled.kind == INDEX_ENTRY_KINDS[0o160000] - for spelled in found): + if gitmodules == GITMODULES_MODIFIED: parts.append( f"stage the change to {GITMODULES} that nobody has staged, or " "set it aside, since `git mv` and `git rm` of a gitlink refuse " f"to run beside one (`git add -- {GITMODULES}`)") + elif gitmodules == GITMODULES_MISSING: + parts.append( + f"put back the copy of {GITMODULES} the index holds, which the " + "work tree lacks, since git edits each member's section there " + f"(`git checkout -- {GITMODULES}`)") if by[COMMIT]: named = ", ".join(f"`{target}`" for target in by[COMMIT]) parts.append( @@ -3608,9 +3656,18 @@ def _checked_mount_path(root: Path, project: str, path: str, + "re-run.") spelled = _members_spellings(root, project, on_disk, rows) if spelled: - raise _spellings_refusal( - path, spelled, GITMODULES.encode() in _git_bytes( - ["ls-files", "-m", "-z"], root).split(b"\0")) + # The work tree's `.gitmodules`, where a remedy runs `git mv` or `git + # rm` of a gitlink (#685). An unmerged copy is `gitmodules-unstaged`'s + # to name, with its own restore, first: git cannot rewrite it, nor + # read conflict markers, and a `git add` of it stages them. + beside = _gitmodules_beside(root) if any( + spelled_one.where == IN_INDEX + and spelled_one.kind == INDEX_ENTRY_KINDS[0o160000] + for spelled_one in spelled) else "" + if beside == GITMODULES_UNMERGED: + _checked_gitmodules_as_staged(root, "add") + beside = "" + raise _spellings_refusal(path, spelled, beside) # A DIRECTORY NO LISTING COULD READ hides nothing: `lexists` asks the # path itself, as it always did, after every listing has answered. if os.path.lexists(root / path): diff --git a/tests/test_family_add_spellings_remedies.py b/tests/test_family_add_spellings_remedies.py index ab58b8ea..fe5cb06c 100644 --- a/tests/test_family_add_spellings_remedies.py +++ b/tests/test_family_add_spellings_remedies.py @@ -41,12 +41,13 @@ from conftest import cloned_just_pushed, git, rmtree from test_family import NAME, SPARE, family # noqa: F401 from test_family_add_member_variant import ( - OTHER_ROW, OTHER_VARIANT, ROW, VARIANT, VARIANT_SHOWN, dry_run_in_process, - in_the_index, moved_back, refused_in_process, registered, - typed_as_printed, variant, + OTHER_ROW, OTHER_VARIANT, REMOVE, ROW, VARIANT, VARIANT_SHOWN, + dry_run_in_process, in_the_index, moved_back, record, refused_in_process, + registered, typed_as_printed, variant, ) from test_family_add_refusals import ( # noqa: F401 - family_module, holder, offline, plant, unborn_holder, + family_module, holder, index_bytes, offline, plant, refusal, + unborn_holder, ) from test_family_head_lost_gitmodules import ( DISKS, assert_passes_in_every_mode, @@ -96,6 +97,10 @@ def renamed_back(commit: bool, free: str = FREE, gone: tuple = (), STAGE_FIRST = ("stage the change to .gitmodules that nobody has staged, or " "set it aside, since `git mv` and `git rm` of a gitlink refuse " "to run beside one (`git add -- .gitmodules`)") +#: The part of the remedy for a `.gitmodules` the work tree lacks. +RESTORE_FIRST = ("put back the copy of .gitmodules the index holds, which the " + "work tree lacks, since git edits each member's section " + "there (`git checkout -- .gitmodules`)") def gitmodules_changed(root: Path) -> None: @@ -246,6 +251,17 @@ def gitmodules_unstaged(root: Path) -> tuple: ["git add -- .gitmodules", *two_steps()]) +def gitmodules_deleted(root: Path) -> tuple: + """The rename committed, then `.gitmodules` deleted from the work tree + and not staged: it is put back first, never staged, since `git add` of + it would stage the deletion and the commit would then take every + member's section out of HEAD (#685's review, MUST-A).""" + commits = renamed_with_git(root, True) + (root / ".gitmodules").unlink() + return (commits, BOTH, remedy_of(RESTORE_FIRST, renamed_back(True)), + ["git checkout -- .gitmodules", *two_steps(), "git commit"]) + + #: `{state: how it is made, returning ({row: commit}, the refusal's first #: line, its remedy, what is typed)}`. RENAMED = { @@ -260,6 +276,7 @@ def gitmodules_unstaged(root: Path) -> tuple: "nothing of it on disk": nothing_of_it_on_disk, "a committed file deleted from disk": a_committed_file_deleted, "a change to .gitmodules nobody staged": gitmodules_unstaged, + "a .gitmodules deleted from disk": gitmodules_deleted, } @@ -430,6 +447,30 @@ def a_directory_where_a_file_is(root: Path) -> None: (root / "Members" / "README" / "inner").write_bytes(b"our own notes\n") +def no_symlink_on_windows() -> None: + """A symlink needs a privilege Windows runners do not grant.""" + if os.name == "nt": + pytest.skip("a symlink needs a privilege Windows runners do not grant") + + +def members_a_link_to_it(root: Path) -> None: + """A link `members -> Members`: no disk's `members/` itself, and the + second step would say `destination already exists` and leave the holder + half renamed (#685's review, MUST-B).""" + no_symlink_on_windows() + os.symlink("Members", root / "members", target_is_directory=True) + + +def it_a_link_outside(root: Path) -> None: + """`Members` a link to a directory outside the holder, holding the + checkouts: `git mv` of it says `not under version control` (#685's + review, SHOULD-A).""" + no_symlink_on_windows() + os.rename(root / "Members", root.parent / "Members-real") + os.symlink(os.path.join("..", "Members-real"), root / "Members", + target_is_directory=True) + + #: `{state: how it is laid out after `git mv members Members`, staged}`. NOT_RENAMED_BACK = { "a members/ on disk beside it": members_beside_it, @@ -450,6 +491,8 @@ def a_directory_where_a_file_is(root: Path) -> None: gone_where_no_section_names_it), "a file where a checkout was": a_file_where_a_checkout_was, "a directory where a file is": a_directory_where_a_file_is, + "a link members to Members": members_a_link_to_it, + "Members a link to a directory outside": it_a_link_outside, } @@ -491,29 +534,151 @@ def test_nothing_in_it_a_members_own_keeps_its_own_remedy(family_module, ("Members/README", "git mv")] -@pytest.mark.parametrize("gitlink", [True, False], - ids=["beside a gitlink", "beside a file alone"]) -def test_a_change_to_gitmodules_is_staged_first_only_beside_a_gitlink( - holder, family_module, gitlink): - """A change to `.gitmodules` nobody staged is staged first wherever the - index holds a gitlink the refusal names, as #646's move back of a - member's own gitlink is (`git rm` of a gitlink refuses beside it too), - and not where it names a file alone, which `git rm --cached` takes out - whatever `.gitmodules` holds.""" - if gitlink: - commit = variant(holder) - move, closing = moved_back({ROW: ([VARIANT], commit)}) - said = remedy_of(STAGE_FIRST, move, *closing) - else: - plant(holder, "100644", "Members/README") - said = remedy_of( - "take what has nothing on disk in that spelling out of the " - "index, which touches no file (`git rm -r --cached -- " - "Members/README`)") - gitmodules_changed(holder) +def beside_the_move_back(root: Path) -> str: + """#646's state, IRRS's gitlink moved to `Members/IRRS` in the index + alone, and a change to `.gitmodules` nobody staged: staged first, as + `git rm` of a gitlink refuses beside it too. Returns the remedy.""" + commit = variant(root) + gitmodules_changed(root) + move, closing = moved_back({ROW: ([VARIANT], commit)}) + return remedy_of(STAGE_FIRST, move, *closing) + + +def beside_a_file_alone(root: Path) -> str: + """A file `Members/README` in the index, and a change to `.gitmodules` + nobody staged: `git rm --cached` takes the file out whatever + `.gitmodules` holds, so nothing is said of it.""" + plant(root, "100644", "Members/README") + gitmodules_changed(root) + return remedy_of( + "take what has nothing on disk in that spelling out of the index, " + "which touches no file (`git rm -r --cached -- Members/README`)") + + +def gitmodules_kept_off_disk(root: Path) -> str: + """#646's state, and a `.gitmodules` a sparse checkout keeps off disk: + `git checkout --` of it matches nothing, and it is `gitmodules-sparse`'s + to name, so nothing is said of it here.""" + commit = variant(root) + git("update-index", "--skip-worktree", "--", ".gitmodules", cwd=root) + (root / ".gitmodules").unlink() + move, closing = moved_back({ROW: ([VARIANT], commit)}) + return remedy_of(move, *closing) + + +#: `{state: how it is made, returning the remedy}`. +GITMODULES_STATES = { + "a change beside a gitlink": beside_the_move_back, + "a change beside a file alone": beside_a_file_alone, + "a copy a sparse checkout keeps off disk": gitmodules_kept_off_disk, +} + + +@pytest.mark.parametrize("state", list(GITMODULES_STATES)) +def test_what_gitmodules_needs_first_is_said_beside_a_gitlink( + holder, family_module, state): + """What the work tree's `.gitmodules` needs before `git mv` or `git rm` + of a gitlink is said first wherever the index holds a gitlink the + refusal names, and only what git can type: nothing beside a file alone, + nor for a copy a sparse checkout keeps off disk.""" + said = GITMODULES_STATES[state](holder) assert refused_in_process(family_module, holder)[1] == said +def test_no_gitmodules_in_the_index_adds_nothing_first(family_module, + tmp_path): + """A holder whose index holds no `.gitmodules` at all, and a gitlink + under `Members/`: nothing is said of the file.""" + root = unborn_holder(tmp_path) + plant(root, "160000", "Members/X") + with pytest.raises(family_module.Refusal) as caught: + family_module._checked_mount_path(root, SPARE, MOUNT, frozenset()) + assert str(caught.value).splitlines()[1] == remedy_of( + "take what has nothing on disk in that spelling out of the index, " + "which touches no file (`git rm -r --cached -- Members/X`)") + + +def test_a_missing_gitmodules_is_put_back_before_the_move_back( + family, holder, offline): + """#646's state, and `.gitmodules` deleted from the work tree and not + staged: put back first, never staged. Typed as printed, HEAD keeps + every section, IRRS is where its row says, the gate passes in every + mode, and the same `add` goes through with every member registered + (#685's review, MUST-A).""" + commit = variant(holder) + (holder / ".gitmodules").unlink() + move, closing = moved_back({ROW: ([VARIANT], commit)}) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (in_the_index(VARIANT_SHOWN), + remedy_of(RESTORE_FIRST, move, *closing)) + assert typed_as_printed(holder, said[1]) == [ + "git checkout -- .gitmodules", REMOVE, record(commit), "git commit"] + registered(holder, family["root"], commit) + assert_passes_in_every_mode(holder, 2) + added(offline, holder, family["remotes"]) + assert_passes_in_every_mode(holder, 3) + + +def unmerged_gitmodules(root: Path) -> bytes: + """`.gitmodules` unmerged at stages 1, 2 and 3, the other side adding a + comment, and its work-tree copy holding conflict markers, with nothing + in progress, as a conflicted `git stash pop` leaves it; all written as + BYTES. Returns this side's copy, the resolution the test takes.""" + ours = (root / ".gitmodules").read_bytes() + base = git("rev-parse", ":.gitmodules", cwd=root).stdout.strip() + theirs = subprocess.run( + ["git", "hash-object", "-w", "--stdin"], cwd=str(root), + input=ours + b"# theirs\n", capture_output=True, + check=True).stdout.decode("ascii").strip() + written = subprocess.run( + ["git", "update-index", "--index-info"], cwd=str(root), + input=(f"0 {'0' * 40}\t.gitmodules\n100644 {base} 1\t.gitmodules\n" + f"100644 {base} 2\t.gitmodules\n" + f"100644 {theirs} 3\t.gitmodules\n").encode("ascii"), + capture_output=True, check=False) + assert written.returncode == 0, written.stderr + (root / ".gitmodules").write_bytes( + ours + b"<<<<<<< Updated upstream\n=======\n# theirs\n" + b">>>>>>> Stashed changes\n") + return ours + + +def test_an_unmerged_gitmodules_is_named_first(family, holder, offline, + monkeypatch, tmp_path): + """The rename committed, and `.gitmodules` unmerged, its work-tree copy + holding conflict markers: `gitmodules-unstaged` names it first, with its + own restore, since `git add` of it stages the markers and `git mv` then + says `bad config line` (#685's review, MUST-A). The index is left as it + was. With the conflict resolved to this side and staged as printed, the + rename back is printed and types through: every member is where its + row says, the gate passes in every mode, and the same `add` goes + through.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder, True) + ours = unmerged_gitmodules(holder) + index = index_bytes(holder) + result = offline.add(holder, SPARE, remotes=family["remotes"]) + assert result.returncode == 2, result.stdout + result.stderr + first, said = refusal(result.stderr) + assert first.startswith("REFUSED gitmodules-unstaged: .gitmodules is " + "unmerged in the index.") + assert said == ("Remediation: resolve the conflict in it and stage the " + "result, `git add .gitmodules`, then run the same " + "command again.") + assert index_bytes(holder) == index + (holder / ".gitmodules").write_bytes(ours) + git("add", ".gitmodules", cwd=holder) + refused = refused_untouched(offline, holder, family["remotes"]) + assert refused == (BOTH, remedy_of(renamed_back(True))) + assert typed_with_local_remotes(monkeypatch, holder, refused[1]) == [ + *two_steps(), "git commit"] + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + assert_passes_in_every_mode(holder, 2) + added(offline, holder, family["remotes"]) + assert_passes_in_every_mode(holder, 3) + + #: `{state: (what holds a name like the free one, the name chosen)}`. FREE_NAMES = { "nothing": ((), (), FREE),