From 2216e1c35b025a44b6f2da90a366d578c7786b3d Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Fri, 16 Oct 2026 12:11:20 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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), From deb83e2ea1ef0bfd666a337608d1a1ee2d30e6d8 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 16:02:18 +0000 Subject: [PATCH 08/10] family.py add: the move back of a member's gitlink points its section of `.gitmodules` back, sets aside first what the index holds in its way and puts back a member whose removal is staged, its closing `mv` is guarded and leaves a checkout to delete by hand, and `--sparse` follows the sparse rules, so no printed line leaves a section naming another spelling or un-registers a member (#761) - (e) Each section of `.gitmodules` that gives a member another spelling of its row's path, as a `git mv` of its mount leaves it, is pointed back with `git config -f .gitmodules submodule..path ` and `git add -- .gitmodules`, in the move's commit where HEAD's section moved too; the removal takes `-f` where a staged variant's checkout has work of its own, which `git rm --cached` otherwise refuses. - (g), (h) Files and links the index holds at or under a row's path, or at `members` itself, are set aside first (`git mv` where on disk, else out of the index), and the member's gitlink is moved back in the same commit, in place of #574's remedy, which took the gitlink away. - Line 4: each closing `mv` is #747's guarded form, the text says where a checkout's commits are kept and to delete it by hand before the update, which now comes last, and a checkout found on disk later is never moved. - rv685's three: a member whose gitlink's removal is staged is put back with its section (`IN_GITMODULES`), a third spelling keeps its own remedy beside the rename back, and `--sparse` is printed where the sparse rules leave an entry out, whatever its skip-worktree bit says. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 647 +++++++++++++++---- tests/test_family_add_member_variant.py | 286 +++++--- tests/test_family_add_move_back_sections.py | 680 ++++++++++++++++++++ tests/test_family_add_spellings_remedies.py | 14 +- tests/test_family_members_case_variants.py | 4 +- 5 files changed, 1414 insertions(+), 217 deletions(-) create mode 100644 tests/test_family_add_move_back_sections.py diff --git a/scripts/family.py b/scripts/family.py index bcb5e326..9d582261 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2552,30 +2552,39 @@ def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: #: #363, #364). IN_INDEX = "in the index" IN_HEAD_ONLY = "in HEAD and no longer in the index" +#: A section of `.gitmodules` that gives a member another spelling of its +#: row's path, where HEAD records the member's gitlink at that path and the +#: index holds it nowhere: its removal staged (#761, rv685's candidate 1). +IN_GITMODULES = "in .gitmodules, its gitlink's removal staged" ON_DISK = "on disk and untracked" #: What the remedy does to a path `_members_spellings` found, in the order #: `_spellings_refusal` says them. What HEAD holds and the index no longer #: does is a pending change, committed first. What the index holds is renamed #: 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 `members/` renamed with `git mv` is renamed back with -#: it, whole, in two steps through a free name (#685). A member's own -#: gitlink, held only under another spelling of its row's path, is moved -#: back to that path in the index alone (#646). What is on disk and -#: untracked is moved out of the way, and a `members/` a hand renamed is -#: moved back. +#: --sparse` where the sparse rules leave it out, 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, 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), after what the index holds at or under +#: that path, or at `members` itself, is set aside (#761). What is on disk +#: and untracked is moved out of the way, but for a checkout, which a move +#: would leave pointing nowhere, and which is deleted by hand (#761); 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" +ASIDE = "git mv aside" +ASIDE_OUT = "git rm -r --cached aside" RESTORE = "git update-index" MOVE = "mv" +CHECKOUT = "by hand" MOVE_BACK = "mv back" -REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, RENAME_BACK, RESTORE, - MOVE, MOVE_BACK) +REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, RENAME_BACK, ASIDE, + ASIDE_OUT, RESTORE, MOVE, CHECKOUT, MOVE_BACK) #: What a `--sparse` the remedy prints needs (#685). `git rm --sparse` came #: with git 2.34. `git mv` took `--sparse` in 2.34 too, but there it still @@ -2591,6 +2600,21 @@ def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: #: 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. +#: +#: THE RULES, NOT THE BIT (#761, rv685's candidate 2). git 2.43 asks the +#: sparse-checkout rules of each path it updates, whatever its +#: `skip-worktree` bit says: an entry the rules leave out that is on disk +#: with the bit cleared is refused by a plain `git mv` (`exist outside of +#: your sparse-checkout definition`, exit 1), file or directory, and taken +#: by `git mv --sparse`; one off disk with the bit cleared is refused by +#: `git rm --cached` the same way and taken by `git rm --cached --sparse`; +#: and `git rm --cached --ignore-unmatch` passes over such a gitlink with +#: exit 0, as it does one marked. So each `--sparse` is printed where the +#: rules leave the entry out (`_sparse_rules_exclude`), as well as where it +#: is marked. A gitlink on disk as a directory is the one entry plain `git +#: mv` takes there, and `--sparse` takes it too. Measured on git 2.43. A git +#: that cannot answer `check-rules` leaves the bit alone to decide, as +#: before. SPARSE_MV_GIT = "which needs git 2.38 or later" SPARSE_RM_GIT = "which needs git 2.34 or later" @@ -2604,19 +2628,37 @@ class Spelled(NamedTuple): itself, or at or under a case variant of the mount path, as `_members_spellings` found it: `name` as git or the disk spells it, `kind` (`a file`, `a submodule`, `a symlink`, `a directory`), `where` - (`IN_INDEX`, `IN_HEAD_ONLY` or `ON_DISK`), `target`, the path its remedy - 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. + (`IN_INDEX`, `IN_HEAD_ONLY`, `IN_GITMODULES` or `ON_DISK`), `target`, + the path its remedy 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. `sparse` says, for `UNSTAGE`, that + the sparse rules leave it out, so that `git rm --cached` takes it only + with `--sparse` (#761). A MEMBER'S OWN GITLINK (`RESTORE`, #646) has its row's path as `target`, `committed` says whether the move leaves anything to commit, `recorded` is the commit it records, at which the move records it back, and - `sparse` whether a sparse checkout keeps it off disk (`skip-worktree`), - which `git rm --cached --ignore-unmatch` passes over without a word - unless it is given `--sparse` too; `recorded` is empty, and `sparse` - false, for every other remedy. + `sparse` whether a sparse checkout keeps it off disk (`skip-worktree`) + or its rules leave it out, either of 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 but `UNSTAGE`. `sections` names each section of `.gitmodules` + that gives the member another spelling of its row's path, as a `git mv` + of its mount leaves it, which the move points back at that path (#761); + empty for every other remedy. One found `IN_GITMODULES` is a member HEAD + records at its row's path and the index holds nowhere, named by the + path its section gives, and moved back at HEAD's commit. `forced` says + the removal takes `-f`: the index stages the variant at a commit HEAD + does not record there, and a checkout is on disk at it, which `git rm + --cached` refuses wherever that checkout has moved on or holds a file + it does not track (#761). + + WHAT IS SET ASIDE BEFORE THE MOVE BACK (`ASIDE`, `ASIDE_OUT`, #761) is + an entry the index holds at or under a row's path, or at `members` + itself, which `git update-index` would replace or fail on: renamed + with `git mv` where it is on disk as the index holds it, else taken out + of the index; `target` is the entry itself. WHAT A `members/` RENAMED WITH `git mv` HOLDS (`RENAME_BACK`, #685) has that spelling as `target`, `committed` says whether renaming it back @@ -2637,6 +2679,8 @@ class Spelled(NamedTuple): sparse: bool = False through: str = "" put_back: bool = False + sections: tuple[str, ...] = () + forced: bool = False def _folded(name: str) -> str: @@ -2810,6 +2854,8 @@ def _disk_spellings( gitlink `members/`), it is `members/` a hand renamed (Codex's P2 on #574), and it is moved BACK; only a case variant of the mount path in it is moved out of the way first. + - An entry to be moved that is a directory holding a `.git` FILE is a + CHECKOUT, never moved (`_moved_or_kept`, #761). """ found: list[tuple[str, str, str]] = [] moved_back = False @@ -2817,7 +2863,8 @@ def _disk_spellings( if name in (canon, MEMBERS_DIR): if kind == "a directory": found.extend((f"{name}/{entry}", - _disk_kind(root / name / entry), MOVE) + _disk_kind(root / name / entry), + _moved_or_kept(root / name / entry)) for entry in entries or () if _folded(entry) == _folded(project) and entry != project) @@ -2830,7 +2877,8 @@ def _disk_spellings( and os.path.lexists(root / name / entry / ".git") for entry in entries) moved_back = moved_back or back - found.extend((f"{name}/{entry}", _disk_kind(root / name / entry), MOVE) + found.extend((f"{name}/{entry}", _disk_kind(root / name / entry), + _moved_or_kept(root / name / entry)) for entry in entries if not back or _folded(entry) == _folded(project)) if back: @@ -2838,6 +2886,29 @@ def _disk_spellings( return found +def _moved_or_kept(path: Path) -> str: + """`CHECKOUT` for a directory, not a link, that holds a `.git` FILE, a + checkout whose store that file names by a path relative to it, as `git + submodule add` and `git mv` write one; `MOVE` for anything else (#761). + + A PLAIN `mv` LEAVES SUCH A CHECKOUT POINTING NOWHERE (#681's N-1, + measured for the validator with plain git): moved out of the holder, + its `.git` file names `../../.git/modules/` from a place where + that is no store, and `git -C status` says `fatal: not a + git repository`, so `` cannot be filled in to keep it. Its + commits are in that store whatever becomes of it, so it is deleted by + hand once nothing only it holds is needed. A clone of its own, whose + `.git` is a directory, holds its commits there and is moved whole by + the `mv`, which keeps them.""" + try: + if stat.S_ISLNK(path.lstat().st_mode) or not path.is_dir(): + return MOVE + return CHECKOUT if stat.S_ISREG( + (path / ".git").lstat().st_mode) else MOVE + except OSError: + return MOVE + + def _tree_entries(root: Path, tree: str, recursive: bool = False) -> list[tuple[str, str, str]]: """`[(name, kind, object)]` of the tree object `tree`, by `git ls-tree @@ -2904,13 +2975,15 @@ def _free_name(root: Path, listed: dict[str, list]) -> str: def _renamed_with_git(root: Path, found: list[Spelled], - listed: dict[str, list], canon: str | None + listed: dict[str, list], canon: str | None, + excluded: frozenset[str] = frozenset() ) -> 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. + `_members_spellings` found in the index under it is that (#685), or + None. `listed` is every index entry's records, `canon` is + `_members_dir_on_disk`'s answer, and `excluded` the index entries the + sparse rules leave out (#761). `git mv` of `members/` renames each member's checkout, its gitlink and its section of `.gitmodules` (`path = Members/

`), staged or @@ -2925,12 +2998,21 @@ def _renamed_with_git(root: Path, found: list[Spelled], disk refuses. It is the remedy where all of these hold: - The index holds nothing at or under `members` itself. - - Everything the index holds under another spelling is under this one - spelling, none of it a case variant of the mount path, and some of it - is a member's own gitlink (`RESTORE`), which nothing at that spelling - itself can sit beside. + - Every member's own gitlink (`RESTORE`) the index holds under another + spelling is under this one spelling, which nothing at that spelling + itself can sit beside, and nothing the index holds under another + spelling is a case variant of the mount path. What it holds under a + THIRD spelling keeps its own remedy, which runs first, in + `REMEDY_ORDER` (#761, rv685's NIT 5): on Linux a `git mv` of it, or + its removal from the index, touches nothing under this spelling, and + on macOS and Windows, where the three are one directory, it is never + on disk in its own spelling (`_on_disk_as_named` reads the listing), + so it is taken out of the index, which touches no file, and its file + goes back with the rest. - HEAD holds no member's gitlink under another spelling that the index - no longer does, which the rename would leave out. + no longer does, and no section of `.gitmodules` gives one whose + removal is staged (`IN_GITMODULES`, #761), which the rename would + leave out, its section naming this spelling (rv685's candidate 1). - 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. @@ -2941,8 +3023,10 @@ def _renamed_with_git(root: Path, found: list[Spelled], leaves the holder half renamed (#685's review, MUST-B). - Every entry under it is at stage 0 (`git mv` says `source directory is empty` for an unmerged gitlink, and `conflicted` for a file), and - no sparse checkout keeps one off disk, which `git mv` refuses without - `--sparse`. + no sparse checkout keeps one off disk, nor do its rules leave one out + whatever its bit says, either of which `git mv` refuses without + `--sparse` (#761: `exist outside of your sparse-checkout definition`, + exit 1, measured on git 2.43 for an entry on disk, its bit cleared). - 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 @@ -2953,12 +3037,12 @@ def _renamed_with_git(root: Path, found: list[Spelled], 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} + spellings = {spelled.name.partition("/")[0] for spelled in held + if spelled.remedy == RESTORE} 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)): + if spelled.where in (IN_HEAD_ONLY, IN_GITMODULES))): return None (spelling,) = spellings if os.path.islink(root / spelling) or ( @@ -2978,7 +3062,7 @@ def _renamed_with_git(root: Path, found: list[Spelled], sections: set[str] | None = None put_back = set() for name in under: - if name in skipped: + if name in skipped or name in excluded: return None here = root / name gitlink = listed[name][0][0] == b"160000" @@ -3022,24 +3106,60 @@ def _members_spellings(root: Path, project: str, 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. + it has no gitlink there. Where the index holds a gitlink 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: it keeps the remedy below. + + WHAT IS IN THE WAY OF THE MOVE IS SET ASIDE FIRST (#761, #670's (g) and + (h)). `git update-index --add --cacheinfo` of the row's path replaces a + file the index holds there without a word, and fails on one under it, + or at `members` itself (`appears as both a file and as a directory`, + exit 128, after the removal took the variant out), and #574's remedy + takes the member's gitlink out instead. So where the index holds only + files and links at or under a row's path, each at stage 0 and neither + marked nor left out by a sparse checkout, a gitlink at a variant is + still the member's own, and each of those entries is set aside first + (`ASIDE`, `ASIDE_OUT`), as is a file or link the index holds at + `members` itself wherever a gitlink is moved back: renamed with `git mv` + where it is on disk as the index holds it, as `_members_is_a_file` moves + `members` itself, else taken out of the index. The other order fails: + measured, the move back first replaced the file at the row's path, and + the `git mv` of that path then took the member's gitlink away. + + ITS SECTION OF `.gitmodules` IS POINTED BACK TOO (#761, #670's (e)). A + `git mv` of a member's mount (`git mv members/

Members/

`) moves + the section's `path` with it, which the move back of the gitlink leaves + naming the variant: `git submodule update` then says `No url found for + submodule path 'members/

'`. So each section that gives a member + another spelling of its row's path is named on the member's `RESTORE` + (`sections`), from the file `_submodule_paths` reads, and the move is a + change to commit where HEAD's section gives another path. + + A MEMBER WHOSE GITLINK'S REMOVAL IS STAGED (#761, rv685's candidate 1): + HEAD records it at its row's path, the index holds it nowhere, and a + section of `.gitmodules` gives it another spelling, as a `git mv + members Members` followed by `git rm --cached Members/

` leaves it. + Nothing in the index or HEAD is under another spelling for it, so the + rename back passed it by and left its section naming `Members/

`. It + is `IN_GITMODULES`, moved back at HEAD's commit, with its section, and + the rename back is not printed (`_renamed_with_git`). A `members/` RENAMED WITH `git mv` (#685), staged or committed, is renamed back with it, whole, in two steps through a free name (`RENAME_BACK`, `_renamed_with_git`), in place of the move back, which would leave each member's section of `.gitmodules` naming the other spelling; what the disk holds under that spelling goes back with it, so - it is not named. + it is not named. What the index holds under a third spelling keeps its + own remedy (#761). 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 is not `members/` itself under another spelling; renamed with `git mv - --sparse` where a sparse checkout keeps the entry off disk; else taken - out of the index, which touches no file. THEN HEAD, for what it holds + --sparse`, entry by entry, where a sparse checkout keeps the entry off + disk, or its rules leave it out whatever its bit says (#761); else taken + out of the index, which touches no file, with `--sparse` where the rules + leave it out. THEN HEAD, for what it holds that the index no longer does, a `git rm -r Members` not committed yet (#363): the add's own commit names three paths and nothing else, so it would keep that file beside the member, and with `core.ignorecase` set @@ -3062,18 +3182,56 @@ 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} + gitlink = INDEX_ENTRY_KINDS[0o160000] + # What a sparse checkout marks (`skip-worktree`) and what its rules + # leave out (#761), each asked of git once, and only where it matters. + asked: dict[str, frozenset[str]] = {} + + def marked() -> frozenset[str]: + if "marked" not in asked: + asked["marked"] = frozenset(_skip_worktree(root)) + return asked["marked"] + + def left_out() -> frozenset[str]: + if "out" not in asked: + asked["out"] = frozenset(_sparse_rules_exclude(root, [ + name for name in indexed + if _folded(name.partition("/")[0]) == MEMBERS_DIR])) + return asked["out"] + + def merged_file(name: str) -> bool: + """A file or link the index holds at stage 0 alone.""" + return indexed[name] != 0o160000 and all( + fields[2] == b"0" for fields in listed[name]) + # 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)} + # Each row's path the index holds files and links alone at or under, + # each set aside before the move back (#761), and a file or link at + # `members` itself, which no `members/

` can sit beside. + # Either, marked or left out by a sparse checkout, keeps the remedy it + # had: `git mv` and `git rm --cached` pass it over without `--sparse`. + blocked: dict[str, list[str]] = {} + for row in rows: + held = [name for name in indexed + if name == row or name.startswith(row + "/")] + if held and all(merged_file(name) for name in held): + blocked[row] = held + if blocked: + blocked = {row: held for row, held in blocked.items() + if not any(name in marked() or name in left_out() + for name in held)} + at_members = (MEMBERS_DIR if MEMBERS_DIR in indexed + and merged_file(MEMBERS_DIR) + and MEMBERS_DIR not in marked() + and MEMBERS_DIR not in left_out() else None) + owners = {**strays, **{_folded(row): row for row in blocked}} found: list[Spelled] = [] restored: list[str] = [] - sparse: set[str] | None = None + moved: set[str] = set() for name, mode in indexed.items(): spelled = _spelled_target(name, project, occupied) if spelled is None: @@ -3086,52 +3244,74 @@ def _members_spellings(root: Path, project: str, # 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)) + own = (owners.get(_folded(name)) if ours is not None and ours[0] == b"160000" else None) if own is not None: # The move leaves nothing to commit only where HEAD holds no # such variant and records the gitlink at the row's path # already, at this commit: a move to the variant, staged alone. recorded = ours[1].decode("ascii") - if sparse is None: - sparse = _skip_worktree(root) - found.append(Spelled(_shown(name), kind, IN_INDEX, _shown(own), - mount, RESTORE, - name in in_head or in_head.get(own) - != recorded, recorded, name in sparse)) + moved.add(own) + found.append(Spelled( + _shown(name), kind, IN_INDEX, _shown(own), mount, RESTORE, + name in in_head or in_head.get(own) != recorded, recorded, + name in marked() or name in left_out(), + forced=in_head.get(name) != recorded + and os.path.lexists(root / name / ".git"))) continue + out = False if target != canon and _on_disk_as_named( root, target, target != name or mode == 0o160000): - remedy = RENAME + # On disk, and left out by the rules whatever its bit says: + # plain `git mv` refuses it (#761). + remedy = RENAME_SPARSE if name in left_out() else RENAME else: - if sparse is None: - sparse = _skip_worktree(root) - remedy = RENAME_SPARSE if name in sparse else UNSTAGE - target = name if name in sparse else target + remedy = RENAME_SPARSE if name in marked() else UNSTAGE + out = remedy == UNSTAGE and name in left_out() + target = name if remedy == RENAME_SPARSE else target found.append(Spelled(_shown(name), kind, IN_INDEX, _shown(target), - mount, remedy, name in in_head)) + mount, remedy, name in in_head, sparse=out)) for name, kind, oid in head: spelled = _spelled_target(name, project, occupied) if spelled is None or name in indexed: continue - own = (strays.get(_folded(name)) - if kind == INDEX_ENTRY_KINDS[0o160000] else None) + own = owners.get(_folded(name)) if kind == gitlink else None if own is not None: restored.append(name) + moved.add(own) found.append(Spelled(_shown(name), kind, IN_HEAD_ONLY, _shown(own), spelled[1], RESTORE, True, oid)) continue found.append(Spelled(_shown(name), kind, IN_HEAD_ONLY, _shown(spelled[0]), spelled[1], COMMIT, True)) - renamed = _renamed_with_git(root, found, listed, canon) + # A member HEAD records at its row's path, the index nowhere, and a + # section gives another spelling of (#761, rv685's candidate 1), in + # sorted order: `rows` is a set, so the text would follow its hash. + staged_out = [row for row in sorted(strays.values()) if row not in moved + and row in in_head and (row, gitlink, in_head[row]) in head] + staged, gone = _sections_restored(root, found, moved, staged_out, + in_head, project) + found.extend(staged) + # What the disk holds where its section says is the move's to name too. + restored.extend(gone) + if moved: + found.extend(_set_aside(root, indexed, in_head, [ + name for row in sorted(moved & blocked.keys()) + for name in blocked[row]] + ([at_members] if at_members else []))) + renamed = _renamed_with_git(root, found, listed, canon, left_out() + if any(spelled.remedy == RESTORE + for spelled in found) else frozenset()) if renamed is not None: spelling, through, missing = renamed gone = {_shown(name) for name in missing} found = [spelled._replace(target=spelling, remedy=RENAME_BACK, recorded="", sparse=False, through=through, - put_back=spelled.name in gone) - if spelled.where == IN_INDEX else spelled + put_back=spelled.name in gone, + sections=(), forced=False) + if spelled.where == IN_INDEX + and spelled.name.startswith(_shown(spelling) + "/") + 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, and what it @@ -3139,7 +3319,8 @@ def _members_spellings(root: Path, project: str, 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 + "/")) + or (remedy in (MOVE, CHECKOUT) + and name.startswith(path + "/")) for name in [*indexed, *restored, *([renamed[0]] if renamed else [])]): continue @@ -3148,6 +3329,107 @@ def _members_spellings(root: Path, project: str, return found +def _sections_restored(root: Path, found: list[Spelled], moved: set[str], + staged_out: list[str], in_head: dict[str, str], + project: str) -> tuple[list[Spelled], list[str]]: + """Each section of `.gitmodules` that gives a member moved back another + spelling of its row's path, named on the member's `RESTORE` in `found` + (which this updates in place), and an `IN_GITMODULES` move for each row + in `staged_out`, whose gitlink HEAD records at its path and the index + holds nowhere, where a section gives it another spelling: returned, with + the path each such section gives, as git spells it (#761). `moved` is + the rows a `RESTORE` in `found` moves back to, which this adds those + rows to. + + The file is the one `_submodule_paths` reads, the work tree's where + there is one, which is the one `git config -f .gitmodules` edits; one + git cannot read names no section here, and is refused on its own once + the move is done (`gitmodules-unreadable`). HEAD's copy says whether + pointing a section back is a change to commit: it is where HEAD's + section gives another path, as after a committed `git mv`, and not + where only a staged one moved it.""" + if not moved and not staged_out: + return [], [] + try: + given = _submodule_paths(root) + except Refusal: + return [], [] + head = _head_section_paths(root) + + def giving(row: str) -> tuple[str, ...]: + return tuple(sorted( + name for name, (path, _, _) in given.items() + if path != row and _folded(path) == _folded(row))) + + added: list[Spelled] = [] + gone: list[str] = [] + for row in staged_out: + variants = sorted({given[name][0] for name in giving(row) + if _spelled_target(given[name][0], project) + is not None}) + if variants: + moved.add(row) + gone.extend(variants) + added.append(Spelled( + _shown(variants[0]), INDEX_ENTRY_KINDS[0o160000], + IN_GITMODULES, _shown(row), + _spelled_target(variants[0], project)[1], RESTORE, False, + in_head[row])) + for index, spelled in enumerate([*found, *added]): + if spelled.remedy != RESTORE: + continue + row = next(row for row in moved if _shown(row) == spelled.target) + names = giving(row) + updated = spelled._replace( + sections=names, committed=spelled.committed or any( + head.get(name) != row for name in names)) + if index < len(found): + found[index] = updated + else: + added[index - len(found)] = updated + return added, gone + + +def _head_section_paths(root: Path) -> dict[str, str]: + """`{name: path}` of every section of HEAD's `.gitmodules` that gives a + path, read as `_submodule_paths` reads a copy (`git config --blob`), or + none where HEAD names no commit, holds no `.gitmodules`, or holds one git + cannot read (#761).""" + try: + blob = git_out(["rev-parse", "-q", "--verify", f"HEAD:{GITMODULES}"], + cwd=root) + listed = git_out(["config", "-z", "--blob", blob, "--list"], + cwd=root, binary=True) + except Refusal: + return {} + found: dict[str, str] = {} + for record in listed.split(b"\0"): + key, _, value = record.decode("utf-8", + "surrogateescape").partition("\n") + section, _, variable = key.rpartition(".") + if section.startswith("submodule.") and variable == "path": + found[section[len("submodule."):]] = value + return found + + +def _set_aside(root: Path, indexed: dict[str, int], in_head: dict[str, str], + names: list[str]) -> list[Spelled]: + """`ASIDE` or `ASIDE_OUT` for each of `names`, an entry the index holds + at or under a row's path that a gitlink is moved back to, or at + `members` itself (#761): renamed with `git mv` where it is on disk as + the index holds it, a file or link and no directory, which leaves a + change to commit; else taken out of the index, which touches no file, + and leaves one where HEAD holds it.""" + aside = [] + for name in names: + on = _on_disk_as_named(root, name, False) + aside.append(Spelled( + _shown(name), INDEX_ENTRY_KINDS.get(indexed[name], "a file"), + IN_INDEX, _shown(name), False, ASIDE if on else ASIDE_OUT, + on or name in in_head)) + return aside + + #: How the work tree's `.gitmodules` stands beside the index's copy, as #: `_gitmodules_beside` answers it (#685). GITMODULES_MODIFIED = "modified" @@ -3180,7 +3462,7 @@ def _gitmodules_beside(root: Path) -> str: #: 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) +SPELLED_ORDER = (IN_INDEX, IN_HEAD_ONLY, IN_GITMODULES, ON_DISK) def _spellings_refusal(path: str, found: list[Spelled], @@ -3203,11 +3485,20 @@ def _spellings_refusal(path: str, found: list[Spelled], --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 + to `members`, and a commit; then what is in the way of the move back set + aside (#761), the move of a member's own gitlink back to its row's path + with its section of `.gitmodules`, and a commit; then `mv` for what is + on disk and untracked, a checkout there left to be deleted by hand + (#761), 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). + (`SPARSE_MV_GIT`, `SPARSE_RM_GIT`, #685), and is printed where the + sparse rules leave an entry out as well as where they mark it (#761). + + AN ENTRY IN THE WAY OF THE MOVE BACK (#761), at or under a row's path + or at `members` itself, which `git update-index` would replace or fail + on, is set aside first, in no commit of its own: the move's commit + takes both, so the history never holds the member's gitlink beside the + file, nor the file gone and the gitlink not back. THE WORK TREE'S `.gitmodules` (`gitmodules`, `_gitmodules_beside`'s answer, which `_checked_mount_path` passes only where the index holds a @@ -3231,20 +3522,38 @@ def _spellings_refusal(path: str, found: list[Spelled], without it git passes that one over, exit 0, and the index ends with two gitlinks; a `git update-index --add --cacheinfo` per row, at the commit the variant records, the first found where a row has two, the index's - before HEAD's; and the commit, where it leaves one to make. Its closing - sentences, after every other remedy, so that a `members/` moved back has - taken the checkout with it, are the validator's too: where nothing is - checked out at the row's path, as in a fresh clone, `git submodule - update --init --recursive` checks the member out there; on a - case-sensitive disk the directory left at the variant is moved out of - the holder, an `mv` with nothing to fill in, or this refusal names it - on the next run; on macOS and Windows it is the row's own directory, - and is left. The text is the same on every disk. + before HEAD's; a `git config -f .gitmodules submodule..path ` per section that gives another spelling of it, and `git add -- + .gitmodules` (#761); and the commit, where it leaves one to make. A + member whose removal is staged (`IN_GITMODULES`) is put back at HEAD's + commit in the same line. + + ITS CLOSING SENTENCES, after every other remedy, so that a `members/` + moved back has taken the checkout with it, are the validator's + (`_wrong_case_move`, #681), each `mv` guarded by two tests a POSIX shell + runs (#761): `[ -ef ]`, true where the disk takes + the two names for one directory, the row's own, and `[ -e + /.git ]`, true for a checkout, which a move leaves pointing + nowhere. So the text is the same on every disk, and the shell, not a + sentence about the system, keeps the row's own directory in place. Then + where a checkout's commits are kept, and that it is deleted by hand + once nothing only it holds is needed; a clone of its own is moved out + whole, by hand. ONE ORDER DIFFERS FROM THE VALIDATOR'S, measured: the + update `git submodule update --init --recursive` comes LAST here. A + checkout at the variant that `git mv` of the mount made shares the + member's store, `.git/modules/members/

`, and the update, checking + the member out at the row's path, points that store's `core.worktree` + there: `git -C status` then printed the row's clean checkout + and not the variant's own uncommitted file. Read before the update, it + names that file, so the checkout is weighed and deleted first. """ mount = any(spelled.mount for spelled in found) groups = [] for where in SPELLED_ORDER: - here = [spelled for spelled in found if spelled.where == where] + # What is set aside is at a row's path or at `members`, no spelling + # of either: its part names it. + here = [spelled for spelled in found if spelled.where == where + and spelled.remedy not in (ASIDE, ASIDE_OUT)] if not here: continue shown = ", ".join(f"`{spelled.name}` ({spelled.kind})" @@ -3284,15 +3593,20 @@ def each(command: str, targets: list[str]) -> str: index.append("rename what the index holds with `git mv`, entry by " 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_MV_GIT}, entry by entry " + index.append("rename what the sparse checkout leaves out with `git " + f"mv --sparse`, {SPARSE_MV_GIT}, entry by entry " f"({each(RENAME_SPARSE, by[RENAME_SPARSE])})") if by[UNSTAGE]: quoted = " ".join(shell_quoted(target, "posix") for target in by[UNSTAGE]) + # `--sparse` where the rules leave one out (#761), which `git rm + # --cached` otherwise refuses. + out = any(spelled.sparse for spelled in found + if spelled.remedy == UNSTAGE) index.append("take what has nothing on disk in that spelling out of " - f"the index, which touches no file (`{UNSTAGE} -- " - f"{quoted}`)") + f"the index, which touches no file (`{UNSTAGE}" + f"{' --sparse' if out else ''} -- {quoted}`" + + (f", {SPARSE_RM_GIT}" if out else "") + ")") # A removal from the index is a change to commit only where HEAD holds # what it removes, as it does once the pending change is committed; an # entry staged and never committed leaves nothing to commit. @@ -3332,6 +3646,29 @@ def each(command: str, targets: list[str]) -> str: f"{through} {MEMBERS_DIR}`)" + (", and commit the rename" if by[COMMIT] or any( spelled.committed for spelled in held) else "")) + # WHAT IS IN THE WAY OF THE MOVE BACK (#761), set aside first, in the + # move's own commit. + aside = [spelled for spelled in found + if spelled.remedy in (ASIDE, ASIDE_OUT)] + if aside: + how = [] + if by[ASIDE]: + how.append("rename each that is on disk as the index holds it " + f"with `git mv` ({each(RENAME, by[ASIDE])})") + if by[ASIDE_OUT]: + quoted = " ".join(shell_quoted(target, "posix") + for target in by[ASIDE_OUT]) + how.append("take each that is not on disk so out of the index, " + f"which touches no file (`{UNSTAGE} -- {quoted}`)") + named = ", ".join(f"`{spelled.name}` ({spelled.kind})" + for spelled in aside) + parts.append( + "set aside first what the index holds in the way of the move of " + f"a member's gitlink below, {named}, since git's update-index " + "replaces a file at the path it records without a word and fails " + f"beside one under it or at {MEMBERS_DIR} itself, and taking the " + "gitlink out instead un-registers the member: " + + " and ".join(how)) # 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 @@ -3341,43 +3678,119 @@ def each(command: str, targets: list[str]) -> str: 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])) + pairs = " and ".join( + f"`{name}` for the row {row}" for row in moved + for name in sorted(spelled.name for spelled in moved[row] + if spelled.where != IN_GITMODULES)) + # A member whose removal is staged (#761), put back at HEAD's commit. + staged = " and ".join( + f"{row}, whose section gives `{spelled.name}`" + for row, held in moved.items() for spelled in held + if spelled.where == IN_GITMODULES) recorded = " and ".join( "`git update-index --add --cacheinfo " f"{shell_quoted(f'160000,{held[0].recorded},{row}', 'posix')}`" for row, held in moved.items()) taken_out = " ".join(shell_quoted(name, "posix") for name in variants) # `--sparse` only where a variant needs it, so that everywhere else - # the line is the validator's word for word (#646's review, S1). + # the line is the validator's word for word (#646's review, S1), and + # `-f` likewise (#761): measured, `git rm --cached` refused a staged + # variant whose checkout held one commit, or one untracked file, of + # its own (`staged content different from both the file and the + # HEAD`, exit 1), and took it with `-f`, touching no file. flag = " --sparse" if any(spelled.sparse for held in moved.values() for spelled in held) else "" + force = any(spelled.forced for held in moved.values() + for spelled in held) + # Each section a `git mv` of a mount left naming the variant (#761). + pointed = sorted({(name, row) for row, held in moved.items() + for spelled in held for name in spelled.sections}) + sections = " and ".join( + f"`git config -f {GITMODULES} " + f"{shell_quoted(f'submodule.{name}.path', 'posix')} " + f"{shell_quoted(row, 'posix')}`" for name, row in pointed) + moves = [] + if pairs: + moves.append("move each member's gitlink held only under another " + f"spelling of its row's path, {pairs}, to that path, " + "at the commit it records") + if staged: + moves.append("put back at its row's path each member's gitlink " + "whose removal is staged though a section of " + f"{GITMODULES} gives it another spelling, {staged}, " + "at the commit HEAD records") parts.append( - "move each member's gitlink held only under another spelling of " - f"its row's path, {pairs}, to that path, at the commit it " - "records, in the index alone, which touches no file (`git rm -r " - f"--cached{flag} --ignore-unmatch -- {taken_out}`, " - + (f"{SPARSE_RM_GIT}, " if flag else "") + f"then {recorded})" + ", and ".join(moves) + ", in the index alone, which touches no " + "file" + + (f", and point each section of {GITMODULES} that gives one of " + "them another spelling of its row's path, as a `git mv` of " + "its mount leaves it, at that path" if pointed else "") + + f" (`git rm -r --cached{flag}{' -f' if force else ''} " + f"--ignore-unmatch -- {taken_out}`, " + + (f"{SPARSE_RM_GIT}, " if flag else "") + + ("whose -f takes out a staged gitlink whose checkout has moved " + "on or holds a file git does not track, which git rm " + "otherwise refuses, and with --cached touches no file, " + if force else "") + f"then {recorded}" + + (f", then {sections} and `git add -- {GITMODULES}`" + if pointed else "") + ")" + (", 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')} " + for spelled in held) or any( + spelled.committed for spelled in aside) else "")) + rows = {spelled.name: row for row, held in moved.items() + for spelled in held} + guarded = " and ".join( + f"`[ {shell_quoted(name, 'posix')} -ef " + f"{shell_quoted(rows[name], 'posix')} ] || " + f"[ -e {shell_quoted(name + '/.git', 'posix')} ] || mv -- " + f"{shell_quoted(name, 'posix')} " f"{shell_quoted('../' + name.replace('/', '-'), 'posix')}`" for name in variants) + status = " or ".join(f"`git -C {shell_quoted(name, 'posix')} status`" + for name in variants) + unpushed = " or ".join( + f"`git -C {shell_quoted(name, 'posix')} log --oneline HEAD " + "--branches --not --remotes`" 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"] + f"a directory left at {' or '.join(variants)} is the row's own " + "where the disk folds case, as macOS's and Windows' usually do " + "and a Linux mount can (WSL's /mnt/c)", + "where the disk holds the two apart it stays there, untracked, " + "and add refuses it as family-members-dir-variant: move each one " + f"that is there out of the holder, in a POSIX shell, {guarded}, " + "which moves nothing where the two names are one directory or " + "where it holds a .git entry, a checkout", + "a checkout there that git submodule add or git mv made holds a " + ".git file naming its store by a path relative to it, which a " + "move would leave pointing nowhere: its commits are kept under " + "this holder's .git/modules/, in the directory its .git file " + "names, whatever becomes of the checkout, and only what is not " + f"committed is in the checkout alone ({status} shows it, except " + "where a plain mv brought the checkout to that path: git cannot " + "open it there, so check its files by hand), so delete such a " + "checkout by hand once nothing only it holds is needed, before " + "the update below, which points a store the row shares at the " + "row's path", + "a checkout whose .git is a directory, a clone of its own, holds " + f"its commits there and nowhere else, and {unpushed} lists the " + "ones not pushed: move it out of the holder whole, by hand, " + "rather than delete it", + f"then, where nothing is checked out at {' or '.join(moved)}, as " + "in a fresh clone, `git submodule update --init --recursive` " + "checks the member out there"] if by[MOVE]: parts.append("move what is on disk and untracked out of the way " f"({each(MOVE, by[MOVE])})") + if by[CHECKOUT]: + named = " and ".join(f"`{target}`" for target in by[CHECKOUT]) + parts.append( + "delete by hand each checkout on disk and untracked, " + f"{named}, once nothing only it holds is needed, rather than " + "move it: its .git file names its store by a path relative to " + "it, which a move would leave pointing nowhere, and its commits " + "are kept in that store, so only what is not committed is in it " + "alone") for target in by[MOVE_BACK]: parts.append( f"move `{target}` back to {MEMBERS_DIR}/, since it holds " @@ -3556,7 +3969,10 @@ def _checked_mount_path(root: Path, project: str, path: str, 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). + the row's path instead (#646), with its section of `.gitmodules` + where that names the other spelling too; and a section that does, + for a member whose gitlink's removal is staged, is named as found in + `.gitmodules` (#761). - 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 @@ -3657,14 +4073,21 @@ def _checked_mount_path(root: Path, project: str, path: str, spelled = _members_spellings(root, project, on_disk, rows) if spelled: # The work tree's `.gitmodules`, where a remedy runs `git mv` or `git - # rm` of a gitlink (#685). An unmerged copy is `gitmodules-unstaged`'s - # to name, with its own restore, first: git cannot rewrite it, nor - # read conflict markers, and a `git add` of it stages them. - beside = _gitmodules_beside(root) if any( + # rm` of a gitlink (#685), or points a section back with `git config + # -f` (#761). 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. So is one a + # sparse checkout marks, as `gitmodules-sparse`, where a section is + # pointed back: `git config -f` would write a new file of that one + # line, which `git add` then refuses. + pointed = any(spelled_one.sections for spelled_one in spelled) + beside = _gitmodules_beside(root) if pointed or any( spelled_one.where == IN_INDEX and spelled_one.kind == INDEX_ENTRY_KINDS[0o160000] for spelled_one in spelled) else "" - if beside == GITMODULES_UNMERGED: + if beside == GITMODULES_UNMERGED or pointed and any( + tag.upper() == "S" for tag, _, _ + in _gitmodules_in_index(root)): _checked_gitmodules_as_staged(root, "add") beside = "" raise _spellings_refusal(path, spelled, beside) diff --git a/tests/test_family_add_member_variant.py b/tests/test_family_add_member_variant.py index 0b879ffb..98698445 100644 --- a/tests/test_family_add_member_variant.py +++ b/tests/test_family_add_member_variant.py @@ -18,11 +18,13 @@ 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. +the variant records, and a commit. Then, as the validator says, an `mv` +out of the holder of what a case-sensitive disk leaves at the variant, +guarded by two tests a POSIX shell runs so that it moves nothing where that +directory is the row's own (macOS and Windows) or a checkout, which is +deleted, or for a clone of its own moved, by hand; and last `git submodule +update --init --recursive` where nothing is checked out at the row's path +(#761). 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 @@ -68,7 +70,18 @@ REMOVE = f"git rm -r --cached --ignore-unmatch -- {VARIANT}" REMOVE_SPARSE = f"git rm -r --cached --sparse --ignore-unmatch -- {VARIANT}" SUBMODULE = "git submodule update --init --recursive" -MOVE_OUT = f"mv -- {VARIANT} ../Members-{MEMBER}" + + +def guarded(name: str = VARIANT, row: str = ROW) -> str: + """The closing `mv` of a directory left at `name`, the variant of + `row`'s path, guarded by the two tests a POSIX shell runs (#761, as + `validate-family.py` guards it, #681): it moves nothing where the two + names are one directory, nor where it holds a `.git` entry.""" + return (f"[ {name} -ef {row} ] || [ -e {name}/.git ] || " + f"mv -- {name} ../{name.replace('/', '-')}") + + +MOVE_OUT = guarded() def record(commit: str, row: str = ROW) -> str: @@ -76,34 +89,91 @@ 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, - sparse: bool = False) -> tuple[str, list[str]]: +def point_back(row: str = ROW, name: str | None = None) -> str: + """The line that points the section `name` (named after `row`, as `git + submodule add` names one) back at `row` (#761).""" + return f"git config -f .gitmodules submodule.{name or row}.path {row}" + + +def moved_back(rows: dict, commit: bool = True, sparse: bool = False, + sections: tuple = (), staged: dict | None = None, + forced: bool = False) -> tuple[str, list[str]]: """`(the part that moves each gitlink back, the closing parts)` of the remedy, for `rows`, `{row path: ([its variants], the commit named)}`; - `sparse` where a sparse checkout keeps a variant off disk.""" - variants = sorted(name for names, _ in rows.values() for name in names) + `sparse` where a sparse checkout keeps a variant off disk; `sections`, + the rows whose section is pointed back (#761); `staged`, `{row path: + (the path its section gives, HEAD's commit)}` for a member whose + removal is staged (#761); `forced` where the removal takes `-f` + (#761).""" + staged = staged or {} + every = {**{row: (names, at) for row, (names, at) in rows.items()}, + **{row: ([given], at) for row, (given, at) in staged.items()}} + variants = sorted(name for names, _ in every.values() for name in names) + row_of = {name: row for row, (names, _) in every.items() + 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)) + put = " and ".join(f"{row}, whose section gives `{given}`" + for row, (given, _) in sorted(staged.items())) lines = " and ".join(f"`{record(at, row)}`" - for row, (_, at) in sorted(rows.items())) - move = ("move each member's gitlink held only under another spelling of " - f"its row's path, {pairs}, to that path, at the commit it " - "records, in the index alone, which touches no file (`git rm -r " - f"--cached{' --sparse' if sparse else ''} --ignore-unmatch -- " + for row, (_, at) in sorted(every.items())) + said = [] + if pairs: + said.append("move each member's gitlink held only under another " + f"spelling of its row's path, {pairs}, to that path, at " + "the commit it records") + if put: + said.append("put back at its row's path each member's gitlink whose " + "removal is staged though a section of .gitmodules gives " + f"it another spelling, {put}, at the commit HEAD records") + pointed = " and ".join(f"`{point_back(row)}`" for row in sorted(sections)) + move = (", and ".join(said) + ", in the index alone, which touches no " + "file" + + (", and point each section of .gitmodules that gives one of " + "them another spelling of its row's path, as a `git mv` of " + "its mount leaves it, at that path" if sections else "") + + f" (`git rm -r --cached{' --sparse' if sparse else ''}" + f"{' -f' if forced else ''} --ignore-unmatch -- " f"{' '.join(variants)}`, " - + (f"{NEEDS_GIT_2_34}, " if sparse else "") + f"then {lines})" + + (f"{NEEDS_GIT_2_34}, " if sparse else "") + + ("whose -f takes out a staged gitlink whose checkout has moved " + "on or holds a file git does not track, which git rm " + "otherwise refuses, and with --cached touches no file, " + if forced else "") + f"then {lines}" + + (f", then {pointed} and `git add -- .gitmodules`" + if sections else "") + ")" + (", and commit that" if commit else "")) - moved = " and ".join(f"`mv -- {name} ../{name.replace('/', '-')}`" + moved = " and ".join(f"`{guarded(name, row_of[name])}`" for name in variants) + status = " or ".join(f"`git -C {name} status`" for name in variants) + unpushed = " or ".join(f"`git -C {name} log --oneline HEAD --branches " + "--not --remotes`" 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"] + f"a directory left at {' or '.join(variants)} is the row's own where " + "the disk folds case, as macOS's and Windows' usually do and a Linux " + "mount can (WSL's /mnt/c)", + "where the disk holds the two apart it stays there, untracked, and " + "add refuses it as family-members-dir-variant: move each one that is " + f"there out of the holder, in a POSIX shell, {moved}, which moves " + "nothing where the two names are one directory or where it holds a " + ".git entry, a checkout", + "a checkout there that git submodule add or git mv made holds a .git " + "file naming its store by a path relative to it, which a move would " + "leave pointing nowhere: its commits are kept under this holder's " + ".git/modules/, in the directory its .git file names, whatever " + "becomes of the checkout, and only what is not committed is in the " + f"checkout alone ({status} shows it, except where a plain mv brought " + "the checkout to that path: git cannot open it there, so check its " + "files by hand), so delete such a checkout by hand once nothing only " + "it holds is needed, before the update below, which points a store " + "the row shares at the row's path", + "a checkout whose .git is a directory, a clone of its own, holds its " + f"commits there and nowhere else, and {unpushed} lists the ones not " + "pushed: move it out of the holder whole, by hand, rather than " + "delete it", + f"then, where nothing is checked out at {' or '.join(sorted(every))}, " + f"as in a fresh clone, `{SUBMODULE}` checks the member out there"] def remedy(commit: str) -> str: @@ -138,30 +208,32 @@ def variant(root: Path, row: str = ROW, spelled: str = VARIANT, 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_in_sh(root: Path, command: str) -> None: + """`command` typed into a POSIX shell in `root`, as printed: exit 0.""" + typed = subprocess.run(["sh", "-c", command], cwd=str(root), + capture_output=True, check=False) + assert typed.returncode == 0, (command, typed.stderr) -def typed_as_printed(root: Path, said: str) -> list[str]: +def typed_as_printed(root: Path, said: str, by_hand=None) -> 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) + nothing is checked out at the row's path, and a guarded `mv` (#761) + where its source is there, into a POSIX shell, which keeps a checkout and + the row's own directory in place. `by_hand`, where given, is what a + person does by hand where the text says to (#761), run after the last + such part. A local member is cloned with `protocol.file.allow`, the + suite's one concession to bare repositories as origins. Returns what was + typed.""" typed: list[str] = [] count = {"git": 0, "mv": 0} - for part in said.split(" on every disk. ", 1)[1].split("; "): + parts = said.split(" on every disk. ", 1)[1].split("; ") + last_by_hand = max((index for index, part in enumerate(parts) + if "by hand" in part), + default=None) + for index, part in enumerate(parts): for command in re.findall(r"`([^`]*)`", part): argv = shlex.split(command) if command == "git commit": @@ -175,19 +247,21 @@ def typed_as_printed(root: Path, said: str) -> list[str]: elif "" in command: tool = argv[0] count[tool] += 1 - argv[-1] = (f"renamed-{count['git']}" if tool == "git" - else f"../moved-{count['mv']}") + argv = shlex.split(command.replace( + "", 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]): + elif argv[0] in ("mv", "["): + source = argv[1] if argv[0] == "[" else argv[2] + if not os.path.lexists(root / source): continue - os.rename(root / argv[2], root / argv[3]) - elif argv[0] == "git" and (" -- " in command - or argv[1] == "update-index"): + typed_in_sh(root, command) + elif argv[0] == "git" and (" -- " in command or argv[1] in ( + "update-index", "config")): git(*argv[1:], cwd=root) else: continue @@ -196,6 +270,8 @@ def typed_as_printed(root: Path, said: str) -> list[str]: part): git("commit", "-qm", "The remedy", cwd=root) typed.append("git commit") + if index == last_by_hand and by_hand is not None: + by_hand(root) return typed @@ -252,16 +328,28 @@ def removal_staged(root: Path, remotes: Path, commit: str) -> None: git("rm", "-q", "--cached", "--", VARIANT, cwd=root) +def clone_moved_out_by_hand(root: Path) -> None: + """What the text says of a clone of its own at the variant, done by + hand: the guarded `mv` moved nothing, since it holds a `.git` entry, and + the clone, whose `.git` is a directory, is moved out of the holder whole + (#761, as `validate-family.py` says it, #681).""" + assert (root / VARIANT / ".git").is_dir(), "premise: a clone of its own" + os.rename(root / VARIANT, root.parent / f"{MEMBER}-clone") + + #: `{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)}`. +#: what is typed after the move and its commit, what is done by hand)}`. LAYOUTS = { - "nothing on disk in that spelling": (nothing_there, False, IN_INDEX, []), + "nothing on disk in that spelling": (nothing_there, False, IN_INDEX, [], + None), "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]), + IN_INDEX, [MOVE_OUT, SUBMODULE], + clone_moved_out_by_hand), + "both directories": (both_directories, True, IN_INDEX, [MOVE_OUT], + clone_moved_out_by_hand), "the removal staged, not committed": (removal_staged, False, HEAD_ONLY, - []), + [], None), } @@ -274,8 +362,10 @@ def test_each_layout_moves_the_gitlink_back_as_printed(family, holder, 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] + the holder's gate passes, and the same `add` goes through. A clone of + its own at the variant is kept by the guarded `mv` and moved out by + hand, as the text says, before the update (#761).""" + lay_out, two_spellings, where, after, by_hand = 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") @@ -285,7 +375,7 @@ def test_each_layout_moves_the_gitlink_back_as_printed(family, holder, 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]) == [ + assert typed_as_printed(holder, said[1], by_hand) == [ REMOVE, record(commit), "git commit", *after] registered(holder, family["root"], commit) gate_passes(holder) @@ -296,12 +386,12 @@ 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.""" + nothing at the row's path. The same text; typed as printed, the empty + directory leaves the holder on a case-sensitive disk by the guarded + `mv` (on macOS and Windows it is the row's own, and the shell's `-ef` + keeps it), `git submodule update` checks the member out at its row's + path, 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" @@ -312,8 +402,7 @@ def test_a_fresh_clone_moves_the_gitlink_back_as_printed(family, holder, 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])] + REMOVE, record(commit), "git commit", MOVE_OUT, SUBMODULE] registered(clone, family["root"], commit) gate_passes(clone) added(offline, clone, family["remotes"]) @@ -346,7 +435,7 @@ def test_every_disk_gives_the_same_answer(family, holder, family_module, 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] + 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") @@ -435,13 +524,14 @@ def members_renamed_by_hand(root: Path, remotes: Path) -> tuple: 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.""" + checkout is there, a clone of its own: the directory is the move's to + name, never a second time as untracked, and it is moved out by hand + before the update (#761).""" 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], + [REMOVE, record(commit), "git commit", MOVE_OUT, SUBMODULE], commit) @@ -515,17 +605,18 @@ def unmerged(root: Path, remotes: Path) -> tuple: #: `{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)}`. +#: needs two directories differing only in case, what is done by hand)}`. 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), + "beside a file under Members/": (beside_a_file, False, None), + "after a pending change in HEAD": (after_a_pending_change, False, None), + "with nothing left to commit": (nothing_to_commit, False, None), + "members/ renamed by hand": (members_renamed_by_hand, True, None), + "HEAD alone, the checkout at the variant": (head_only_and_on_disk, True, + clone_moved_out_by_hand), "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), + False, None), + "kept off disk by a sparse checkout": (kept_off_disk, False, None), + "unmerged, with nothing in progress": (unmerged, False, None), } @@ -539,14 +630,14 @@ def test_with_the_other_remedies_each_runs_in_its_order(family, holder, 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] + make, two_spellings, by_hand = 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 + assert typed_as_printed(holder, said, by_hand) == typed registered(holder, family["root"], commit) gate_passes(holder) added(offline, holder, family["remotes"]) @@ -688,7 +779,8 @@ 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).""" + (#646's review, N3), so the file is set aside first, here out of the + index, since the disk holds the member's checkout there (#761).""" def make(root: Path) -> None: plant(root, "100644", path) plant(root, "160000", VARIANT) @@ -712,32 +804,34 @@ def unmerged_file_ours(root: Path) -> None: #: `{state: (how it is made after the row's gitlink leaves the index, what -#: `_members_spellings` gives the variant: where, target, remedy)}`. The +#: `_members_spellings` gives each path: 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")), + [(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")), + [(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")), + [(IN_INDEX, VARIANT, "git rm -r --cached")]), "a file at the variant in HEAD alone": ( - head_file, (HEAD_ONLY, VARIANT, "git commit")), + 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")), + beside_a_file_at(ROW), [(IN_INDEX, ROW, "git update-index"), + (IN_INDEX, ROW, "git rm -r --cached aside")]), "a file under the row's path": ( beside_a_file_at(f"{ROW}/x"), - (IN_INDEX, VARIANT, "git rm -r --cached")), + [(IN_INDEX, ROW, "git update-index"), + (IN_INDEX, f"{ROW}/x", "git rm -r --cached aside")]), "unmerged, with no gitlink at stage 2": ( - unmerged_theirs, (IN_INDEX, VARIANT, "git rm -r --cached")), + 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")), + 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")), + [(IN_INDEX, ROW, "git update-index")]), } @@ -745,12 +839,12 @@ def unmerged_file_ours(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 nothing at or under that path. A - second gitlink beside the row's, a file at the variant, a gitlink under - it, a file at or under the row's path, or an unmerged variant with no - gitlink at stage 2 (none, or a file there, whatever stages 1 and 3 - hold) keeps #574's remedy.""" - make, (where, target, remedy_said) = OWN[state] + row's path, where the index holds nothing at or under that path but + files (#761), which are set aside first. A second gitlink beside the + row's, a file at the variant, a gitlink under it, or an unmerged variant + with no gitlink at stage 2 (none, or a file there, whatever stages 1 + and 3 hold) keeps #574's remedy.""" + make, said = OWN[state] if state != "a second gitlink beside the row's": git("rm", "-q", "--cached", "--", ROW, cwd=holder) make(holder) @@ -758,7 +852,7 @@ def test_only_the_members_own_gitlink_is_moved_back(holder, family_module, 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)] + rows)] == said def test_the_rows_that_have_a_path_of_their_own(family_module): diff --git a/tests/test_family_add_move_back_sections.py b/tests/test_family_add_move_back_sections.py new file mode 100644 index 00000000..d9b65a77 --- /dev/null +++ b/tests/test_family_add_move_back_sections.py @@ -0,0 +1,680 @@ +# SPDX-License-Identifier: Apache-2.0 +"""`family.py add`'s move back of a member's gitlink held in the wrong case +(#646) and rename back of a `members/` renamed with `git mv` (#685), typed as +printed in each state git leaves them in (#761). + +1. (e) `git mv members/IRRS Members/IRRS` moves the section's `path` too. The + move back put the gitlink back and left the section naming the variant: + `git submodule update` said `No url found for submodule path + 'members/IRRS'`. Each such section is pointed back now (`git config -f + .gitmodules submodule..path `, `git add -- .gitmodules`), + in the move's commit where HEAD's section moved too. +2. (g) A file at or under the row's path beside the variant's gitlink kept + #574's remedy, which took the member's gitlink away. It is set aside + first now, with `git mv` where it is on disk, then the gitlink moved back. +3. (h) A file or link at `members` itself: each `git update-index` failed + `appears as both a file and as a directory`. It is set aside first too. +4. The plain `mv` of the variant's directory moved a live checkout out of + the holder, its `.git` file left pointing nowhere. The `mv` is guarded as + `validate-family.py` guards it (#681), the text says where the commits + are and to delete the checkout by hand, before the update, and a checkout + `add` finds on disk later is never given a plain `mv`. +5. rv685's three: a member whose gitlink's removal is staged beside a staged + `git mv members Members` is moved back with its section, not passed by; a + file under a third spelling keeps its own remedy beside the rename back; + and an entry the sparse rules leave out, its `skip-worktree` bit cleared, + is given `--sparse`. + +States that need two directories differing only in case, or `git mv` into +one, are made on a disk that does not fold case alone; what a folding disk +answers is asked in process (`disk_as`). +""" + +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + +import pytest + +from conftest import cloned_just_pushed, git, rmtree +from test_family import NAME, SPARE, family # noqa: F401 +from test_family_add_member_variant import ( + MOVE_OUT, OTHER_ROW, OTHER_VARIANT, REMOVE, REMOVE_SPARSE, ROW, + SUBMODULE, VARIANT, VARIANT_SHOWN, dry_run_in_process, guarded, + in_the_index, moved_back, point_back, record, refused_in_process, + registered, typed_as_printed, variant, +) +from test_family_add_refusals import ( # noqa: F401 + family_module, holder, offline, plant, +) +from test_family_add_spellings_remedies import ( + BOTH, folding_skip, renamed_back, renamed_with_git, two_steps, +) +from test_family_head_lost_gitmodules import assert_passes_in_every_mode +from test_family_members_case_variants import ( + DIR_VARIANT, NEEDS_GIT_2_34, added, disk_as, git_version, index_part, + refused_untouched, remedy_of, +) +from test_family_refuse_up_front import needs_check_rules + +#: Both sections pointed back. +SECTIONS = (ROW, OTHER_ROW) +#: The two gitlinks under `Members/` taken out in one line, with `-f` where +#: a staged one has its checkout on disk. +REMOVE_BOTH = (f"git rm -r --cached --ignore-unmatch -- {VARIANT} " + f"{OTHER_VARIANT}") +REMOVE_BOTH_FORCED = REMOVE_BOTH.replace("--cached", "--cached -f") +REMOVE_FORCED = REMOVE.replace("--cached", "--cached -f") +POINTED = [point_back(), point_back(OTHER_ROW), "git add -- .gitmodules"] + + +def mount_moved(root: Path, committed: bool = True) -> str: + """`git mv members/IRRS Members/IRRS`, which moves the checkout, the + gitlink and the section's `path`, and its commit where `committed`. + Returns the commit the gitlink records.""" + commit = git("rev-parse", f"HEAD:{ROW}", cwd=root).stdout.strip() + (root / "Members").mkdir(exist_ok=True) + git("mv", "members/IRRS", VARIANT, cwd=root) + if committed: + git("commit", "-qm", "git mv members/IRRS Members/IRRS", cwd=root) + return commit + + +def work_there(root: Path, name: str = VARIANT) -> str: + """A commit of its own made in the checkout at `name`, and a file not + committed, written as BYTES. Returns the commit.""" + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", + "--allow-empty", "-m", "Work there", cwd=root / name) + (root / name / "WIP.txt").write_bytes(b"not committed\n") + return git("rev-parse", "HEAD", cwd=root / name).stdout.strip() + + +def deleted_by_hand(*names: str): + """What the text says of each checkout at `names`, done by hand, before + the update: `git -C status` first names the file not committed + there (the update would point the store at the row's path, and it then + names none), then the checkout is deleted.""" + def by_hand(root: Path) -> None: + for name in names: + if not (root / name / ".git").is_file(): + continue + status = git("-C", name, "status", "--porcelain", cwd=root) + assert "?? WIP.txt" in status.stdout.splitlines(), status.stdout + rmtree(root / name) + return by_hand + + +def kept_in_the_store(root: Path, commit: str, row: str = ROW) -> None: + """`commit` is in the member's store, `.git/modules/`, the + checkout it was made in deleted.""" + store = root / ".git" / "modules" / row + kept = subprocess.run(["git", f"--git-dir={store}", "cat-file", "-e", + commit], capture_output=True, check=False) + assert kept.returncode == 0, kept.stderr + + +def gate_then_add(offline, root: Path, remotes: Path) -> None: + """The holder's gate in every mode, the same `add`, the gate again.""" + assert_passes_in_every_mode(root, 2) + added(offline, root, remotes) + assert_passes_in_every_mode(root, 3) + + +# --- 1. (e) a section git mv moved ------------------------------------------- + +@pytest.mark.parametrize("committed", [True, False], + ids=["committed", "staged"]) +def test_a_mount_moved_with_git_mv_is_moved_back_with_its_section( + family, holder, offline, tmp_path, committed): + """(e) `git mv members/IRRS Members/IRRS`, the member's commit and a + file not committed in its checkout there. Typed as printed: the gitlink + and the section are back at `members/IRRS` (HEAD's `.gitmodules` byte + for byte the holder's), the commit only where HEAD's section moved + (none after a staged move, where `git commit` would fail), the removal + with `-f` where the move is staged, which `git rm --cached` refused + beside that checkout's work, the guarded `mv` keeps the checkout, `git + -C Members/IRRS status` names the file not committed, the checkout is + deleted by hand, and `git submodule update --init` checks the member + out at its row's path with the commit still in its store. The gate + passes and the same `add` goes through.""" + folding_skip(tmp_path) + commit = mount_moved(holder, committed) + work = work_there(holder) + move, closing = moved_back({ROW: ([VARIANT], commit)}, commit=committed, + sections=(ROW,), forced=not committed) + said = refused_untouched(offline, holder, family["remotes"], "--dry-run") + assert said == (in_the_index(VARIANT_SHOWN), remedy_of(move, *closing)) + assert typed_as_printed(holder, said[1], deleted_by_hand(VARIANT)) == [ + REMOVE if committed else REMOVE_FORCED, record(commit), point_back(), + "git add -- .gitmodules", *(["git commit"] if committed else []), + MOVE_OUT, SUBMODULE] + registered(holder, family["root"], commit) + assert (holder / ROW / "project.yaml").is_file() + kept_in_the_store(holder, work) + gate_then_add(offline, holder, family["remotes"]) + + +def test_a_fresh_clone_of_it_is_moved_back_with_its_section( + family, holder, offline, tmp_path): + """(e), committed and pushed, and a fresh recursive clone: its section + gives `Members/IRRS`, so the member is checked out there. The same + text; typed as printed, with that checkout deleted by hand, the clone's + gate passes and the same `add` goes through.""" + folding_skip(tmp_path) + commit = mount_moved(holder) + remote = family["remotes"] / f"{NAME}.git" + branch = "w761/mount-moved" + 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 / ".git").is_file(), "premise: checked out there" + move, closing = moved_back({ROW: ([VARIANT], commit)}, sections=(ROW,)) + said = refused_untouched(offline, clone, family["remotes"], "--dry-run") + assert said == (in_the_index(VARIANT_SHOWN), remedy_of(move, *closing)) + assert typed_as_printed(clone, said[1], + lambda root: rmtree(root / VARIANT)) == [ + REMOVE, record(commit), *POINTED[::2], "git commit", MOVE_OUT, + SUBMODULE] + registered(clone, family["root"], commit) + gate_then_add(offline, clone, family["remotes"]) + + +@pytest.mark.parametrize("folding", [False, True], ids=["linux", "folding"]) +def test_every_disk_points_the_section_back(family, holder, family_module, + offline, monkeypatch, capsys, + tmp_path, folding): + """(e) asked in process of a disk that folds case and of one that does + not: one text, the section line on both.""" + folding_skip(tmp_path) + commit = mount_moved(holder) + disk_as(monkeypatch, holder, folding) + move, closing = moved_back({ROW: ([VARIANT], commit)}, sections=(ROW,)) + assert dry_run_in_process(family_module, monkeypatch, offline, holder, + family["remotes"], capsys) == ( + in_the_index(VARIANT_SHOWN), remedy_of(move, *closing)) + + +def test_a_sparse_gitmodules_is_named_first_where_a_section_is_pointed_back( + family, holder, offline, tmp_path): + """(e), and a `.gitmodules` a sparse checkout marks: `git config -f` + would write a new file of the one line, which `git add` refuses, so + `gitmodules-sparse` names it first, with the holder as it was.""" + folding_skip(tmp_path) + mount_moved(holder) + git("update-index", "--skip-worktree", "--", ".gitmodules", cwd=holder) + first, _ = refused_untouched(offline, holder, family["remotes"]) + assert first.startswith("REFUSED gitmodules-sparse: "), first + + +# --- 2. (g) and 3. (h): what is in the way, set aside first ------------------ + +def set_aside(entries: list[str], kinds: dict, renamed=(), out=()) -> str: + """The part of the remedy that sets `entries` aside: `renamed` with `git + mv`, `out` taken out of the index.""" + how = [] + if renamed: + how.append("rename each that is on disk as the index holds it with " + "`git mv` (" + " and ".join(f"`git mv -- {name} `" + for name in renamed) + ")") + if out: + how.append("take each that is not on disk so out of the index, which " + "touches no file (`git rm -r --cached -- " + f"{' '.join(out)}`)") + named = ", ".join(f"`{name}` ({kinds.get(name, 'a file')})" + for name in entries) + return ("set aside first what the index holds in the way of the move of " + f"a member's gitlink below, {named}, since git's update-index " + "replaces a file at the path it records without a word and fails " + "beside one under it or at members itself, and taking the " + "gitlink out instead un-registers the member: " + + " and ".join(how)) + + +def file_committed(root: Path, name: str) -> bytes: + """`name` written as BYTES and committed. Returns the bytes.""" + data = b"our own notes\n" + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_bytes(data) + git("add", "--", name, cwd=root) + git("commit", "-qm", f"A file at {name}", cwd=root) + return data + + +@pytest.mark.parametrize("entry", [ROW, f"{ROW}/notes"], + ids=["at the row's path", "under it"]) +def test_a_file_at_or_under_the_rows_path_is_set_aside_first( + family, holder, offline, tmp_path, entry): + """(g) `git mv members/IRRS Members/IRRS`, then a file committed at the + row's path or under it. #574's `git mv -- Members/IRRS ` took + the member away from its row. Typed as printed: the file renamed with + `git mv`, then the move back and the section in the same commit, the + checkout deleted by hand, the update; the file's bytes are at its new + name in HEAD, the member is where its row says, the gate passes and the + same `add` goes through.""" + folding_skip(tmp_path) + commit = mount_moved(holder) + data = file_committed(holder, entry) + move, closing = moved_back({ROW: ([VARIANT], commit)}, sections=(ROW,)) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (in_the_index(VARIANT_SHOWN), + remedy_of(set_aside([entry], {}, renamed=[entry]), move, + *closing)) + assert typed_as_printed(holder, said[1], + lambda root: rmtree(root / VARIANT)) == [ + f"git mv -- {entry} renamed-1", REMOVE, record(commit), *POINTED[::2], + "git commit", MOVE_OUT, SUBMODULE] + assert git("show", "HEAD:renamed-1", cwd=holder).stdout.encode() == data + registered(holder, family["root"], commit) + gate_then_add(offline, holder, family["remotes"]) + + +def test_a_file_where_the_disk_holds_the_checkout_is_taken_out_first( + family, holder, offline): + """(g) #646's state, the member's checkout still at `members/IRRS`, and + a file the index holds there, which `git mv` cannot take: it is taken + out of the index, which touches no file, then the gitlink is moved + back over the checkout.""" + commit = variant(holder) + plant(holder, "100644", ROW) + move, closing = moved_back({ROW: ([VARIANT], commit)}) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (in_the_index(VARIANT_SHOWN), + remedy_of(set_aside([ROW], {}, out=[ROW]), move, + *closing)) + assert typed_as_printed(holder, said[1]) == [ + f"git rm -r --cached -- {ROW}", REMOVE, record(commit), "git commit"] + registered(holder, family["root"], commit) + gate_then_add(offline, holder, family["remotes"]) + + +def members_file_deleted(root: Path) -> tuple: + """A file `members` committed, then deleted from disk and not staged: + taken out of the index.""" + file_committed(root, "members") + (root / "members").unlink() + return set_aside(["members"], {}, out=["members"]), \ + "git rm -r --cached -- members" + + +def members_a_link(root: Path) -> tuple: + """A link `members -> Members` committed: renamed with `git mv`.""" + if os.name == "nt": + pytest.skip("a symlink needs a privilege Windows runners do not grant") + os.symlink("Members", root / "members", target_is_directory=True) + git("add", "--", "members", cwd=root) + git("commit", "-qm", "A link members -> Members", cwd=root) + return (set_aside(["members"], {"members": "a symlink"}, + renamed=["members"]), "git mv -- members renamed-1") + + +@pytest.mark.parametrize("make", [members_file_deleted, members_a_link], + ids=["a file deleted from disk", "a link"]) +def test_an_entry_at_members_itself_is_set_aside_first(family, holder, + offline, tmp_path, + make): + """(h) `git mv members Members`, committed, and the index holds a file + or a link at `members`: each `git update-index` failed `appears as both + a file and as a directory`, exit 128. Typed as printed: the entry set + aside, both gitlinks and sections moved back in one commit, both + checkouts deleted by hand, the update; the gate passes and the same + `add` goes through.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder, True) + aside, typed_first = make(holder) + move, closing = moved_back({ROW: ([VARIANT], commits[ROW]), + OTHER_ROW: ([OTHER_VARIANT], + commits[OTHER_ROW])}, + sections=SECTIONS) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (BOTH, remedy_of(aside, move, *closing)) + assert typed_as_printed(holder, said[1], lambda root: [ + rmtree(root / name) for name in (VARIANT, OTHER_VARIANT)]) == [ + typed_first, REMOVE_BOTH, record(commits[ROW]), + record(commits[OTHER_ROW], OTHER_ROW), *POINTED, "git commit", + MOVE_OUT, guarded(OTHER_VARIANT, OTHER_ROW), SUBMODULE] + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + gate_then_add(offline, holder, family["remotes"]) + + +# --- 4. a live checkout is never moved by a plain mv ------------------------ + +def test_a_checkout_left_at_the_variant_is_named_to_delete_by_hand( + family, holder, offline, tmp_path): + """(e) typed as printed with the hand step skipped: the update then + points the member's store at its row's path, and `git -C Members/IRRS + status` names nothing, though a file not committed is there (why the + text puts the hand step first). The next `add` names the checkout to + delete by hand, with no `mv`; deleted, the commit is still in the store, + and the gate and `add` pass.""" + folding_skip(tmp_path) + commit = mount_moved(holder) + work = work_there(holder) + said = refused_untouched(offline, holder, family["remotes"]) + typed_as_printed(holder, said[1]) + assert (holder / VARIANT / "WIP.txt").is_file() + assert git("-C", VARIANT, "status", "--porcelain", + cwd=holder).stdout == "" + first, again = refused_untouched(offline, holder, family["remotes"]) + assert first == DIR_VARIANT + (f"on disk and untracked: `{VARIANT}` (a " + "directory)") + assert again == remedy_of( + f"delete by hand each checkout on disk and untracked, `{VARIANT}`, " + "once nothing only it holds is needed, rather than move it: its .git " + "file names its store by a path relative to it, which a move would " + "leave pointing nowhere, and its commits are kept in that store, so " + "only what is not committed is in it alone") + assert re.findall(r"`mv ", again) == [] + assert typed_as_printed(holder, again, + lambda root: rmtree(root / VARIANT)) == [] + registered(holder, family["root"], commit) + kept_in_the_store(holder, work) + gate_then_add(offline, holder, family["remotes"]) + + +# --- 5. rv685's three -------------------------------------------------------- + +@pytest.mark.parametrize("committed", [False, True], + ids=["staged", "committed"]) +def test_a_members_removal_staged_beside_the_rename_is_moved_back( + family, holder, offline, tmp_path, committed): + """rv685's candidate 1: `git mv members Members`, then `git rm --cached + Members/IRSS`. Staged, HEAD records `members/IRSS` and the index holds + it nowhere: nothing named it, the rename back left its section at + `Members/IRSS`, `add` committed that, and the gate refused. Now it is + named, put back at HEAD's commit beside IRRS's move, with both sections + and no commit, which nothing would be left to make. Committed, HEAD + holds `Members/IRSS`: the move back from HEAD, with both sections and + the commit. Typed as printed, both members are where their rows say, + the gate passes and the same `add` goes through.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder, committed) + git("rm", "-q", "--cached", "--", OTHER_VARIANT, cwd=holder) + rows = {ROW: ([VARIANT], commits[ROW])} + staged = {} + if committed: + rows[OTHER_ROW] = ([OTHER_VARIANT], commits[OTHER_ROW]) + where = "in HEAD and no longer in the index" + else: + staged = {OTHER_ROW: (OTHER_VARIANT, commits[OTHER_ROW])} + where = "in .gitmodules, its gitlink's removal staged" + move, closing = moved_back(rows, commit=committed, sections=SECTIONS, + staged=staged, forced=not committed) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (in_the_index(VARIANT_SHOWN) + + f"; {where}: `{OTHER_VARIANT}` (a submodule)", + remedy_of(move, *closing)) + assert typed_as_printed(holder, said[1], lambda root: [ + rmtree(root / name) for name in (VARIANT, OTHER_VARIANT)]) == [ + REMOVE_BOTH if committed else REMOVE_BOTH_FORCED, + record(commits[ROW]), record(commits[OTHER_ROW], OTHER_ROW), + *POINTED, *(["git commit"] if committed else []), MOVE_OUT, + guarded(OTHER_VARIANT, OTHER_ROW), SUBMODULE] + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + gate_then_add(offline, holder, family["remotes"]) + + +def test_a_third_spelling_keeps_its_own_remedy_beside_the_rename_back( + family, holder, offline, tmp_path): + """rv685's NIT 5: `git mv members Members`, committed, and a file + committed at `MEMBERS/notes`. The rename back was withheld for the move + back, whose section gap (e) then left. Now the file keeps its own + remedy, run first, and the rename back follows. Typed as printed, both + members are where their rows say, the gate passes and the same `add` + goes through.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder, True) + file_committed(holder, "MEMBERS/notes") + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (DIR_VARIANT + "in the index: `MEMBERS/notes` (a file), " + f"{VARIANT_SHOWN}, `{OTHER_VARIANT}` (a submodule)", + remedy_of(index_part(rename=["MEMBERS/notes"], + commit="the rename"), + renamed_back(True))) + assert typed_as_printed(holder, said[1]) == [ + "git mv -- MEMBERS/notes renamed-1", "git commit", *two_steps(), + "git commit"] + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + gate_then_add(offline, holder, family["remotes"]) + + +@pytest.mark.parametrize("folding", [False, True], ids=["linux", "folding"]) +def test_every_disk_renames_back_beside_a_third_spelling( + family, holder, family_module, offline, monkeypatch, capsys, + tmp_path, folding): + """The same, the third spelling in the index alone, as a disk that folds + case holds it, where the three spellings are one directory and + `MEMBERS` is not listed: taken out of the index, which touches no file, + then the rename back, on both disks.""" + folding_skip(tmp_path) + renamed_with_git(holder, True) + plant(holder, "100644", "MEMBERS/notes") + disk_as(monkeypatch, holder, folding) + assert dry_run_in_process(family_module, monkeypatch, offline, holder, + family["remotes"], capsys) == ( + DIR_VARIANT + "in the index: `MEMBERS/notes` (a file), " + f"{VARIANT_SHOWN}, `{OTHER_VARIANT}` (a submodule)", + remedy_of(index_part(unstage=["MEMBERS/notes"]), renamed_back(True))) + + +def sparse_cone(root: Path) -> None: + """A cone-mode sparse checkout keeping `members/` and every directory + the gate reads, which leaves out `Members/`.""" + if git_version() < (2, 38): + pytest.skip("`git mv --sparse` of one entry needs git 2.38 or later") + git("sparse-checkout", "init", "--cone", cwd=root) + git("sparse-checkout", "set", "members", "contracts", "scripts", + ".github", cwd=root) + + +def bit_cleared(root: Path, name: str, on_disk: bool, directory: bool) -> None: + """`name`'s `skip-worktree` bit cleared, and it put back on disk, a + directory or a file written as BYTES, where `on_disk`.""" + if on_disk and directory: + (root / name).mkdir(parents=True, exist_ok=True) + elif on_disk: + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_bytes(b"our own notes\n") + git("update-index", "--no-skip-worktree", "--", name, cwd=root) + assert git("ls-files", "-t", "--", name, cwd=root).stdout.startswith("H ") + + +def readme_on_disk(root: Path) -> tuple: + """`Members/README` on disk: plain `git mv` refuses it.""" + file_committed(root, "Members/README") + sparse_cone(root) + bit_cleared(root, "Members/README", True, False) + return (DIR_VARIANT + "in the index: `Members/README` (a file)", + remedy_of(index_part(sparse=["Members/README"], + commit="the rename")), + ["git mv --sparse -- Members/README renamed-1", "git commit"]) + + +def readme_off_disk(root: Path) -> tuple: + """`Members/README` off disk: plain `git rm --cached` refuses it.""" + file_committed(root, "Members/README") + sparse_cone(root) + bit_cleared(root, "Members/README", False, False) + return (DIR_VARIANT + "in the index: `Members/README` (a file)", + remedy_of("take what has nothing on disk in that spelling out of " + "the index, which touches no file (`git rm -r --cached " + "--sparse -- Members/README`, which needs git 2.34 or " + "later), and commit the removal"), + ["git rm -r --cached --sparse -- Members/README", "git commit"]) + + +def own_gitlink_on_disk(root: Path) -> tuple: + """#646's state, `Members/IRRS` an empty directory on disk: plain `git + rm --cached --ignore-unmatch` passes it over, exit 0, and the move left + two gitlinks.""" + commit = variant(root) + sparse_cone(root) + bit_cleared(root, VARIANT, True, True) + 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", MOVE_OUT]) + + +SPARSE = { + "a file on disk": readme_on_disk, + "a file off disk": readme_off_disk, + "a member's own gitlink on disk": own_gitlink_on_disk, +} + + +@needs_check_rules +@pytest.mark.parametrize("state", list(SPARSE)) +def test_an_entry_the_sparse_rules_leave_out_takes_sparse_whatever_its_bit( + family, holder, offline, state): + """rv685's candidate 2: a cone-mode sparse checkout leaves `Members/` + out, and an entry there has its `skip-worktree` bit cleared. Plain `git + mv` (on disk) and `git rm --cached` (off disk) refuse it, exit 1, `exist + outside of your sparse-checkout definition`, and the move back's removal + passes it over. Each is given `--sparse` and the git it needs; typed as + printed, the gate passes and the same `add` goes through.""" + first, said, typed = SPARSE[state](holder) + assert refused_untouched(offline, holder, family["remotes"]) == (first, + said) + assert typed_as_printed(holder, said) == typed + gate_then_add(offline, holder, family["remotes"]) + + +@needs_check_rules +def test_a_gitlink_the_sparse_rules_leave_out_is_renamed_with_sparse( + family, holder, offline): + """rv685's candidate 2, on a gitlink, as the issue asks (`Members/X`, on + disk as a directory, its bit cleared): plain `git mv` would take it, and + `git mv --sparse` takes it too, so the one rule holds; typed as printed, + the same `add` goes through.""" + commit = git("rev-parse", f"HEAD:{ROW}", cwd=holder).stdout.strip() + git("update-index", "--add", "--cacheinfo", f"160000,{commit},Members/X", + cwd=holder) + git("commit", "-qm", "A gitlink Members/X", cwd=holder) + sparse_cone(holder) + bit_cleared(holder, "Members/X", True, True) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (DIR_VARIANT + "in the index: `Members/X` (a submodule)", + remedy_of(index_part(sparse=["Members/X"], + commit="the rename"))) + assert typed_as_printed(holder, said[1]) == [ + "git mv --sparse -- Members/X renamed-1", "git commit"] + result = offline.add(holder, SPARE, remotes=family["remotes"]) + assert result.returncode == 0, result.stdout + result.stderr + + +@needs_check_rules +def test_the_rename_back_is_withheld_where_the_rules_leave_an_entry_out( + holder, family_module, tmp_path): + """The rename back's first `git mv` refuses a directory holding an entry + the sparse rules leave out, its bit cleared (exit 1, measured), so the + move back is printed, its removal with `--sparse`.""" + folding_skip(tmp_path) + renamed_with_git(holder, True) + sparse_cone(holder) + for name in (VARIANT, OTHER_VARIANT): + bit_cleared(holder, name, True, True) + _, said = refused_in_process(family_module, holder) + assert "git mv -- Members members.renaming" not in said + assert f"`git rm -r --cached --sparse --ignore-unmatch -- {VARIANT} " \ + f"{OTHER_VARIANT}`, {NEEDS_GIT_2_34}, " in said + + +# --- what each rule turns on ------------------------------------------------- + +def test_a_section_head_moved_is_a_change_to_commit(holder, family_module, + tmp_path): + """HEAD records the gitlink at its row's path and its section at + `Members/IRRS`, as #646's move back left it typed after a committed + `git mv` of the mount, and the index stages the gitlink at the variant + again: the move alone leaves nothing to commit, but pointing the + section back does, so the commit is printed.""" + folding_skip(tmp_path) + commit = mount_moved(holder) + git("rm", "-q", "--cached", "--", VARIANT, cwd=holder) + git("update-index", "--add", "--cacheinfo", f"160000,{commit},{ROW}", + cwd=holder) + git("commit", "-qm", "#646's move back, its section left", cwd=holder) + git("rm", "-q", "--cached", "--", ROW, cwd=holder) + git("update-index", "--add", "--cacheinfo", f"160000,{commit},{VARIANT}", + cwd=holder) + move, closing = moved_back({ROW: ([VARIANT], commit)}, sections=(ROW,), + forced=True) + assert refused_in_process(family_module, holder)[1] == remedy_of( + move, *closing) + + +def test_what_is_set_aside_is_committed_with_the_move(family, holder, + offline, tmp_path): + """(g) staged: `git mv members/IRRS Members/IRRS` and a new file at the + row's path, neither committed. The move back alone would leave nothing + to commit, but the file renamed aside would be left staged, so the + commit is printed; typed as printed, HEAD holds the file at its new + name, the gate passes and the same `add` goes through.""" + folding_skip(tmp_path) + commit = mount_moved(holder, committed=False) + (holder / ROW).write_bytes(b"our own notes\n") + git("add", "--", ROW, cwd=holder) + move, closing = moved_back({ROW: ([VARIANT], commit)}, sections=(ROW,), + forced=True) + said = refused_untouched(offline, holder, family["remotes"]) + assert said[1] == remedy_of(set_aside([ROW], {}, renamed=[ROW]), move, + *closing) + assert typed_as_printed(holder, said[1], + lambda root: rmtree(root / VARIANT)) == [ + f"git mv -- {ROW} renamed-1", REMOVE_FORCED, record(commit), + *POINTED[::2], "git commit", MOVE_OUT, SUBMODULE] + assert git("show", "HEAD:renamed-1", cwd=holder).stdout == ( + "our own notes\n") + registered(holder, family["root"], commit) + gate_then_add(offline, holder, family["remotes"]) + + +def test_both_removals_staged_with_no_gitmodules_on_disk(family, holder, + offline, tmp_path): + """A staged `git mv members Members`, both gitlinks then taken out of the + index, and `.gitmodules` deleted from the work tree: nothing the index + holds is a gitlink, yet both sections are pointed back, so the copy the + index holds is put back first, where `git config -f` would otherwise + write a new file of one line. Typed as printed, both members are where + their rows say, the gate passes and the same `add` goes through.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder) + git("rm", "-q", "--cached", "--", VARIANT, OTHER_VARIANT, cwd=holder) + (holder / ".gitmodules").unlink() + move, closing = moved_back({}, commit=False, sections=SECTIONS, staged={ + ROW: (VARIANT, commits[ROW]), + OTHER_ROW: (OTHER_VARIANT, commits[OTHER_ROW])}) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == ( + DIR_VARIANT + "in .gitmodules, its gitlink's removal staged: " + f"{VARIANT_SHOWN}, `{OTHER_VARIANT}` (a submodule)", + remedy_of("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`)", move, *closing)) + assert typed_as_printed(holder, said[1], lambda root: [ + rmtree(root / name) for name in (VARIANT, OTHER_VARIANT)]) == [ + "git checkout -- .gitmodules", REMOVE_BOTH, record(commits[ROW]), + record(commits[OTHER_ROW], OTHER_ROW), *POINTED, MOVE_OUT, + guarded(OTHER_VARIANT, OTHER_ROW), SUBMODULE] + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + gate_then_add(offline, holder, family["remotes"]) + + +def test_a_checkout_under_an_index_entry_is_the_indexs_to_name( + holder, family_module): + """A checkout on disk at `Members/X` (a `.git` file) under which the + index holds `Members/X/notes`: the index's remedy names that path, and + the checkout is not named a second time from the disk.""" + (holder / "Members" / "X").mkdir(parents=True) + (holder / "Members" / "X" / ".git").write_bytes( + b"gitdir: ../../.git/modules/x\n") + plant(holder, "100644", "Members/X/notes") + found = family_module._members_spellings( + holder, SPARE, family_module._members_on_disk(holder), frozenset()) + assert [(spelled.where, spelled.target) for spelled in found] == [ + ("in the index", "Members/X")] diff --git a/tests/test_family_add_spellings_remedies.py b/tests/test_family_add_spellings_remedies.py index fe5cb06c..156a2ec1 100644 --- a/tests/test_family_add_spellings_remedies.py +++ b/tests/test_family_add_spellings_remedies.py @@ -376,7 +376,8 @@ def test_a_folding_disk_that_received_it_renames_it_back( 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.""" + back is printed, as before, pointing each section, which the rename + moved to `Members/`, back at its row's path (#761).""" folding_skip(tmp_path) commits = renamed_with_git(holder) os.rename(holder / "Members", holder / "members") @@ -384,7 +385,7 @@ def test_a_folding_disk_that_received_it_renames_it_back( move, closing = moved_back({ROW: ([VARIANT], commits[ROW]), OTHER_ROW: ([OTHER_VARIANT], commits[OTHER_ROW])}, - commit=False) + commit=False, sections=(ROW, OTHER_ROW)) assert dry_run_in_process(family_module, monkeypatch, offline, holder, family["remotes"], capsys) == ( BOTH, remedy_of(renamed_back(False)) if folding @@ -478,8 +479,6 @@ def it_a_link_outside(root: Path) -> None: lambda root: plant(root, "100644", "members/notes")), "a file at members in the index": ( lambda root: plant(root, "100644", "members")), - "another spelling in the index too": ( - lambda root: plant(root, "100644", "MEMBERS/notes")), "a case variant of the mount path under it": ( lambda root: plant(root, "100644", f"Members/{SPARE}")), "an entry under it a sparse checkout keeps off disk": ( @@ -501,9 +500,10 @@ def test_only_members_renamed_whole_is_renamed_back(holder, family_module, tmp_path, state): """The rename back is printed only where it types clean and leaves nothing out: no `members/` on disk but that spelling's own directory, - nothing in the index at or under `members` itself or under a second - spelling, no case variant of the mount path in it, nothing under it - kept off disk or unmerged, no member's gitlink that HEAD alone holds + nothing in the index at or under `members` itself, no member's gitlink + under a second spelling (a third spelling's own entries keep their own + remedy, #761), 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`.""" diff --git a/tests/test_family_members_case_variants.py b/tests/test_family_members_case_variants.py index b66cf9fc..028726c7 100644 --- a/tests/test_family_members_case_variants.py +++ b/tests/test_family_members_case_variants.py @@ -109,7 +109,7 @@ def index_part(*, rename=(), sparse=(), unstage=(), commit="") -> str: said.append("rename what the index holds with `git mv`, entry by " f"entry ({each('git mv', rename)})") if sparse: - said.append("rename what the sparse checkout keeps off disk with " + said.append("rename what the sparse checkout leaves out with " f"`git mv --sparse`, {NEEDS_GIT_2_38}, entry by entry " f"({each('git mv --sparse', sparse)})") if unstage: @@ -457,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, "", False, (), False)] # --- the disk (#364) --------------------------------------------------------- From 6cf02edf6df1fc145c27b9e9e43d2c207d987fa9 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 17:04:52 +0000 Subject: [PATCH 09/10] family.py add: a sparse checkout no longer decides whose a gitlink in the wrong case is, what stands in the way of its move back is set aside with the `--sparse` git asks for, and the closing `mv` moves nothing and exits 0 where nothing is at the variant, so following the remedy never un-registers the member or stops before the update (#761) Codex's two P2 findings on #828 at d4e8c65a (review 5558731894): - P2 1 (`scripts/family.py:3409`): a row whose files a sparse checkout marks (`skip-worktree`) or whose rules leave out was dropped from the rows to set aside before `owners` was built, so a member's own gitlink at a variant kept #574's remedy. Typed at d4e8c65a, `git mv -- Members/IRRS renamed-1` and the commit took the gitlink and its section to `renamed-1`, and HEAD held no gitlink at `members/IRRS`. A file at `members` itself that a sparse checkout keeps off disk was not set aside either, and `git update-index` then failed (`'members/IRRS' appears as both a file and as a directory`). Ownership no longer turns on the sparse checkout, and `_set_aside` names each entry as the index's own rule does: `git mv` on disk, `git mv --sparse` where the rules leave it out on disk or it is marked off disk, and `git rm -r --cached --sparse` where the rules leave it out off disk (`ASIDE_SPARSE`, and `sparse` on `ASIDE_OUT`), each measured with plain git 2.43 and 2.47.1. - P2 2 (`scripts/family.py:3931`): the guarded closing `mv` ran where nothing was at the variant and failed, exit 1 (`mv: cannot stat 'Members/IRRS'`), in four layouts typed at d4e8c65a, Codex's sparse `kept_off_disk` among them. The guard now begins `[ ! -e ] && [ ! -L ] ||`: nothing there moves nothing and exits 0, and a link there pointing nowhere is still moved, as before. The test helper `typed_as_printed` types every `mv` as printed, where it skipped one whose source was absent. - Found on the way: a file staged at a row's path over a staged `git mv` of the mount, then deleted from disk, was said to leave its removal to commit, since HEAD records the member's gitlink at that path, and the printed `git commit` failed with nothing to commit. HEAD's gitlink at a row the move puts back no longer counts. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 190 ++++++++++------ tests/test_family_add_member_variant.py | 58 ++--- tests/test_family_add_move_back_sections.py | 239 ++++++++++++++++++-- tests/test_family_add_spellings_remedies.py | 5 +- 4 files changed, 380 insertions(+), 112 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 56f7a117..522356f7 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -2751,23 +2751,28 @@ def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: #: 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), after what the index holds at or under -#: that path, or at `members` itself, is set aside (#761). What is on disk -#: and untracked is moved out of the way, but for a checkout, which a move -#: would leave pointing nowhere, and which is deleted by hand (#761); a -#: `members/` a hand renamed is moved back. +#: that path, or at `members` itself, is set aside (#761), with `git mv`, +#: `git mv --sparse` or `git rm -r --cached` as the same rule names what the +#: index holds (Codex's P2 1 on #828). What is on disk and untracked is +#: moved out of the way, but for a checkout, which a move would leave +#: pointing nowhere, and which is deleted by hand (#761); 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" ASIDE = "git mv aside" +ASIDE_SPARSE = "git mv --sparse aside" ASIDE_OUT = "git rm -r --cached aside" RESTORE = "git update-index" MOVE = "mv" CHECKOUT = "by hand" MOVE_BACK = "mv back" REMEDY_ORDER = (COMMIT, RENAME, RENAME_SPARSE, UNSTAGE, RENAME_BACK, ASIDE, - ASIDE_OUT, RESTORE, MOVE, CHECKOUT, MOVE_BACK) + ASIDE_SPARSE, ASIDE_OUT, RESTORE, MOVE, CHECKOUT, MOVE_BACK) +#: Each remedy that sets aside what is in the way of a move back (#761). +SET_ASIDE = (ASIDE, ASIDE_SPARSE, ASIDE_OUT) #: 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 @@ -2826,22 +2831,25 @@ class Spelled(NamedTuple): or its rules leave it out, either of 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 but `UNSTAGE`. `sections` names each section of `.gitmodules` - that gives the member another spelling of its row's path, as a `git mv` - of its mount leaves it, which the move points back at that path (#761); - empty for every other remedy. One found `IN_GITMODULES` is a member HEAD - records at its row's path and the index holds nowhere, named by the - path its section gives, and moved back at HEAD's commit. `forced` says - the removal takes `-f`: the index stages the variant at a commit HEAD - does not record there, and a checkout is on disk at it, which `git rm - --cached` refuses wherever that checkout has moved on or holds a file - it does not track (#761). - - WHAT IS SET ASIDE BEFORE THE MOVE BACK (`ASIDE`, `ASIDE_OUT`, #761) is - an entry the index holds at or under a row's path, or at `members` - itself, which `git update-index` would replace or fail on: renamed - with `git mv` where it is on disk as the index holds it, else taken out - of the index; `target` is the entry itself. + remedy but `UNSTAGE` and `ASIDE_OUT`. `sections` names each section of + `.gitmodules` that gives the member another spelling of its row's path, + as a `git mv` of its mount leaves it, which the move points back at that + path (#761); empty for every other remedy. One found `IN_GITMODULES` is + a member HEAD records at its row's path and the index holds nowhere, + named by the path its section gives, and moved back at HEAD's commit. + `forced` says the removal takes `-f`: the index stages the variant at a + commit HEAD does not record there, and a checkout is on disk at it, + which `git rm --cached` refuses wherever that checkout has moved on or + holds a file it does not track (#761). + + WHAT IS SET ASIDE BEFORE THE MOVE BACK (`SET_ASIDE`, #761) is an entry + the index holds at or under a row's path, or at `members` itself, which + `git update-index` would replace or fail on: renamed with `git mv` + (`ASIDE`) where it is on disk as the index holds it, with `git mv + --sparse` (`ASIDE_SPARSE`) where a sparse checkout needs that, else + taken out of the index (`ASIDE_OUT`), `sparse` saying that its rules + leave it out, so that `git rm --cached` takes it only with `--sparse` + (Codex's P2 1 on #828); `target` is the entry itself. WHAT A `members/` RENAMED WITH `git mv` HOLDS (`RENAME_BACK`, #685) has that spelling as `target`, `committed` says whether renaming it back @@ -3299,15 +3307,19 @@ def _members_spellings(root: Path, project: str, or at `members` itself (`appears as both a file and as a directory`, exit 128, after the removal took the variant out), and #574's remedy takes the member's gitlink out instead. So where the index holds only - files and links at or under a row's path, each at stage 0 and neither - marked nor left out by a sparse checkout, a gitlink at a variant is - still the member's own, and each of those entries is set aside first - (`ASIDE`, `ASIDE_OUT`), as is a file or link the index holds at - `members` itself wherever a gitlink is moved back: renamed with `git mv` - where it is on disk as the index holds it, as `_members_is_a_file` moves - `members` itself, else taken out of the index. The other order fails: - measured, the move back first replaced the file at the row's path, and - the `git mv` of that path then took the member's gitlink away. + files and links at or under a row's path, each at stage 0, a gitlink at + a variant is still the member's own, and each of those entries is set + aside first (`SET_ASIDE`, `_set_aside`), as is a file or link the index + holds at `members` itself wherever a gitlink is moved back: renamed with + `git mv` where it is on disk as the index holds it, as + `_members_is_a_file` moves `members` itself, else taken out of the + index, each with `--sparse` where a sparse checkout needs it. WHOSE + GITLINK IT IS NEVER TURNS ON THE SPARSE CHECKOUT (Codex's P2 1 on #828): + a row whose file it marked or left out was dropped here, so the gitlink + kept #574's remedy, which took it away from its row. The other order + fails: measured, the move back first replaced the file at the row's + path, and the `git mv` of that path then took the member's gitlink + away. ITS SECTION OF `.gitmodules` IS POINTED BACK TOO (#761, #670's (e)). A `git mv` of a member's mount (`git mv members/

Members/

`) moves @@ -3394,23 +3406,18 @@ def merged_file(name: str) -> bool: for name in indexed)} # Each row's path the index holds files and links alone at or under, # each set aside before the move back (#761), and a file or link at - # `members` itself, which no `members/

` can sit beside. - # Either, marked or left out by a sparse checkout, keeps the remedy it - # had: `git mv` and `git rm --cached` pass it over without `--sparse`. + # `members` itself, which no `members/

` can sit beside. Whatever a + # sparse checkout says of either, the gitlink is still the member's + # own: `_set_aside` gives it the `--sparse` git needs (Codex's P2 1 on + # #828). blocked: dict[str, list[str]] = {} for row in rows: held = [name for name in indexed if name == row or name.startswith(row + "/")] if held and all(merged_file(name) for name in held): blocked[row] = held - if blocked: - blocked = {row: held for row, held in blocked.items() - if not any(name in marked() or name in left_out() - for name in held)} at_members = (MEMBERS_DIR if MEMBERS_DIR in indexed - and merged_file(MEMBERS_DIR) - and MEMBERS_DIR not in marked() - and MEMBERS_DIR not in left_out() else None) + and merged_file(MEMBERS_DIR) else None) owners = {**strays, **{_folded(row): row for row in blocked}} found: list[Spelled] = [] restored: list[str] = [] @@ -3478,10 +3485,16 @@ def merged_file(name: str) -> bool: found.extend(staged) # What the disk holds where its section says is the move's to name too. restored.extend(gone) - if moved: - found.extend(_set_aside(root, indexed, in_head, [ - name for row in sorted(moved & blocked.keys()) - for name in blocked[row]] + ([at_members] if at_members else []))) + aside = ([name for row in sorted(moved & blocked.keys()) + for name in blocked[row]] + ([at_members] if at_members else []) + if moved else []) + if aside: + # HEAD's gitlink at a row's path, which the move puts back, is no + # change for the removal of what stands there to leave to commit. + kept = {name: oid for name, kind, oid in head + if kind != gitlink or name not in moved} + found.extend(_set_aside(root, indexed, kept, aside, marked(), + left_out())) renamed = _renamed_with_git(root, found, listed, canon, left_out() if any(spelled.remedy == RESTORE for spelled in found) else frozenset()) @@ -3596,20 +3609,45 @@ def _head_section_paths(root: Path) -> dict[str, str]: def _set_aside(root: Path, indexed: dict[str, int], in_head: dict[str, str], - names: list[str]) -> list[Spelled]: - """`ASIDE` or `ASIDE_OUT` for each of `names`, an entry the index holds - at or under a row's path that a gitlink is moved back to, or at - `members` itself (#761): renamed with `git mv` where it is on disk as - the index holds it, a file or link and no directory, which leaves a - change to commit; else taken out of the index, which touches no file, - and leaves one where HEAD holds it.""" + names: list[str], marked: frozenset[str], + left_out: frozenset[str]) -> list[Spelled]: + """One of `SET_ASIDE` for each of `names`, an entry the index holds at + or under a row's path that a gitlink is moved back to, or at `members` + itself (#761), by the rule `_members_spellings` names what the index + holds by; `in_head` is what HEAD holds that the move back does not put + back, `marked` what a sparse checkout marks (`skip-worktree`), and + `left_out` what its rules leave out. Renamed with `git mv` where it is + on disk as the index holds it, a file or link and no directory, or with + `git mv --sparse` where the rules leave it out; off disk, renamed with + `git mv --sparse` where it is marked, which keeps what it holds, and + else taken out of the index, which touches no file, with `--sparse` + where the rules leave it out. A rename leaves a change to commit, and a + removal one where `in_head` holds the entry. HEAD's gitlink at the row's + path is not in it: a file staged there over a staged `git mv` of the + mount and deleted from disk was said to leave its removal to commit, + and the commit printed failed with nothing to commit (found in #828's + fix round for Codex's P2s). + + EACH `--sparse` IS ONE GIT ASKS FOR (Codex's P2 1 on #828), measured on + git 2.43 and 2.47.1 for a file under a row's path. Marked and on disk, + plain `git mv` takes it. Marked and off disk, or left out by the rules + with its bit cleared and on disk, plain `git mv` and `git rm --cached` + refuse it (`exist outside of your sparse-checkout definition`, exit 1), + and `git mv --sparse` takes it. Left out and off disk, `git rm --cached` + refuses it the same way, `git mv` with or without `--sparse` says `bad + source` (exit 128), and `git rm --cached --sparse` takes it.""" aside = [] for name in names: on = _on_disk_as_named(root, name, False) + if on: + remedy = ASIDE_SPARSE if name in left_out else ASIDE + else: + remedy = ASIDE_SPARSE if name in marked else ASIDE_OUT aside.append(Spelled( _shown(name), INDEX_ENTRY_KINDS.get(indexed[name], "a file"), - IN_INDEX, _shown(name), False, ASIDE if on else ASIDE_OUT, - on or name in in_head)) + IN_INDEX, _shown(name), False, remedy, + remedy != ASIDE_OUT or name in in_head, + sparse=remedy == ASIDE_OUT and name in left_out)) return aside @@ -3681,7 +3719,9 @@ def _spellings_refusal(path: str, found: list[Spelled], or at `members` itself, which `git update-index` would replace or fail on, is set aside first, in no commit of its own: the move's commit takes both, so the history never holds the member's gitlink beside the - file, nor the file gone and the gitlink not back. + file, nor the file gone and the gitlink not back. Its `git mv --sparse` + and `git rm --cached --sparse` are said as the index's are (Codex's P2 + 1 on #828). THE WORK TREE'S `.gitmodules` (`gitmodules`, `_gitmodules_beside`'s answer, which `_checked_mount_path` passes only where the index holds a @@ -3713,11 +3753,16 @@ def _spellings_refusal(path: str, found: list[Spelled], ITS CLOSING SENTENCES, after every other remedy, so that a `members/` moved back has taken the checkout with it, are the validator's - (`_wrong_case_move`, #681), each `mv` guarded by two tests a POSIX shell - runs (#761): `[ -ef ]`, true where the disk takes - the two names for one directory, the row's own, and `[ -e - /.git ]`, true for a checkout, which a move leaves pointing - nowhere. So the text is the same on every disk, and the shell, not a + (`_wrong_case_move`, #681), each `mv` guarded by tests a POSIX shell + runs (#761): `[ ! -e ] && [ ! -L ]`, true where + nothing is there, where the `mv` alone failed, exit 1 (`cannot stat`), + and stopped what was typed after it, as where a sparse checkout keeps + the variant off disk (Codex's P2 2 on #828), while a link there pointing + nowhere, which `-e` takes for nothing, is still moved; `[ -ef + ]`, true where the disk takes the two names for one + directory, the row's own; and `[ -e /.git ]`, true for a + checkout, which a move leaves pointing nowhere. So the text is the same + on every disk, it exits 0 wherever it is typed, and the shell, not a sentence about the system, keeps the row's own directory in place. Then where a checkout's commits are kept, and that it is deleted by hand once nothing only it holds is needed; a clone of its own is moved out @@ -3736,7 +3781,7 @@ def _spellings_refusal(path: str, found: list[Spelled], # What is set aside is at a row's path or at `members`, no spelling # of either: its part names it. here = [spelled for spelled in found if spelled.where == where - and spelled.remedy not in (ASIDE, ASIDE_OUT)] + and spelled.remedy not in SET_ASIDE] if not here: continue shown = ", ".join(f"`{spelled.name}` ({spelled.kind})" @@ -3831,18 +3876,27 @@ def each(command: str, targets: list[str]) -> str: spelled.committed for spelled in held) else "")) # WHAT IS IN THE WAY OF THE MOVE BACK (#761), set aside first, in the # move's own commit. - aside = [spelled for spelled in found - if spelled.remedy in (ASIDE, ASIDE_OUT)] + aside = [spelled for spelled in found if spelled.remedy in SET_ASIDE] if aside: how = [] if by[ASIDE]: how.append("rename each that is on disk as the index holds it " f"with `git mv` ({each(RENAME, by[ASIDE])})") + # `--sparse` where a sparse checkout needs it, said as the index's + # are (Codex's P2 1 on #828). + if by[ASIDE_SPARSE]: + how.append("rename each that the sparse checkout leaves out with " + f"`git mv --sparse`, {SPARSE_MV_GIT} " + f"({each(RENAME_SPARSE, by[ASIDE_SPARSE])})") if by[ASIDE_OUT]: quoted = " ".join(shell_quoted(target, "posix") for target in by[ASIDE_OUT]) + out = any(spelled.sparse for spelled in aside + if spelled.remedy == ASIDE_OUT) how.append("take each that is not on disk so out of the index, " - f"which touches no file (`{UNSTAGE} -- {quoted}`)") + f"which touches no file (`{UNSTAGE}" + f"{' --sparse' if out else ''} -- {quoted}`" + + (f", {SPARSE_RM_GIT}" if out else "") + ")") named = ", ".join(f"`{spelled.name}` ({spelled.kind})" for spelled in aside) parts.append( @@ -3923,8 +3977,12 @@ def each(command: str, targets: list[str]) -> str: spelled.committed for spelled in aside) else "")) rows = {spelled.name: row for row, held in moved.items() for spelled in held} + # Nothing there, not even a link, moves nothing and exits 0 (Codex's + # P2 2 on #828): a plain `mv` of it failed and stopped the update. guarded = " and ".join( - f"`[ {shell_quoted(name, 'posix')} -ef " + f"`[ ! -e {shell_quoted(name, 'posix')} ] && " + f"[ ! -L {shell_quoted(name, 'posix')} ] || " + f"[ {shell_quoted(name, 'posix')} -ef " f"{shell_quoted(rows[name], 'posix')} ] || " f"[ -e {shell_quoted(name + '/.git', 'posix')} ] || mv -- " f"{shell_quoted(name, 'posix')} " @@ -3942,8 +4000,8 @@ def each(command: str, targets: list[str]) -> str: "where the disk holds the two apart it stays there, untracked, " "and add refuses it as family-members-dir-variant: move each one " f"that is there out of the holder, in a POSIX shell, {guarded}, " - "which moves nothing where the two names are one directory or " - "where it holds a .git entry, a checkout", + "which moves nothing where nothing is there, where the two names " + "are one directory or where it holds a .git entry, a checkout", "a checkout there that git submodule add or git mv made holds a " ".git file naming its store by a path relative to it, which a " "move would leave pointing nowhere: its commits are kept under " diff --git a/tests/test_family_add_member_variant.py b/tests/test_family_add_member_variant.py index 98698445..fc503761 100644 --- a/tests/test_family_add_member_variant.py +++ b/tests/test_family_add_member_variant.py @@ -20,7 +20,8 @@ update-index --add --cacheinfo 160000,,members/IRRS` at the commit the variant records, and a commit. Then, as the validator says, an `mv` out of the holder of what a case-sensitive disk leaves at the variant, -guarded by two tests a POSIX shell runs so that it moves nothing where that +guarded by tests a POSIX shell runs so that it moves nothing, and exits 0, +where nothing is there (Codex's P2 2 on #828), and moves nothing where that directory is the row's own (macOS and Windows) or a checkout, which is deleted, or for a clone of its own moved, by hand; and last `git submodule update --init --recursive` where nothing is checked out at the row's path @@ -74,11 +75,12 @@ def guarded(name: str = VARIANT, row: str = ROW) -> str: """The closing `mv` of a directory left at `name`, the variant of - `row`'s path, guarded by the two tests a POSIX shell runs (#761, as - `validate-family.py` guards it, #681): it moves nothing where the two - names are one directory, nor where it holds a `.git` entry.""" - return (f"[ {name} -ef {row} ] || [ -e {name}/.git ] || " - f"mv -- {name} ../{name.replace('/', '-')}") + `row`'s path, guarded by the tests a POSIX shell runs (#761, as + `validate-family.py` guards it, #681): it moves nothing where nothing is + there, not even a link pointing nowhere (Codex's P2 on #828), where the + two names are one directory, or where it holds a `.git` entry.""" + return (f"[ ! -e {name} ] && [ ! -L {name} ] || [ {name} -ef {row} ] || " + f"[ -e {name}/.git ] || mv -- {name} ../{name.replace('/', '-')}") MOVE_OUT = guarded() @@ -156,8 +158,8 @@ def moved_back(rows: dict, commit: bool = True, sparse: bool = False, "where the disk holds the two apart it stays there, untracked, and " "add refuses it as family-members-dir-variant: move each one that is " f"there out of the holder, in a POSIX shell, {moved}, which moves " - "nothing where the two names are one directory or where it holds a " - ".git entry, a checkout", + "nothing where nothing is there, where the two names are one " + "directory or where it holds a .git entry, a checkout", "a checkout there that git submodule add or git mv made holds a .git " "file naming its store by a path relative to it, which a move would " "leave pointing nowhere: its commits are kept under this holder's " @@ -218,11 +220,13 @@ def typed_in_sh(root: Path, command: str) -> None: def typed_as_printed(root: Path, said: str, by_hand=None) -> 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 a guarded `mv` (#761) - where its source is there, into a POSIX shell, which keeps a checkout and - the row's own directory in place. `by_hand`, where given, is what a + filled in `renamed-N` for a `git mv` and `../moved-N` for a `mv`. `git + submodule update` is typed where its sentence says so, and only there, + where nothing is checked out at the row's path. Every other `mv`, and + each guarded one (#761), is typed into a POSIX shell AS PRINTED, whether + or not its source is there (Codex's P2 on #828: a guard that skipped an + absent source here hid an `mv` that failed, exit 1, where the variant is + not on disk), and must exit 0. `by_hand`, where given, is what a person does by hand where the text says to (#761), run after the last such part. A local member is cloned with `protocol.file.allow`, the suite's one concession to bare repositories as origins. Returns what was @@ -256,9 +260,6 @@ def typed_as_printed(root: Path, said: str, by_hand=None) -> list[str]: os.rename(root / argv[2], root / argv[3]) command = shlex.join(argv) elif argv[0] in ("mv", "["): - source = argv[1] if argv[0] == "[" else argv[2] - if not os.path.lexists(root / source): - continue typed_in_sh(root, command) elif argv[0] == "git" and (" -- " in command or argv[1] in ( "update-index", "config")): @@ -341,15 +342,15 @@ def clone_moved_out_by_hand(root: Path) -> None: #: directories differing only in case, where the refusal finds the gitlink, #: what is typed after the move and its commit, what is done by hand)}`. LAYOUTS = { - "nothing on disk in that spelling": (nothing_there, False, IN_INDEX, [], - None), + "nothing on disk in that spelling": (nothing_there, False, IN_INDEX, + [MOVE_OUT], None), "the only checkout at the variant": (checkout_at_the_variant, True, IN_INDEX, [MOVE_OUT, SUBMODULE], clone_moved_out_by_hand), "both directories": (both_directories, True, IN_INDEX, [MOVE_OUT], clone_moved_out_by_hand), "the removal staged, not committed": (removal_staged, False, HEAD_ONLY, - [], None), + [MOVE_OUT], None), } @@ -477,7 +478,7 @@ def beside_a_file(root: Path, remotes: Path) -> tuple: remedy_of(index_part(unstage=["Members/README"]), move, *closing), ["git rm -r --cached -- Members/README", REMOVE, record(commit), - "git commit"], commit) + "git commit", MOVE_OUT], commit) def after_a_pending_change(root: Path, remotes: Path) -> tuple: @@ -492,7 +493,8 @@ def after_a_pending_change(root: Path, remotes: Path) -> tuple: 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) + ["git commit", REMOVE, record(commit), "git commit", MOVE_OUT], + commit) def nothing_to_commit(root: Path, remotes: Path) -> tuple: @@ -501,7 +503,7 @@ def nothing_to_commit(root: Path, remotes: Path) -> tuple: 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) + [REMOVE, record(commit), MOVE_OUT], commit) def members_renamed_by_hand(root: Path, remotes: Path) -> tuple: @@ -519,7 +521,7 @@ def members_renamed_by_hand(root: Path, remotes: Path) -> tuple: "`Members` (a directory)", remedy_of(move, MOVE_BACK, *closing), [REMOVE, record(commit), "git commit", "mv -- Members members", - SUBMODULE], commit) + MOVE_OUT, SUBMODULE], commit) def head_only_and_on_disk(root: Path, remotes: Path) -> tuple: @@ -548,7 +550,7 @@ def head_holding_both(root: Path, remotes: Path) -> tuple: 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) + [REMOVE, record(commit), "git commit", MOVE_OUT], commit) def kept_off_disk(root: Path, remotes: Path) -> tuple: @@ -563,7 +565,7 @@ def kept_off_disk(root: Path, remotes: Path) -> tuple: 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) + [REMOVE_SPARSE, record(commit), "git commit", MOVE_OUT], commit) def file_blob(root: Path) -> str: @@ -600,7 +602,7 @@ def unmerged(root: Path, remotes: Path) -> tuple: 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) + [REMOVE, record(commit), "git commit", MOVE_OUT], commit) #: `{state: (how it is made, from the holder and the remotes, returning @@ -657,7 +659,7 @@ def test_every_member_held_in_the_wrong_case_is_moved_back(family, holder, 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"] + "git commit", MOVE_OUT, guarded(OTHER_VARIANT, OTHER_ROW)] for row, (_, commit) in rows.items(): registered(holder, family["root"], commit, row) gate_passes(holder) @@ -758,7 +760,7 @@ def test_a_commit_the_row_does_not_pin_is_moved_not_bumped(family, holder, 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"] + "git commit", MOVE_OUT] registered(holder, family["root"], commit) gate = validate(holder) assert gate.returncode == 1, gate.stdout + gate.stderr diff --git a/tests/test_family_add_move_back_sections.py b/tests/test_family_add_move_back_sections.py index d9b65a77..60c131d1 100644 --- a/tests/test_family_add_move_back_sections.py +++ b/tests/test_family_add_move_back_sections.py @@ -24,6 +24,10 @@ file under a third spelling keeps its own remedy beside the rename back; and an entry the sparse rules leave out, its `skip-worktree` bit cleared, is given `--sparse`. +6. Codex's two P2s on #828: what is in the way of the move back is the + member's own to set aside whatever a sparse checkout says of it, with + `--sparse` where it needs one, and the closing `mv` moves nothing, and + exits 0, where nothing is at the variant. States that need two directories differing only in case, or `git mv` into one, are made on a disk that does not fold case alone; what a folding disk @@ -42,10 +46,10 @@ from conftest import cloned_just_pushed, git, rmtree from test_family import NAME, SPARE, family # noqa: F401 from test_family_add_member_variant import ( - MOVE_OUT, OTHER_ROW, OTHER_VARIANT, REMOVE, REMOVE_SPARSE, ROW, + HEAD_ONLY, MOVE_OUT, OTHER_ROW, OTHER_VARIANT, REMOVE, REMOVE_SPARSE, ROW, SUBMODULE, VARIANT, VARIANT_SHOWN, dry_run_in_process, guarded, in_the_index, moved_back, point_back, record, refused_in_process, - registered, typed_as_printed, variant, + registered, remedy, typed_as_printed, variant, ) from test_family_add_refusals import ( # noqa: F401 family_module, holder, offline, plant, @@ -55,8 +59,8 @@ ) from test_family_head_lost_gitmodules import assert_passes_in_every_mode from test_family_members_case_variants import ( - DIR_VARIANT, NEEDS_GIT_2_34, added, disk_as, git_version, index_part, - refused_untouched, remedy_of, + DIR_VARIANT, NEEDS_GIT_2_34, NEEDS_GIT_2_38, added, disk_as, disk_folds, + git_version, index_part, refused_untouched, remedy_of, ) from test_family_refuse_up_front import needs_check_rules @@ -210,18 +214,26 @@ def test_a_sparse_gitmodules_is_named_first_where_a_section_is_pointed_back( # --- 2. (g) and 3. (h): what is in the way, set aside first ------------------ -def set_aside(entries: list[str], kinds: dict, renamed=(), out=()) -> str: +def set_aside(entries: list[str], kinds: dict, renamed=(), out=(), + sparse=(), out_sparse: bool = False) -> str: """The part of the remedy that sets `entries` aside: `renamed` with `git - mv`, `out` taken out of the index.""" + mv`, `sparse` with `git mv --sparse`, `out` taken out of the index, with + `--sparse` where `out_sparse` (Codex's P2 on #828).""" how = [] if renamed: how.append("rename each that is on disk as the index holds it with " "`git mv` (" + " and ".join(f"`git mv -- {name} `" for name in renamed) + ")") + if sparse: + how.append("rename each that the sparse checkout leaves out with `git " + f"mv --sparse`, {NEEDS_GIT_2_38} (" + + " and ".join(f"`git mv --sparse -- {name} `" + for name in sparse) + ")") if out: how.append("take each that is not on disk so out of the index, which " - "touches no file (`git rm -r --cached -- " - f"{' '.join(out)}`)") + "touches no file (`git rm -r --cached" + f"{' --sparse' if out_sparse else ''} -- {' '.join(out)}`" + + (f", {NEEDS_GIT_2_34}" if out_sparse else "") + ")") named = ", ".join(f"`{name}` ({kinds.get(name, 'a file')})" for name in entries) return ("set aside first what the index holds in the way of the move of " @@ -284,7 +296,8 @@ def test_a_file_where_the_disk_holds_the_checkout_is_taken_out_first( remedy_of(set_aside([ROW], {}, out=[ROW]), move, *closing)) assert typed_as_printed(holder, said[1]) == [ - f"git rm -r --cached -- {ROW}", REMOVE, record(commit), "git commit"] + f"git rm -r --cached -- {ROW}", REMOVE, record(commit), "git commit", + MOVE_OUT] registered(holder, family["root"], commit) gate_then_add(offline, holder, family["remotes"]) @@ -309,17 +322,35 @@ def members_a_link(root: Path) -> tuple: renamed=["members"]), "git mv -- members renamed-1") -@pytest.mark.parametrize("make", [members_file_deleted, members_a_link], - ids=["a file deleted from disk", "a link"]) +def members_file_marked(root: Path) -> tuple: + """A file `members` committed, which a sparse checkout keeps off disk + (`skip-worktree`): it was left where it was, so each `git update-index` + still failed beside it. Renamed with `git mv --sparse`, which keeps what + it holds, as the index's own rule names such an entry (Codex's P2 1 on + #828).""" + if git_version() < (2, 38): + pytest.skip("`git mv --sparse` of one entry kept off disk needs git " + "2.38 or later") + file_committed(root, "members") + git("update-index", "--skip-worktree", "--", "members", cwd=root) + (root / "members").unlink() + return (set_aside(["members"], {}, sparse=["members"]), + "git mv --sparse -- members renamed-1") + + +@pytest.mark.parametrize("make", [members_file_deleted, members_a_link, + members_file_marked], + ids=["a file deleted from disk", "a link", + "a file a sparse checkout keeps off disk"]) def test_an_entry_at_members_itself_is_set_aside_first(family, holder, offline, tmp_path, make): """(h) `git mv members Members`, committed, and the index holds a file - or a link at `members`: each `git update-index` failed `appears as both - a file and as a directory`, exit 128. Typed as printed: the entry set - aside, both gitlinks and sections moved back in one commit, both - checkouts deleted by hand, the update; the gate passes and the same - `add` goes through.""" + or a link at `members`, one a sparse checkout keeps off disk too: each + `git update-index` failed `appears as both a file and as a directory`, + exit 128. Typed as printed: the entry set aside, both gitlinks and + sections moved back in one commit, both checkouts deleted by hand, the + update; the gate passes and the same `add` goes through.""" folding_skip(tmp_path) commits = renamed_with_git(holder, True) aside, typed_first = make(holder) @@ -633,6 +664,64 @@ def test_what_is_set_aside_is_committed_with_the_move(family, holder, gate_then_add(offline, holder, family["remotes"]) +def test_what_a_sparse_checkout_keeps_off_disk_is_committed_with_the_move( + family, holder, offline, tmp_path): + """The same, the new file marked `skip-worktree` and kept off disk, as a + sparse checkout keeps it: renamed aside with `git mv --sparse`, which + leaves a change to commit though nothing is on disk, so the commit is + printed (Codex's P2 1 on #828). Typed as printed, HEAD holds the file at + its new name, the gate passes and the same `add` goes through.""" + if git_version() < (2, 38): + pytest.skip("`git mv --sparse` of one entry kept off disk needs git " + "2.38 or later") + folding_skip(tmp_path) + commit = mount_moved(holder, committed=False) + (holder / ROW).write_bytes(b"our own notes\n") + git("add", "--", ROW, cwd=holder) + git("update-index", "--skip-worktree", "--", ROW, cwd=holder) + (holder / ROW).unlink() + move, closing = moved_back({ROW: ([VARIANT], commit)}, sections=(ROW,), + forced=True) + said = refused_untouched(offline, holder, family["remotes"]) + assert said[1] == remedy_of(set_aside([ROW], {}, sparse=[ROW]), move, + *closing) + assert typed_as_printed(holder, said[1], + lambda root: rmtree(root / VARIANT)) == [ + f"git mv --sparse -- {ROW} renamed-1", REMOVE_FORCED, record(commit), + *POINTED[::2], "git commit", MOVE_OUT, SUBMODULE] + assert git("show", "HEAD:renamed-1", cwd=holder).stdout == ( + "our own notes\n") + registered(holder, family["root"], commit) + gate_then_add(offline, holder, family["remotes"]) + + +def test_a_removal_where_head_records_the_gitlink_leaves_no_commit( + family, holder, offline, tmp_path): + """The same, the new file deleted from disk and not marked: taken out of + the index. HEAD records the member's gitlink at that path, which the + move puts back, so the removal leaves nothing to commit, and no commit + is printed: one was, and `git commit` failed with nothing to commit + (found in #828's fix round for Codex's P2s). Typed as printed, the + member is where its row says, the gate passes and the same `add` goes + through.""" + folding_skip(tmp_path) + commit = mount_moved(holder, committed=False) + (holder / ROW).write_bytes(b"our own notes\n") + git("add", "--", ROW, cwd=holder) + (holder / ROW).unlink() + move, closing = moved_back({ROW: ([VARIANT], commit)}, commit=False, + sections=(ROW,), forced=True) + said = refused_untouched(offline, holder, family["remotes"]) + assert said[1] == remedy_of(set_aside([ROW], {}, out=[ROW]), move, + *closing) + assert typed_as_printed(holder, said[1], + lambda root: rmtree(root / VARIANT)) == [ + f"git rm -r --cached -- {ROW}", REMOVE_FORCED, record(commit), + *POINTED[::2], MOVE_OUT, SUBMODULE] + registered(holder, family["root"], commit) + gate_then_add(offline, holder, family["remotes"]) + + def test_both_removals_staged_with_no_gitmodules_on_disk(family, holder, offline, tmp_path): """A staged `git mv members Members`, both gitlinks then taken out of the @@ -678,3 +767,121 @@ def test_a_checkout_under_an_index_entry_is_the_indexs_to_name( holder, SPARE, family_module._members_on_disk(holder), frozenset()) assert [(spelled.where, spelled.target) for spelled in found] == [ ("in the index", "Members/X")] + + +# --- 6. Codex's two P2s on #828 ---------------------------------------------- + +#: A file committed under the row's path, in the way of the move back. +NOTES = f"{ROW}/notes" + + +def notes_marked(on_disk: bool): + """`members/IRRS/notes` marked `skip-worktree`, as a sparse checkout + marks it, and kept off disk unless `on_disk`.""" + def make(root: Path) -> None: + if not on_disk and git_version() < (2, 38): + pytest.skip("`git mv --sparse` of one entry kept off disk needs " + "git 2.38 or later") + git("update-index", "--skip-worktree", "--", NOTES, cwd=root) + if not on_disk: + (root / NOTES).unlink() + return make + + +def notes_left_out(on_disk: bool): + """Non-cone sparse rules that leave out `members/IRRS/notes` alone, its + `skip-worktree` bit cleared, on disk where `on_disk`.""" + def make(root: Path) -> None: + git("sparse-checkout", "set", "--no-cone", "/*", f"!/{NOTES}", + cwd=root) + bit_cleared(root, NOTES, on_disk, False) + return make + + +#: `{state: (how it is made, the set-aside part's arguments, the line typed +#: for it)}`. +SPARSE_ASIDE = { + "marked, on disk": (notes_marked(True), {"renamed": [NOTES]}, + f"git mv -- {NOTES} renamed-1"), + "marked, off disk": (notes_marked(False), {"sparse": [NOTES]}, + f"git mv --sparse -- {NOTES} renamed-1"), + "left out by the rules, on disk": ( + notes_left_out(True), {"sparse": [NOTES]}, + f"git mv --sparse -- {NOTES} renamed-1"), + "left out by the rules, off disk": ( + notes_left_out(False), {"out": [NOTES], "out_sparse": True}, + f"git rm -r --cached --sparse -- {NOTES}"), +} + + +@pytest.mark.parametrize("state", [ + pytest.param(state, marks=needs_check_rules) if "rules" in state + else state for state in SPARSE_ASIDE]) +def test_a_blocker_a_sparse_checkout_marks_or_leaves_out_is_set_aside_too( + family, holder, offline, tmp_path, state): + """Codex's P2 1 on #828: `git mv members/IRRS Members/IRRS`, committed, + and a file committed under the row's path, which a sparse checkout + marks or its rules leave out. Such a row was dropped from the rows to + set aside before the member's own gitlink was asked for, so + `Members/IRRS` kept #574's `git mv -- Members/IRRS `, which + takes the member's gitlink and section away from its row. Now the + gitlink is moved back, and the file is set aside as the index's own + rule names it: plain `git mv` where it is marked and on disk, `git mv + --sparse` where it is marked and off disk or left out and on disk, and + `git rm -r --cached --sparse` where it is left out and off disk; plain + `git mv` and `git rm --cached` refuse each of the last three (measured + on git 2.43 and 2.47.1). Typed as printed, the file's bytes are at its + new name where it is renamed, the member is where its row says, the + gate passes and the same `add` goes through.""" + folding_skip(tmp_path) + make, how, typed_first = SPARSE_ASIDE[state] + commit = mount_moved(holder) + data = file_committed(holder, NOTES) + make(holder) + move, closing = moved_back({ROW: ([VARIANT], commit)}, sections=(ROW,)) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (in_the_index(VARIANT_SHOWN), + remedy_of(set_aside([NOTES], {}, **how), move, *closing)) + assert typed_as_printed(holder, said[1], + lambda root: rmtree(root / VARIANT)) == [ + typed_first, REMOVE, record(commit), *POINTED[::2], "git commit", + MOVE_OUT, SUBMODULE] + if typed_first.startswith("git mv"): + assert git("show", "HEAD:renamed-1", + cwd=holder).stdout.encode() == data + registered(holder, family["root"], commit) + gate_then_add(offline, holder, family["remotes"]) + + +def test_a_link_pointing_nowhere_at_the_variant_is_still_moved_out( + family, holder, offline, tmp_path): + """Codex's P2 2 on #828: the closing `mv` failed, exit 1, where nothing + is at the variant, so its guard now begins `[ ! -e ] && [ ! + -L ]`, and every typing of a state with nothing there types + it, as printed, moving nothing. The second test keeps what the guard did + before for a link there pointing nowhere, which `-e` takes for nothing: + #646's state with the removal staged, so HEAD alone holds the variant, + and a link `Members/IRRS -> nowhere`. (With the variant's gitlink still + in the index, git 2.45.1 and later refuse the link itself: `git status` + says `expected submodule path 'Members/IRRS' not to be a symbolic + link`, measured on git 2.47.1.) Typed as printed, the link is moved out + of the holder, the member is where its row says, the gate passes and + the same `add` goes through.""" + if os.name == "nt": + pytest.skip("a symlink needs a privilege Windows runners do not grant") + if disk_folds(tmp_path): + pytest.skip("a link at the variant beside the row's directory needs a " + "disk that does not fold case") + commit = variant(holder) + git("rm", "-q", "--cached", "--", VARIANT, cwd=holder) + (holder / "Members").mkdir() + os.symlink("nowhere", holder / VARIANT) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (DIR_VARIANT + f"{HEAD_ONLY}: {VARIANT_SHOWN}", + remedy(commit)) + assert typed_as_printed(holder, said[1]) == [ + REMOVE, record(commit), "git commit", MOVE_OUT] + assert not os.path.lexists(holder / VARIANT) + assert os.readlink(holder.parent / "Members-IRRS") == "nowhere" + registered(holder, family["root"], commit) + gate_then_add(offline, holder, family["remotes"]) diff --git a/tests/test_family_add_spellings_remedies.py b/tests/test_family_add_spellings_remedies.py index 156a2ec1..f30f7793 100644 --- a/tests/test_family_add_spellings_remedies.py +++ b/tests/test_family_add_spellings_remedies.py @@ -41,7 +41,7 @@ from conftest import cloned_just_pushed, git, rmtree from test_family import NAME, SPARE, family # noqa: F401 from test_family_add_member_variant import ( - OTHER_ROW, OTHER_VARIANT, REMOVE, ROW, VARIANT, VARIANT_SHOWN, + MOVE_OUT, 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, ) @@ -612,7 +612,8 @@ def test_a_missing_gitmodules_is_put_back_before_the_move_back( 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"] + "git checkout -- .gitmodules", REMOVE, record(commit), "git commit", + MOVE_OUT] registered(holder, family["root"], commit) assert_passes_in_every_mode(holder, 2) added(offline, holder, family["remotes"]) From ce0f2a5f4f691f60ed7909267b139ea6a88610af Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Sat, 17 Oct 2026 19:27:27 +0000 Subject: [PATCH 10/10] family.py add: a `members` file HEAD holds and a sparse checkout keeps off disk is taken out with `git rm --cached --sparse`, a clone or link found on disk keeps its `mv`, the closing `mv` says Git Bash, and the set-aside and move-back parts leave `_spellings_refusal`, so the merge with #684 types clean (#761) rv828 (lane 2) on 6cf02edf, after the merge of main bad05d21: - MUST 1: since #684 (fa04d2cd) `_checked_mount_path` asks `_members_file_left_in_head` before `_members_spellings`, so a `members` file HEAD and the index hold, off disk, is #684's `member-path-unaddable` and never reaches the set-aside. Where a sparse checkout marks that file or its rules leave it out, #684's printed `git rm --cached -- members` exited 1 (`exist outside of your sparse-checkout definition`), measured on the merge. It now takes `--sparse`, and says git 2.34, as `_set_aside` names such an entry off disk. The (h) tests for a file HEAD holds now type #684's removal and its commit, then the rename back; the set-aside keeps the cases that still reach it: a link, and a file staged new. - SHOULD 2: an untracked clone of its own at `Members/X`, and a link there to a checkout, each keep #574's `mv`, typed. - SHOULD 4: the set-aside part, the move back and its closing sentences move out of `_spellings_refusal` (`_set_aside_part`, `_move_back_parts`, `_move_back_line`, `_closing_sentences`), and the row bookkeeping, the sparse questions and the set-aside call out of `_members_spellings` (`_rows_held`, `_SparseAsked`, `_in_the_way`). The text is unchanged. - NIT 5: a file at `members` with no gitlink moved back, and a gitlink at `members`, are never set aside. - NIT 6: the three staged set-aside tests are one table. - NIT 7: the guarded `mv` is to be typed "in a POSIX shell (Git Bash on Windows)", as `family.py`'s other POSIX-only line says. Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 513 ++++++++++++-------- tests/test_family_add_member_variant.py | 3 +- tests/test_family_add_move_back_sections.py | 336 +++++++++---- 3 files changed, 557 insertions(+), 295 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 76f78320..4094ca0b 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -3914,46 +3914,9 @@ def _members_spellings(root: Path, project: str, head = _head_spellings(root) in_head = {name: oid for name, _, oid in head} gitlink = INDEX_ENTRY_KINDS[0o160000] - # What a sparse checkout marks (`skip-worktree`) and what its rules - # leave out (#761), each asked of git once, and only where it matters. - asked: dict[str, frozenset[str]] = {} - - def marked() -> frozenset[str]: - if "marked" not in asked: - asked["marked"] = frozenset(_skip_worktree(root)) - return asked["marked"] - - def left_out() -> frozenset[str]: - if "out" not in asked: - asked["out"] = frozenset(_sparse_rules_exclude(root, [ - name for name in indexed - if _folded(name.partition("/")[0]) == MEMBERS_DIR])) - return asked["out"] - - def merged_file(name: str) -> bool: - """A file or link the index holds at stage 0 alone.""" - return indexed[name] != 0o160000 and all( - fields[2] == b"0" for fields in listed[name]) - - # 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. - strays = {_folded(row): row for row in rows - if not any(name == row or name.startswith(row + "/") - for name in indexed)} - # Each row's path the index holds files and links alone at or under, - # each set aside before the move back (#761), and a file or link at - # `members` itself, which no `members/

` can sit beside. Whatever a - # sparse checkout says of either, the gitlink is still the member's - # own: `_set_aside` gives it the `--sparse` git needs (Codex's P2 1 on - # #828). - blocked: dict[str, list[str]] = {} - for row in rows: - held = [name for name in indexed - if name == row or name.startswith(row + "/")] - if held and all(merged_file(name) for name in held): - blocked[row] = held - at_members = (MEMBERS_DIR if MEMBERS_DIR in indexed - and merged_file(MEMBERS_DIR) else None) + sparse = _SparseAsked(root, indexed) + marked, left_out = sparse.marked, sparse.left_out + strays, blocked, at_members = _rows_held(rows, indexed, listed) owners = {**strays, **{_folded(row): row for row in blocked}} found: list[Spelled] = [] restored: list[str] = [] @@ -4021,16 +3984,8 @@ def merged_file(name: str) -> bool: found.extend(staged) # What the disk holds where its section says is the move's to name too. restored.extend(gone) - aside = ([name for row in sorted(moved & blocked.keys()) - for name in blocked[row]] + ([at_members] if at_members else []) - if moved else []) - if aside: - # HEAD's gitlink at a row's path, which the move puts back, is no - # change for the removal of what stands there to leave to commit. - kept = {name: oid for name, kind, oid in head - if kind != gitlink or name not in moved} - found.extend(_set_aside(root, indexed, kept, aside, marked(), - left_out())) + found.extend(_in_the_way(root, indexed, head, moved, + (blocked, at_members), sparse)) renamed = _renamed_with_git(root, found, listed, canon, left_out() if any(spelled.remedy == RESTORE for spelled in found) else frozenset()) @@ -4061,6 +4016,94 @@ def merged_file(name: str) -> bool: return found +class _SparseAsked: + """What a sparse checkout marks (`skip-worktree`), and what its rules + leave out of the index entries under a spelling of `members` (#761), + each asked of git once, and only where it matters.""" + + def __init__(self, root: Path, indexed: dict[str, int]) -> None: + self.root = root + self.names = [name for name in indexed + if _folded(name.partition("/")[0]) == MEMBERS_DIR] + self._marked: frozenset[str] | None = None + self._out: frozenset[str] | None = None + + def marked(self) -> frozenset[str]: + """The entries a sparse checkout marks (`_skip_worktree`).""" + if self._marked is None: + self._marked = frozenset(_skip_worktree(self.root)) + return self._marked + + def left_out(self) -> frozenset[str]: + """The entries its rules leave out (`_sparse_rules_exclude`).""" + if self._out is None: + self._out = frozenset(_sparse_rules_exclude(self.root, + self.names)) + return self._out + + +def _in_the_way(root: Path, indexed: dict[str, int], + head: list[tuple[str, str, str]], moved: set[str], + held: tuple[dict[str, list[str]], str | None], + sparse: _SparseAsked) -> list[Spelled]: + """What is in the way of the move back of the gitlinks to the rows in + `moved`, set aside first (`_set_aside`, #761): what the index holds at + or under each of those rows, and a file or link at `members`, as + `_rows_held` found them (`held`), wherever a gitlink is moved back, and + nothing where none is. `head` is `_head_spellings`'s. HEAD's gitlink at + a row's path, which the move puts back, is no change for the removal of + what stands there to leave to commit.""" + blocked, at_members = held + if not moved: + return [] + names = [name for row in sorted(moved & blocked.keys()) + for name in blocked[row]] + ([at_members] if at_members else []) + if not names: + return [] + gitlink = INDEX_ENTRY_KINDS[0o160000] + kept = {name: oid for name, kind, oid in head + if kind != gitlink or name not in moved} + return _set_aside(root, indexed, kept, names, sparse.marked(), + sparse.left_out()) + + +def _rows_held(rows: frozenset[str], indexed: dict[str, int], + listed: dict[str, list] + ) -> tuple[dict[str, str], dict[str, list[str]], str | None]: + """`(strays, blocked, at_members)` for `_members_spellings`, from the + index's entries by mode (`indexed`) and their records (`listed`): + + - each row's path the index holds nothing at or under, by its folded + spelling, where a gitlink at another spelling of it is that member's + own (#646); + - each row's path the index holds files and links alone at or under, + each at stage 0, with those entries, which are set aside before the + move back (#761); + - `members` where the index holds a file or link there at stage 0, + which no `members/

` can sit beside, else None. + + Whatever a sparse checkout says of an entry in the way, the gitlink is + still the member's own: `_set_aside` gives the entry the `--sparse` git + needs (Codex's P2 1 on #828).""" + def merged_file(name: str) -> bool: + """A file or link the index holds at stage 0 alone.""" + return indexed[name] != 0o160000 and all( + fields[2] == b"0" for fields in listed[name]) + + strays: dict[str, str] = {} + blocked: dict[str, list[str]] = {} + for row in rows: + held = [name for name in indexed + if name == row or name.startswith(row + "/")] + if not held: + strays[_folded(row)] = row + elif all(merged_file(name) for name in held): + blocked[row] = held + at_members = (MEMBERS_DIR if MEMBERS_DIR in indexed + and merged_file(MEMBERS_DIR) else None) + return strays, blocked, at_members + + def _sections_restored(root: Path, found: list[Spelled], moved: set[str], staged_out: list[str], in_head: dict[str, str], project: str) -> tuple[list[Spelled], list[str]]: @@ -4328,11 +4371,6 @@ def _spellings_refusal(path: str, found: list[Spelled], if spelled.remedy == remedy}) for remedy in REMEDY_ORDER} - def each(command: str, targets: list[str]) -> str: - return " and ".join( - f"`{command} -- {shell_quoted(target, 'posix')} `" - for target in targets) - parts = [] if gitmodules == GITMODULES_MODIFIED: parts.append( @@ -4355,11 +4393,11 @@ def each(command: str, targets: list[str]) -> str: index = [] if by[RENAME]: index.append("rename what the index holds with `git mv`, entry by " - f"entry ({each(RENAME, by[RENAME])})") + f"entry ({_each(RENAME, by[RENAME])})") if by[RENAME_SPARSE]: index.append("rename what the sparse checkout leaves out with `git " f"mv --sparse`, {SPARSE_MV_GIT}, entry by entry " - f"({each(RENAME_SPARSE, by[RENAME_SPARSE])})") + f"({_each(RENAME_SPARSE, by[RENAME_SPARSE])})") if by[UNSTAGE]: quoted = " ".join(shell_quoted(target, "posix") for target in by[UNSTAGE]) @@ -4411,154 +4449,17 @@ def each(command: str, targets: list[str]) -> str: + (", and commit the rename" if by[COMMIT] or any( spelled.committed for spelled in held) else "")) # WHAT IS IN THE WAY OF THE MOVE BACK (#761), set aside first, in the - # move's own commit. - aside = [spelled for spelled in found if spelled.remedy in SET_ASIDE] + # move's own commit; then the move back itself, and its closing + # sentences, which come last. + aside = _set_aside_part(found, by) if aside: - how = [] - if by[ASIDE]: - how.append("rename each that is on disk as the index holds it " - f"with `git mv` ({each(RENAME, by[ASIDE])})") - # `--sparse` where a sparse checkout needs it, said as the index's - # are (Codex's P2 1 on #828). - if by[ASIDE_SPARSE]: - how.append("rename each that the sparse checkout leaves out with " - f"`git mv --sparse`, {SPARSE_MV_GIT} " - f"({each(RENAME_SPARSE, by[ASIDE_SPARSE])})") - if by[ASIDE_OUT]: - quoted = " ".join(shell_quoted(target, "posix") - for target in by[ASIDE_OUT]) - out = any(spelled.sparse for spelled in aside - if spelled.remedy == ASIDE_OUT) - how.append("take each that is not on disk so out of the index, " - f"which touches no file (`{UNSTAGE}" - f"{' --sparse' if out else ''} -- {quoted}`" - + (f", {SPARSE_RM_GIT}" if out else "") + ")") - named = ", ".join(f"`{spelled.name}` ({spelled.kind})" - for spelled in aside) - parts.append( - "set aside first what the index holds in the way of the move of " - f"a member's gitlink below, {named}, since git's update-index " - "replaces a file at the path it records without a word and fails " - f"beside one under it or at {MEMBERS_DIR} itself, and taking the " - "gitlink out instead un-registers the member: " - + " and ".join(how)) - # 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] - if spelled.where != IN_GITMODULES)) - # A member whose removal is staged (#761), put back at HEAD's commit. - staged = " and ".join( - f"{row}, whose section gives `{spelled.name}`" - for row, held in moved.items() for spelled in held - if spelled.where == IN_GITMODULES) - recorded = " and ".join( - "`git update-index --add --cacheinfo " - f"{shell_quoted(f'160000,{held[0].recorded},{row}', 'posix')}`" - for row, held in moved.items()) - taken_out = " ".join(shell_quoted(name, "posix") for name in variants) - # `--sparse` only where a variant needs it, so that everywhere else - # the line is the validator's word for word (#646's review, S1), and - # `-f` likewise (#761): measured, `git rm --cached` refused a staged - # variant whose checkout held one commit, or one untracked file, of - # its own (`staged content different from both the file and the - # HEAD`, exit 1), and took it with `-f`, touching no file. - flag = " --sparse" if any(spelled.sparse for held in moved.values() - for spelled in held) else "" - force = any(spelled.forced for held in moved.values() - for spelled in held) - # Each section a `git mv` of a mount left naming the variant (#761). - pointed = sorted({(name, row) for row, held in moved.items() - for spelled in held for name in spelled.sections}) - sections = " and ".join( - f"`git config -f {GITMODULES} " - f"{shell_quoted(f'submodule.{name}.path', 'posix')} " - f"{shell_quoted(row, 'posix')}`" for name, row in pointed) - moves = [] - if pairs: - moves.append("move each member's gitlink held only under another " - f"spelling of its row's path, {pairs}, to that path, " - "at the commit it records") - if staged: - moves.append("put back at its row's path each member's gitlink " - "whose removal is staged though a section of " - f"{GITMODULES} gives it another spelling, {staged}, " - "at the commit HEAD records") - parts.append( - ", and ".join(moves) + ", in the index alone, which touches no " - "file" - + (f", and point each section of {GITMODULES} that gives one of " - "them another spelling of its row's path, as a `git mv` of " - "its mount leaves it, at that path" if pointed else "") - + f" (`git rm -r --cached{flag}{' -f' if force else ''} " - f"--ignore-unmatch -- {taken_out}`, " - + (f"{SPARSE_RM_GIT}, " if flag else "") - + ("whose -f takes out a staged gitlink whose checkout has moved " - "on or holds a file git does not track, which git rm " - "otherwise refuses, and with --cached touches no file, " - if force else "") + f"then {recorded}" - + (f", then {sections} and `git add -- {GITMODULES}`" - if pointed else "") + ")" - + (", and commit that" if by[COMMIT] or any( - spelled.committed for held in moved.values() - for spelled in held) or any( - spelled.committed for spelled in aside) else "")) - rows = {spelled.name: row for row, held in moved.items() - for spelled in held} - # Nothing there, not even a link, moves nothing and exits 0 (Codex's - # P2 2 on #828): a plain `mv` of it failed and stopped the update. - guarded = " and ".join( - f"`[ ! -e {shell_quoted(name, 'posix')} ] && " - f"[ ! -L {shell_quoted(name, 'posix')} ] || " - f"[ {shell_quoted(name, 'posix')} -ef " - f"{shell_quoted(rows[name], 'posix')} ] || " - f"[ -e {shell_quoted(name + '/.git', 'posix')} ] || mv -- " - f"{shell_quoted(name, 'posix')} " - f"{shell_quoted('../' + name.replace('/', '-'), 'posix')}`" - for name in variants) - status = " or ".join(f"`git -C {shell_quoted(name, 'posix')} status`" - for name in variants) - unpushed = " or ".join( - f"`git -C {shell_quoted(name, 'posix')} log --oneline HEAD " - "--branches --not --remotes`" for name in variants) - closing = [ - f"a directory left at {' or '.join(variants)} is the row's own " - "where the disk folds case, as macOS's and Windows' usually do " - "and a Linux mount can (WSL's /mnt/c)", - "where the disk holds the two apart it stays there, untracked, " - "and add refuses it as family-members-dir-variant: move each one " - f"that is there out of the holder, in a POSIX shell, {guarded}, " - "which moves nothing where nothing is there, where the two names " - "are one directory or where it holds a .git entry, a checkout", - "a checkout there that git submodule add or git mv made holds a " - ".git file naming its store by a path relative to it, which a " - "move would leave pointing nowhere: its commits are kept under " - "this holder's .git/modules/, in the directory its .git file " - "names, whatever becomes of the checkout, and only what is not " - f"committed is in the checkout alone ({status} shows it, except " - "where a plain mv brought the checkout to that path: git cannot " - "open it there, so check its files by hand), so delete such a " - "checkout by hand once nothing only it holds is needed, before " - "the update below, which points a store the row shares at the " - "row's path", - "a checkout whose .git is a directory, a clone of its own, holds " - f"its commits there and nowhere else, and {unpushed} lists the " - "ones not pushed: move it out of the holder whole, by hand, " - "rather than delete it", - f"then, where nothing is checked out at {' or '.join(moved)}, as " - "in a fresh clone, `git submodule update --init --recursive` " - "checks the member out there"] + parts.append(aside) + moving, closing = _move_back_parts(found, by) + if moving: + parts.append(moving) if by[MOVE]: parts.append("move what is on disk and untracked out of the way " - f"({each(MOVE, by[MOVE])})") + f"({_each(MOVE, by[MOVE])})") if by[CHECKOUT]: named = " and ".join(f"`{target}`" for target in by[CHECKOUT]) parts.append( @@ -4590,6 +4491,188 @@ def each(command: str, targets: list[str]) -> str: + said[:1].upper() + said[1:] + "; then re-run.") +def _each(command: str, targets: list[str]) -> str: + """One printed ` -- ` for each of `targets`, + each quoted for a POSIX shell (#532).""" + return " and ".join( + f"`{command} -- {shell_quoted(target, 'posix')} `" + for target in targets) + + +def _set_aside_part(found: list[Spelled], by: dict[str, list[str]]) -> str: + """`_spellings_refusal`'s part that sets aside what is in the way of the + move back (#761), or empty; `by` is its targets by remedy. `git mv`, + `git mv --sparse` and `git rm -r --cached`, each `--sparse` said as the + index's own are (Codex's P2 1 on #828).""" + aside = [spelled for spelled in found if spelled.remedy in SET_ASIDE] + if not aside: + return "" + how = [] + if by[ASIDE]: + how.append("rename each that is on disk as the index holds it with " + f"`git mv` ({_each(RENAME, by[ASIDE])})") + if by[ASIDE_SPARSE]: + how.append("rename each that the sparse checkout leaves out with `git " + f"mv --sparse`, {SPARSE_MV_GIT} " + f"({_each(RENAME_SPARSE, by[ASIDE_SPARSE])})") + if by[ASIDE_OUT]: + quoted = " ".join(shell_quoted(target, "posix") + for target in by[ASIDE_OUT]) + out = any(spelled.sparse for spelled in aside + if spelled.remedy == ASIDE_OUT) + how.append("take each that is not on disk so out of the index, which " + f"touches no file (`{UNSTAGE}{' --sparse' if out else ''} " + f"-- {quoted}`" + (f", {SPARSE_RM_GIT}" if out else "") + + ")") + named = ", ".join(f"`{spelled.name}` ({spelled.kind})" + for spelled in aside) + return ("set aside first what the index holds in the way of the move of a " + f"member's gitlink below, {named}, since git's update-index " + "replaces a file at the path it records without a word and fails " + f"beside one under it or at {MEMBERS_DIR} itself, and taking the " + "gitlink out instead un-registers the member: " + + " and ".join(how)) + + +def _move_back_parts(found: list[Spelled], by: dict[str, list[str]] + ) -> tuple[str, list[str]]: + """`(the part, the closing sentences)` of `_spellings_refusal` that move + each member's own gitlink back to its row's path (#646, #761), or empty + ones; `by` is its targets by remedy. Per row, every variant found, the + index's before HEAD's, the first of which names the commit; the move is + committed where it, a pending change or what is set aside leaves a + change to commit.""" + moved = {row: [spelled for spelled in found + if spelled.remedy == RESTORE and spelled.target == row] + for row in by[RESTORE]} + if not moved: + return "", [] + variants = sorted(spelled.name for held in moved.values() + for spelled in held) + committed = bool(by[COMMIT]) or any( + spelled.committed for spelled in found + if spelled.remedy == RESTORE or spelled.remedy in SET_ASIDE) + rows = {spelled.name: row for row, held in moved.items() + for spelled in held} + return (_move_back_line(moved, variants, committed), + _closing_sentences(variants, rows, list(moved))) + + +def _move_back_line(moved: dict[str, list[Spelled]], variants: list[str], + committed: bool) -> str: + """The move back's own part, `moved` the variants by row: the variants + taken out of the index, each row's gitlink recorded at the commit named, + each section a `git mv` of a mount left naming a variant pointed back, + and the commit where `committed`.""" + pairs = " and ".join( + f"`{name}` for the row {row}" for row in moved + for name in sorted(spelled.name for spelled in moved[row] + if spelled.where != IN_GITMODULES)) + # A member whose removal is staged (#761), put back at HEAD's commit. + staged = " and ".join( + f"{row}, whose section gives `{spelled.name}`" + for row, held in moved.items() for spelled in held + if spelled.where == IN_GITMODULES) + recorded = " and ".join( + "`git update-index --add --cacheinfo " + f"{shell_quoted(f'160000,{held[0].recorded},{row}', 'posix')}`" + for row, held in moved.items()) + taken_out = " ".join(shell_quoted(name, "posix") for name in variants) + # `--sparse` only where a variant needs it, so that everywhere else the + # line is the validator's word for word (#646's review, S1), and `-f` + # likewise (#761): measured, `git rm --cached` refused a staged variant + # whose checkout held one commit, or one untracked file, of its own + # (`staged content different from both the file and the HEAD`, exit 1), + # and took it with `-f`, touching no file. + every = [spelled for held in moved.values() for spelled in held] + flag = " --sparse" if any(spelled.sparse for spelled in every) else "" + force = any(spelled.forced for spelled in every) + # Each section a `git mv` of a mount left naming the variant (#761). + pointed = sorted({(name, row) for row, held in moved.items() + for spelled in held for name in spelled.sections}) + sections = " and ".join( + f"`git config -f {GITMODULES} " + f"{shell_quoted(f'submodule.{name}.path', 'posix')} " + f"{shell_quoted(row, 'posix')}`" for name, row in pointed) + moves = [] + if pairs: + moves.append("move each member's gitlink held only under another " + f"spelling of its row's path, {pairs}, to that path, at " + "the commit it records") + if staged: + moves.append("put back at its row's path each member's gitlink whose " + f"removal is staged though a section of {GITMODULES} " + f"gives it another spelling, {staged}, at the commit " + "HEAD records") + return ( + ", and ".join(moves) + ", in the index alone, which touches no file" + + (f", and point each section of {GITMODULES} that gives one of them " + "another spelling of its row's path, as a `git mv` of its mount " + "leaves it, at that path" if pointed else "") + + f" (`git rm -r --cached{flag}{' -f' if force else ''} " + f"--ignore-unmatch -- {taken_out}`, " + + (f"{SPARSE_RM_GIT}, " if flag else "") + + ("whose -f takes out a staged gitlink whose checkout has moved on " + "or holds a file git does not track, which git rm otherwise " + "refuses, and with --cached touches no file, " if force else "") + + f"then {recorded}" + + (f", then {sections} and `git add -- {GITMODULES}`" if pointed + else "") + ")" + + (", and commit that" if committed else "")) + + +def _closing_sentences(variants: list[str], rows: dict[str, str], + moved: list[str]) -> list[str]: + """The move back's closing sentences (#681, #761), after every other + remedy: what a directory left at a variant is, its guarded `mv`, where a + checkout's commits are kept and that it is deleted by hand, a clone of + its own moved out whole, and the update, last. `rows` is each variant's + row path, and `moved` the rows.""" + # Nothing there, not even a link, moves nothing and exits 0 (Codex's P2 2 + # on #828): a plain `mv` of it failed and stopped the update. + guarded = " and ".join( + f"`[ ! -e {shell_quoted(name, 'posix')} ] && " + f"[ ! -L {shell_quoted(name, 'posix')} ] || " + f"[ {shell_quoted(name, 'posix')} -ef " + f"{shell_quoted(rows[name], 'posix')} ] || " + f"[ -e {shell_quoted(name + '/.git', 'posix')} ] || mv -- " + f"{shell_quoted(name, 'posix')} " + f"{shell_quoted('../' + name.replace('/', '-'), 'posix')}`" + for name in variants) + status = " or ".join(f"`git -C {shell_quoted(name, 'posix')} status`" + for name in variants) + unpushed = " or ".join( + f"`git -C {shell_quoted(name, 'posix')} log --oneline HEAD " + "--branches --not --remotes`" for name in variants) + return [ + f"a directory left at {' or '.join(variants)} is the row's own where " + "the disk folds case, as macOS's and Windows' usually do and a Linux " + "mount can (WSL's /mnt/c)", + "where the disk holds the two apart it stays there, untracked, and " + "add refuses it as family-members-dir-variant: move each one that is " + "there out of the holder, in a POSIX shell (Git Bash on Windows), " + f"{guarded}, which moves nothing where nothing is there, where the " + "two names are one directory or where it holds a .git entry, a " + "checkout", + "a checkout there that git submodule add or git mv made holds a .git " + "file naming its store by a path relative to it, which a move would " + "leave pointing nowhere: its commits are kept under this holder's " + ".git/modules/, in the directory its .git file names, whatever " + "becomes of the checkout, and only what is not committed is in the " + f"checkout alone ({status} shows it, except where a plain mv brought " + "the checkout to that path: git cannot open it there, so check its " + "files by hand), so delete such a checkout by hand once nothing only " + "it holds is needed, before the update below, which points a store " + "the row shares at the row's path", + "a checkout whose .git is a directory, a clone of its own, holds its " + f"commits there and nowhere else, and {unpushed} lists the ones not " + "pushed: move it out of the holder whole, by hand, rather than delete " + "it", + f"then, where nothing is checked out at {' or '.join(moved)}, as in a " + "fresh clone, `git submodule update --init --recursive` checks the " + "member out there"] + + def _submodule_paths( root: Path) -> dict[str, tuple[str, str | None, tuple[str, ...]]]: """`{name: (path, url, keys)}` of every submodule the holder's @@ -4687,7 +4770,13 @@ def _members_file_left_in_head(root: Path, path: str) -> Refusal | None: 128, measured on git 2.43), nor put back first with `git checkout -- members`, which would put the file back over such a directory and take the directory away. A link on disk is left to git's own dry run, which - refuses a path beyond one.""" + refuses a path beyond one. + + ONE A SPARSE CHECKOUT MARKS OR LEAVES OUT (#761, Codex's P2 1 on #828) + is off disk the same way, and the removal takes `--sparse`, which says + the git it needs: plain `git rm --cached` refuses it (`exist outside of + your sparse-checkout definition`, exit 1, measured on git 2.43 and + 2.47.1), as `_set_aside` names such an entry off disk.""" held = [kind for name, kind in _index_entries(root, MEMBERS_DIR) if name == MEMBERS_DIR] if any(kind != "a file" for kind in held) or ( @@ -4701,19 +4790,25 @@ def _members_file_left_in_head(root: Path, path: str) -> Refusal | None: for name, kind, _ in head): return None if held: + sparse = MEMBERS_DIR in _skip_worktree(root) or bool( + _sparse_rules_exclude(root, [MEMBERS_DIR])) return Refusal( "member-path-unaddable", f"git would add the mount path {path} and take `{MEMBERS_DIR}` " f"out of the holder with it: `{MEMBERS_DIR}` is a file HEAD and " - "the index hold and the work tree no longer does, its deletion " - "not staged, and the add's mount, putting a directory where the " - "file was, would take it out of the index, and its commit would " - f"take that deletion into itself (`D {MEMBERS_DIR}`). No git " - "command that writes was run and nothing was written.", + "the index hold and the work tree no longer does, " + + ("as the sparse checkout leaves it out" if sparse else + "its deletion not staged") + + ", and the add's mount, putting a directory where the file " + "was, would take it out of the index, and its commit would take " + f"that deletion into itself (`D {MEMBERS_DIR}`). No git command " + "that writes was run and nothing was written.", f"Remediation: every member is mounted under {MEMBERS_DIR}/, " "which must be a plain directory of the holder's own. Take the " "file out of the index, which touches no file (`git rm --cached " - f"-- {MEMBERS_DIR}`), and commit the removal on its own first " + + (f"--sparse -- {MEMBERS_DIR}`, {SPARSE_RM_GIT}" if sparse else + f"-- {MEMBERS_DIR}`") + + "), and commit the removal on its own first " "(`git commit`, with nothing else staged: a bare `git commit` " "takes every staged change, and `git status` lists them), since " f"the add's own commit names {GITMODULES}, {path} and {MANIFEST} " diff --git a/tests/test_family_add_member_variant.py b/tests/test_family_add_member_variant.py index fc503761..c9eab9a9 100644 --- a/tests/test_family_add_member_variant.py +++ b/tests/test_family_add_member_variant.py @@ -157,7 +157,8 @@ def moved_back(rows: dict, commit: bool = True, sparse: bool = False, "mount can (WSL's /mnt/c)", "where the disk holds the two apart it stays there, untracked, and " "add refuses it as family-members-dir-variant: move each one that is " - f"there out of the holder, in a POSIX shell, {moved}, which moves " + "there out of the holder, in a POSIX shell (Git Bash on Windows), " + f"{moved}, which moves " "nothing where nothing is there, where the two names are one " "directory or where it holds a .git entry, a checkout", "a checkout there that git submodule add or git mv made holds a .git " diff --git a/tests/test_family_add_move_back_sections.py b/tests/test_family_add_move_back_sections.py index 60c131d1..ecbb906c 100644 --- a/tests/test_family_add_move_back_sections.py +++ b/tests/test_family_add_move_back_sections.py @@ -28,6 +28,10 @@ member's own to set aside whatever a sparse checkout says of it, with `--sparse` where it needs one, and the closing `mv` moves nothing, and exits 0, where nothing is at the variant. +7. rv828's review: a `members` file HEAD holds is #684's to name first, + its removal with `--sparse` where a sparse checkout needs it, and the + rename back follows; a clone of its own, or a link, found on disk keeps + its `mv`; and nothing but a file or link in a move's way is set aside. States that need two directories differing only in case, or `git mv` into one, are made on a disk that does not fold case alone; what a folding disk @@ -38,6 +42,7 @@ import os import re +import shlex import subprocess from pathlib import Path @@ -58,10 +63,12 @@ BOTH, folding_skip, renamed_back, renamed_with_git, two_steps, ) from test_family_head_lost_gitmodules import assert_passes_in_every_mode +from test_family_holder_states import DELETED_LINE, DELETED_REMEDY from test_family_members_case_variants import ( DIR_VARIANT, NEEDS_GIT_2_34, NEEDS_GIT_2_38, added, disk_as, disk_folds, git_version, index_part, refused_untouched, remedy_of, ) +from test_family_members_case_variants import move as untracked_moved from test_family_refuse_up_front import needs_check_rules #: Both sections pointed back. @@ -302,10 +309,16 @@ def test_a_file_where_the_disk_holds_the_checkout_is_taken_out_first( gate_then_add(offline, holder, family["remotes"]) -def members_file_deleted(root: Path) -> tuple: - """A file `members` committed, then deleted from disk and not staged: - taken out of the index.""" - file_committed(root, "members") +def members_staged(root: Path) -> None: + """A file `members` staged and never committed, so HEAD holds none and + #684's refusal (`_members_file_left_in_head`) passes it by.""" + (root / "members").write_bytes(b"our own notes\n") + git("add", "--", "members", cwd=root) + + +def members_staged_deleted(root: Path) -> tuple: + """That file deleted from disk: taken out of the index.""" + members_staged(root) (root / "members").unlink() return set_aside(["members"], {}, out=["members"]), \ "git rm -r --cached -- members" @@ -322,35 +335,37 @@ def members_a_link(root: Path) -> tuple: renamed=["members"]), "git mv -- members renamed-1") -def members_file_marked(root: Path) -> tuple: - """A file `members` committed, which a sparse checkout keeps off disk - (`skip-worktree`): it was left where it was, so each `git update-index` +def members_staged_marked(root: Path) -> tuple: + """That file marked `skip-worktree` and kept off disk, as a sparse + checkout keeps it: it was left where it was, so each `git update-index` still failed beside it. Renamed with `git mv --sparse`, which keeps what it holds, as the index's own rule names such an entry (Codex's P2 1 on #828).""" if git_version() < (2, 38): pytest.skip("`git mv --sparse` of one entry kept off disk needs git " "2.38 or later") - file_committed(root, "members") + members_staged(root) git("update-index", "--skip-worktree", "--", "members", cwd=root) (root / "members").unlink() return (set_aside(["members"], {}, sparse=["members"]), "git mv --sparse -- members renamed-1") -@pytest.mark.parametrize("make", [members_file_deleted, members_a_link, - members_file_marked], - ids=["a file deleted from disk", "a link", - "a file a sparse checkout keeps off disk"]) +@pytest.mark.parametrize("make", [members_staged_deleted, members_a_link, + members_staged_marked], + ids=["a file staged and deleted from disk", "a link", + "a file staged and kept off disk by a sparse " + "checkout"]) def test_an_entry_at_members_itself_is_set_aside_first(family, holder, offline, tmp_path, make): - """(h) `git mv members Members`, committed, and the index holds a file - or a link at `members`, one a sparse checkout keeps off disk too: each - `git update-index` failed `appears as both a file and as a directory`, - exit 128. Typed as printed: the entry set aside, both gitlinks and - sections moved back in one commit, both checkouts deleted by hand, the - update; the gate passes and the same `add` goes through.""" + """(h) `git mv members Members`, committed, and the index holds a link at + `members`, or a file HEAD does not hold, deleted from disk or kept off + it by a sparse checkout: each `git update-index` failed `appears as both + a file and as a directory`, exit 128. Typed as printed: the entry set + aside, both gitlinks and sections moved back in one commit, both + checkouts deleted by hand, the update; the gate passes and the same + `add` goes through. A file HEAD holds is #684's to name first, below.""" folding_skip(tmp_path) commits = renamed_with_git(holder, True) aside, typed_first = make(holder) @@ -370,6 +385,87 @@ def test_an_entry_at_members_itself_is_set_aside_first(family, holder, gate_then_add(offline, holder, family["remotes"]) +#: #684's refusal of a `members` file HEAD and the index hold, kept off disk +#: by a sparse checkout: its removal takes `--sparse` (Codex's P2 1 on #828). +SPARSE_LINE = DELETED_LINE.replace("its deletion not staged", + "as the sparse checkout leaves it out") +SPARSE_REMEDY = DELETED_REMEDY.replace( + "(`git rm --cached -- members`)", + f"(`git rm --cached --sparse -- members`, {NEEDS_GIT_2_34})") + + +def head_members_deleted(root: Path) -> tuple: + """Deleted from disk, the deletion not staged: #684's own state.""" + (root / "members").unlink() + return DELETED_LINE, DELETED_REMEDY + + +def head_members_marked(root: Path) -> tuple: + """Marked `skip-worktree` and kept off disk, as a sparse checkout keeps + it: plain `git rm --cached` refuses it, exit 1.""" + git("update-index", "--skip-worktree", "--", "members", cwd=root) + (root / "members").unlink() + return SPARSE_LINE, SPARSE_REMEDY + + +def head_members_left_out(root: Path) -> tuple: + """Left out by non-cone rules that leave out the file `members` alone + (`/members/` keeps the directory in), its bit cleared and off disk: + plain `git rm --cached` refuses it, exit 1.""" + git("sparse-checkout", "set", "--no-cone", "/*", "!/members", + "/members/", cwd=root) + bit_cleared(root, "members", False, False) + return SPARSE_LINE, SPARSE_REMEDY + + +def typed_684(root: Path, remedy: str) -> list[str]: + """#684's remedy, typed as printed: its `git rm --cached` line, then the + commit it asks for, on its own.""" + (line,) = [command for command in re.findall(r"`([^`]*)`", remedy) + if command.startswith("git rm ")] + git(*shlex.split(line)[1:], cwd=root) + git("commit", "-qm", "The removal, on its own", cwd=root) + return [line, "git commit"] + + +#: `{state: how the committed file `members` is left off disk}`. +HEAD_MEMBERS = { + "deleted from disk": head_members_deleted, + "kept off disk by a sparse checkout": head_members_marked, + "left out by the sparse rules": head_members_left_out, +} + + +@pytest.mark.parametrize("state", [ + pytest.param(state, marks=needs_check_rules) if "rules" in state + else state for state in HEAD_MEMBERS]) +def test_a_members_file_head_holds_is_taken_out_then_renamed_back( + family, holder, offline, tmp_path, state): + """(h) once main holds #684 (rv828's MUST 1): `git mv members Members`, + committed, then a file `members` committed and left off disk. #684's + `member-path-unaddable` names it before `_members_spellings` is asked, + so the set-aside is never reached. Its `git rm --cached -- members` + typed as printed refused the file a sparse checkout marks or leaves out + (`exist outside of your sparse-checkout definition`, exit 1), and now + takes `--sparse` there. Typed as printed, the removal and its commit, + then the rename back the next `add` prints: both members are where their + rows say, the gate passes and the same `add` goes through.""" + folding_skip(tmp_path) + commits = renamed_with_git(holder, True) + file_committed(holder, "members") + line, remedy = HEAD_MEMBERS[state](holder) + assert refused_untouched(offline, holder, family["remotes"]) == (line, + remedy) + assert typed_684(holder, remedy) == [ + re.search(r"`(git rm [^`]*)`", remedy).group(1), "git commit"] + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (BOTH, remedy_of(renamed_back(True))) + assert typed_as_printed(holder, said[1]) == [*two_steps(), "git commit"] + for row, commit in commits.items(): + registered(holder, family["root"], commit, row) + gate_then_add(offline, holder, family["remotes"]) + + # --- 4. a live checkout is never moved by a plain mv ------------------------ def test_a_checkout_left_at_the_variant_is_named_to_delete_by_hand( @@ -405,6 +501,52 @@ def test_a_checkout_left_at_the_variant_is_named_to_delete_by_hand( gate_then_add(offline, holder, family["remotes"]) +def a_clone_of_its_own(root: Path, remotes: Path) -> str: + """An untracked clone of a member at `Members/X`, its `.git` a + directory, which holds its commits: the `mv` moves it whole.""" + cloned_just_pushed(remotes / "IRRS.git", root / "Members" / "X", + recurse=False) + return "a directory" + + +def a_link_to_a_checkout(root: Path, remotes: Path) -> str: + """A link `Members/X -> ../members/IRRS`, the member's own checkout: a + link is moved, never followed to the checkout it names.""" + if os.name == "nt": + pytest.skip("a symlink needs a privilege Windows runners do not grant") + (root / "Members").mkdir() + os.symlink("../members/IRRS", root / "Members" / "X", + target_is_directory=True) + return "a symlink" + + +#: `{state: how it is laid out, returning what the refusal calls it}`. +MOVED_WHOLE = {"a clone of its own": a_clone_of_its_own, + "a link to a checkout": a_link_to_a_checkout} + + +@pytest.mark.parametrize("state", list(MOVED_WHOLE)) +def test_what_is_on_disk_and_no_checkout_keeps_its_mv(family, holder, + offline, tmp_path, + state): + """rv828's SHOULD 2, `_moved_or_kept`: only a directory, not a link, + holding a `.git` FILE is a checkout to delete by hand. An untracked + clone of its own at `Members/X`, or a link there to the member's + checkout, keeps #574's `mv`. Typed as printed, the `mv` moves it out of + the holder whole, and the same `add` goes through.""" + if disk_folds(tmp_path): + pytest.skip("`Members/X` beside `members/` needs a disk that does " + "not fold case") + kind = MOVED_WHOLE[state](holder, family["remotes"]) + said = refused_untouched(offline, holder, family["remotes"]) + assert said == (DIR_VARIANT + f"on disk and untracked: `Members/X` " + f"({kind})", remedy_of(untracked_moved("Members/X"))) + assert typed_as_printed(holder, said[1]) == [ + "mv -- Members/X ../moved-1"] + assert os.path.lexists(holder.parent / "moved-1") + added(offline, holder, family["remotes"]) + + # --- 5. rv685's three -------------------------------------------------------- @pytest.mark.parametrize("committed", [False, True], @@ -638,86 +780,71 @@ def test_a_section_head_moved_is_a_change_to_commit(holder, family_module, move, *closing) -def test_what_is_set_aside_is_committed_with_the_move(family, holder, - offline, tmp_path): - """(g) staged: `git mv members/IRRS Members/IRRS` and a new file at the - row's path, neither committed. The move back alone would leave nothing - to commit, but the file renamed aside would be left staged, so the - commit is printed; typed as printed, HEAD holds the file at its new - name, the gate passes and the same `add` goes through.""" - folding_skip(tmp_path) - commit = mount_moved(holder, committed=False) - (holder / ROW).write_bytes(b"our own notes\n") - git("add", "--", ROW, cwd=holder) - move, closing = moved_back({ROW: ([VARIANT], commit)}, sections=(ROW,), - forced=True) - said = refused_untouched(offline, holder, family["remotes"]) - assert said[1] == remedy_of(set_aside([ROW], {}, renamed=[ROW]), move, - *closing) - assert typed_as_printed(holder, said[1], - lambda root: rmtree(root / VARIANT)) == [ - f"git mv -- {ROW} renamed-1", REMOVE_FORCED, record(commit), - *POINTED[::2], "git commit", MOVE_OUT, SUBMODULE] - assert git("show", "HEAD:renamed-1", cwd=holder).stdout == ( - "our own notes\n") - registered(holder, family["root"], commit) - gate_then_add(offline, holder, family["remotes"]) - - -def test_what_a_sparse_checkout_keeps_off_disk_is_committed_with_the_move( - family, holder, offline, tmp_path): - """The same, the new file marked `skip-worktree` and kept off disk, as a - sparse checkout keeps it: renamed aside with `git mv --sparse`, which - leaves a change to commit though nothing is on disk, so the commit is - printed (Codex's P2 1 on #828). Typed as printed, HEAD holds the file at - its new name, the gate passes and the same `add` goes through.""" +def kept_off(root: Path) -> None: + """The new file at the row's path marked `skip-worktree` and kept off + disk, as a sparse checkout keeps it.""" if git_version() < (2, 38): pytest.skip("`git mv --sparse` of one entry kept off disk needs git " "2.38 or later") - folding_skip(tmp_path) - commit = mount_moved(holder, committed=False) - (holder / ROW).write_bytes(b"our own notes\n") - git("add", "--", ROW, cwd=holder) - git("update-index", "--skip-worktree", "--", ROW, cwd=holder) - (holder / ROW).unlink() - move, closing = moved_back({ROW: ([VARIANT], commit)}, sections=(ROW,), - forced=True) - said = refused_untouched(offline, holder, family["remotes"]) - assert said[1] == remedy_of(set_aside([ROW], {}, sparse=[ROW]), move, - *closing) - assert typed_as_printed(holder, said[1], - lambda root: rmtree(root / VARIANT)) == [ - f"git mv --sparse -- {ROW} renamed-1", REMOVE_FORCED, record(commit), - *POINTED[::2], "git commit", MOVE_OUT, SUBMODULE] - assert git("show", "HEAD:renamed-1", cwd=holder).stdout == ( - "our own notes\n") - registered(holder, family["root"], commit) - gate_then_add(offline, holder, family["remotes"]) + git("update-index", "--skip-worktree", "--", ROW, cwd=root) + (root / ROW).unlink() -def test_a_removal_where_head_records_the_gitlink_leaves_no_commit( - family, holder, offline, tmp_path): - """The same, the new file deleted from disk and not marked: taken out of - the index. HEAD records the member's gitlink at that path, which the - move puts back, so the removal leaves nothing to commit, and no commit - is printed: one was, and `git commit` failed with nothing to commit - (found in #828's fix round for Codex's P2s). Typed as printed, the - member is where its row says, the gate passes and the same `add` goes - through.""" +def deleted(root: Path) -> None: + """The new file at the row's path deleted from disk, not marked.""" + (root / ROW).unlink() + + +#: `{state: (what is done to the new file once it is staged, the set-aside +#: part's arguments, the line typed for it, whether a commit is printed)}`. +STAGED_ASIDE = { + "on disk": (None, {"renamed": [ROW]}, f"git mv -- {ROW} renamed-1", + True), + "kept off disk by a sparse checkout": ( + kept_off, {"sparse": [ROW]}, f"git mv --sparse -- {ROW} renamed-1", + True), + "deleted from disk": (deleted, {"out": [ROW]}, + f"git rm -r --cached -- {ROW}", False), +} + + +@pytest.mark.parametrize("state", list(STAGED_ASIDE)) +def test_what_is_set_aside_is_committed_with_the_move(family, holder, + offline, tmp_path, + state): + """(g) staged: `git mv members/IRRS Members/IRRS` and a new file at the + row's path, neither committed, so the move back alone leaves nothing to + commit. + - On disk, the file renamed aside would be left staged, so the commit + is printed. + - Kept off disk by a sparse checkout, it is renamed with `git mv + --sparse`, which leaves a change to commit all the same (Codex's P2 1 + on #828). + - Deleted from disk, it is taken out of the index. HEAD records the + member's gitlink at that path, which the move puts back, so nothing + is left to commit and no commit is printed: one was, and `git commit` + failed with nothing to commit (found in #828's first fix round). + Typed as printed, HEAD holds the file at its new name where it was + renamed, the member is where its row says, the gate passes and the same + `add` goes through.""" + left, how, typed_first, committed = STAGED_ASIDE[state] folding_skip(tmp_path) commit = mount_moved(holder, committed=False) (holder / ROW).write_bytes(b"our own notes\n") git("add", "--", ROW, cwd=holder) - (holder / ROW).unlink() - move, closing = moved_back({ROW: ([VARIANT], commit)}, commit=False, + if left is not None: + left(holder) + move, closing = moved_back({ROW: ([VARIANT], commit)}, commit=committed, sections=(ROW,), forced=True) said = refused_untouched(offline, holder, family["remotes"]) - assert said[1] == remedy_of(set_aside([ROW], {}, out=[ROW]), move, - *closing) + assert said[1] == remedy_of(set_aside([ROW], {}, **how), move, *closing) assert typed_as_printed(holder, said[1], lambda root: rmtree(root / VARIANT)) == [ - f"git rm -r --cached -- {ROW}", REMOVE_FORCED, record(commit), - *POINTED[::2], MOVE_OUT, SUBMODULE] + typed_first, REMOVE_FORCED, record(commit), *POINTED[::2], + *(["git commit"] if committed else []), MOVE_OUT, SUBMODULE] + if committed: + assert git("show", "HEAD:renamed-1", cwd=holder).stdout == ( + "our own notes\n") registered(holder, family["root"], commit) gate_then_add(offline, holder, family["remotes"]) @@ -754,6 +881,45 @@ def test_both_removals_staged_with_no_gitmodules_on_disk(family, holder, gate_then_add(offline, holder, family["remotes"]) +def no_gitlink_moved(root: Path) -> None: + """Every member's gitlink taken out of the index, and a file planted at + `members`: nothing is moved back, so nothing is in a move's way.""" + git("rm", "-r", "-q", "--cached", "--", "members", cwd=root) + plant(root, "100644", "members") + + +def a_gitlink_at_members(root: Path) -> None: + """`git mv members Members`, committed, and a gitlink planted at + `members`: the move back moves both members' gitlinks, and the gitlink + is no file or link to set aside.""" + renamed_with_git(root, True) + plant(root, "160000", "members") + + +#: `{state: how it is made}`, each with nothing to set aside. +NOTHING_ASIDE = {"no gitlink moved back": no_gitlink_moved, + "a gitlink at members": a_gitlink_at_members} + + +@pytest.mark.parametrize("state", list(NOTHING_ASIDE)) +def test_only_a_file_or_link_in_a_moves_way_is_set_aside(holder, + family_module, + tmp_path, state): + """rv828's NIT 5: what is set aside is a file or link, stage 0, at + `members` or at or under a row's path, and only where a gitlink is moved + back. A file at `members` with no gitlink to move back is in no move's + way, and a gitlink at `members` is never taken out of the index, which + would un-register what it records.""" + if state == "a gitlink at members": + folding_skip(tmp_path) + NOTHING_ASIDE[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 [spelled.name for spelled in found + if spelled.remedy in family_module.SET_ASIDE] == [] + + def test_a_checkout_under_an_index_entry_is_the_indexs_to_name( holder, family_module): """A checkout on disk at `Members/X` (a `.git` file) under which the