From 80050a9fd37d8e55c3c5b1dd294353ea0ac31ebe Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:24:43 +0000 Subject: [PATCH 1/3] family.py: `add` asks the holder's index for the mount path in any case, so an entry `Members/Atlas/README` is refused as `member-path-taken` before anything is cloned instead of leaving the member's 19 files staged under it, and a refused `add` says the holder is exactly as it was only when the whole index is as it was (#302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE DEFECT. With `core.ignorecase=true` and an index entry `Members/Atlas/README` (nothing on disk), `add --member testorg/Atlas` on c0ed615 mounted the member, and `git submodule add` staged its 19 files as regular files under `Members/Atlas/`. The commit's pathspec `members/Atlas` then matched nothing, and `MountRollback`, which reads the index by exact name, put back six things and printed "the holder is exactly as it was before this command" over an index that had gone from 16 entries to 35. A plain `git commit` in the holder would have committed them. `member-path- taken`'s index read (`_index_entries`, #242) was exact as well, so it never saw the variant. RULING 1, REFUSE BEFORE THE MOUNT. `_index_entries` now finds every entry AT the mount path or UNDER it IN ANY CASE, on every disk, whatever `core.ignorecase` says, as adopt's `_index_entries` does. A holder is cloned on macOS and Windows, where `Members/Atlas` and `members/Atlas` are one path. It is LITERAL TOO, and so asks git with no pathspec at all: it lists the whole index (`_index_records`, `git ls-files -s -z`) and compares names with ASCII case folded. Probed on git 2.43 in a scratch repository with a `*` in the path: - `--literal-pathspecs --icase-pathspecs` is fatal ("global 'literal' pathspec setting is incompatible with all other global pathspec settings"); - `--icase-pathspecs` alone is a glob: `members/A*` selected `members/Atlas/z` and `members/A*X/w`; - `:(literal,icase)members/A*` selects the right three entries, but with `GIT_LITERAL_PATHSPECS=1` in the environment git does not parse the magic and matches nothing, exit 0, so the refusal would vanish without a word; - the listing compared in Python selects the same three under every `GIT_*_PATHSPECS` setting; - git's `icase` folds ASCII only (`members/ä` did not select `members/Ä`), and so does `bytes.lower()`. The refusal names the entry as the index spells it, and its remediation spells each variant the index holds, because `git rm -r --cached -- members/Atlas` does not match `Members/Atlas/...` even with `core.ignorecase=true` ("pathspec 'members/Atlas' did not match any files"). For a variant it adds one clause: where HEAD holds it too, commit the removal first. The add's own commit names `members/Atlas` alone, so a probe that skipped that commit ended with HEAD holding both `Members/Atlas/notes.txt` and `members/Atlas`. For an exact-case entry the add's commit carries the staged removal, and its text is unchanged, so #242's rows pass as they were. `_index_entries` now returns one entry per name, not one per stage. ON DISK. A DIRECTORY `Members/Atlas` that is tracked is in the index and is now refused. An UNTRACKED one on a case-sensitive disk still adds: `members/Atlas` is mounted beside it, the commit holds only the gitlink, and `Members/` stays untracked, so the pushed holder has no case twin for a case-insensitive clone. On a case-insensitive disk the existing `lexists` check already refuses it. Refusing it on Linux would take a case-folded walk of the disk in the disk block, not a few lines in the index block, so it is left as a follow-up. RULING 2, THE CLOSING SENTENCE. `MountRollback` records the whole index before the mount. When no step failed, `undo` compares it with the index after its steps. When they agree, the sentence is today's. When they differ, it says "Nothing was committed: the mount has been undone — ... — but the holder's index is NOT as it was before this command (new in it: ...; gone from it: ...; staged differently: ...)", at most five names each. It then sends the human to `git status` and names `git rm -r --cached -- `, which touches no file, for a path that is new. An index that cannot be read back after the unwind gets the partial sentence. A state ruling 1 rightly lets by reaches this: an index entry `Members/README`, a variant of `members/` and not of the mount path, makes git stage the new gitlink as `Members/IRQS` under `core.ignorecase`. The commit fails on its pathspec, and the unwind left that gitlink staged while printing "exactly as it was". `git rm -r --cached -- Members/IRQS` puts the index back byte for byte and touches no file. RULING 3. `_entries`, the rollback's per-name read, keeps its exact-name records: the whole-index comparison covers what it does not ask for. TESTS, appended to `tests/test_family_add_refusals.py`: - the issue's state, refused before anything is read, the index byte- identical (`git ls-files -s -z`) and the snapshot equal. `core.ignorecase` true and false, for a file under the mount path, a gitlink at it and a committed file under it. The remediation, run as printed, lets the same add through with the member under one spelling; - `Members/README` under `core.ignorecase`: the refusal at the commit, the closing sentence naming `Members/IRQS`, the one new record being that gitlink, and the remediation restoring the snapshot. It asserts the step, not the commit command's spelling, which #299 changes; - `MountRollback` at unit level: an unchanged index keeps "nothing in the holder had changed"; six new entries, one gone and one restaged are named with the cap of five; an index that cannot be read back gets the partial sentence; - `_index_entries` at unit level: any case, not a prefix sibling, not a variant of `members/` alone, and `members/IRQ*` read as a name. MUTANTS, each run against the new tests: - the index read exact again: the six refusal rows and the unit selection test fail (7 failed). On the issue's state the sentence then names `Members/Atlas/.gitattributes` and four more "and 14 more" instead of saying "exactly as it was"; - the index comparison dropped: the ruling-2 test and both unit tests of the sentence fail (3 failed); - the prefix compared without `/`, and adopt's `--icase-pathspecs` read: the unit selection test fails (1 failed each); - the remediation spelled as the exact path: the six refusal rows fail (6 failed). Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 146 +++++++++++++++++--- tests/test_family_add_refusals.py | 222 ++++++++++++++++++++++++++++++ 2 files changed, 349 insertions(+), 19 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 88125222..067eee9b 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -945,24 +945,58 @@ def _module_dir(root: Path, path: str) -> tuple[str, Path]: INDEX_ENTRY_KINDS = {0o160000: "a submodule", 0o120000: "a symlink"} +def _index_records(root: Path) -> dict[bytes, list[bytes]]: + """Every entry of the holder's index, `{name: its records}`: one record + per stage, each as `git ls-files -s -z` prints it, which is the form + `git update-index --index-info` takes back. + + THE WHOLE INDEX, WITH NO PATHSPEC, so no pathspec setting in the + environment can narrow what is read. `_index_entries` picks a mount + path's entries out of it, and `MountRollback` compares all of it, before + the mount and after the unwind (#302). `-z`, for a name git would + otherwise quote. + """ + listed = git_out(["ls-files", "-s", "-z"], cwd=root, binary=True) + records: dict[bytes, list[bytes]] = {} + for record in listed.split(b"\0"): + if record: + records.setdefault(record.partition(b"\t")[2], []).append(record) + return records + + def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: """`(name, kind)` of every entry the holder's index holds AT `path` or - UNDER it: what `git submodule add` refuses, without `--force`, as - "already exists in the index". - - The pathspec is the one git's own check is made with, taken literally: - `members/IRRS` selects `members/IRRS` and `members/IRRS/...`, and never - `members/IRRSX`. `-z`, for a name git would otherwise quote. + UNDER it, IN ANY CASE: what `git submodule add` refuses, without + `--force`, as "already exists in the index", and what a clone on a + case-insensitive disk takes for the same path. One per name, whatever + its stages, spelled as the index spells it. + + ANY CASE, ON EVERY DISK, whatever the holder's `core.ignorecase` says, + as adopt's `_index_entries` asks (#302). A holder is cloned on macOS and + Windows too, where `Members/IRRS` and `members/IRRS` are ONE path. With + `core.ignorecase` set, `git submodule add` staged the member's own files + under the variant the index held (an entry `Members/IRRS/README` had 19 + staged beside it). The commit's pathspec then matched nothing, and the + unwind, which asks for exact names, left all 19 staged while saying the + holder was exactly as it was. + + LITERAL, AND SO ASKED WITH NO PATHSPEC AT ALL. git refuses + `--literal-pathspecs` beside `--icase-pathspecs`, and `--icase-pathspecs` + alone reads `*` and `?` as globs. `:(literal,icase)` magic is not parsed + at all when the environment sets `GIT_LITERAL_PATHSPECS`, and then it + matches nothing, without a word. So the whole index is listed and the + names are compared here, with ASCII case folded, as git's own `icase` + folds it: `members/IRRS` selects `MEMBERS/irrs` and `members/IRRS/...`, + and never `members/IRRSX`, nor a `members/IRR?` that a glob would match. """ - raw = git_out(["--literal-pathspecs", "ls-files", "-s", "-z", "--", path], - cwd=root, binary=True) + wanted = path.encode("utf-8").lower() entries = [] - for record in raw.split(b"\0"): - if record: - head, _, name = record.partition(b"\t") + for name, records in _index_records(root).items(): + folded = name.lower() + if folded == wanted or folded.startswith(wanted + b"/"): entries.append((name.decode("utf-8", "replace"), - INDEX_ENTRY_KINDS.get(int(head.split()[0], 8), - "a file"))) + INDEX_ENTRY_KINDS.get( + int(records[0].split()[0], 8), "a file"))) return entries @@ -1033,17 +1067,34 @@ def _checked_mount_path(root: Path, project: str, path: str) -> None: "directory. Remove it (`git rm -r` if it is tracked), then re-run.") held = _index_entries(root, path) if held: - at_it = [entry for entry in held if entry[0] == path] + at_it = [entry for entry in held if entry[0].lower() == path.lower()] name, kind = (at_it or held)[0] more = f" and {len(held) - 1} more" if len(held) > 1 else "" + # IN ANY CASE, AND NAMED AS THE INDEX SPELLS IT (#302). `git rm` + # matches a pathspec case-sensitively even with `core.ignorecase` + # set, so the remediation names each spelling of the mount path the + # index holds. That is `path` itself when the case is the same. A + # removal staged under another spelling is one the add's commit, + # whose pathspec is `path`, does not carry, so where HEAD holds the + # variant it is committed first, or the holder keeps both. + variant = name[:len(path)] + spelled = " ".join(sorted({entry[0][:len(path)] for entry in held})) raise Refusal( "member-path-taken", f"the family root's index holds `{name}` ({kind}){more}" - + ("" if at_it else f" under {path}") + f", though nothing is at " - f"{path} on disk and no `members:` row names it", + + ("" if at_it else f" under {variant}") + + ("" if variant == path else + f", the same path as {path} on a case-insensitive disk") + + f", though nothing is at {path} on disk and no `members:` row " + "names it", "Remediation: this is a half-finished add or a hand edit. `git rm " - f"-r --cached -- {path}` takes it out of the index and touches no " - "file; then re-run.") + f"-r --cached -- {spelled}` takes it out of the index and touches " + "no file; " + + ("then " if spelled == path else + "where HEAD holds it too, commit that removal first, since the " + f"add's own commit names {path} alone and would keep both " + "spellings; then ") + + "re-run.") try: run(["git", "--literal-pathspecs", "add", "--dry-run", "--ignore-missing", "-f", "--no-warn-embedded-repo", "--", path], @@ -1352,6 +1403,16 @@ class MountRollback: moved the one commit happened: undoing the tree under it would leave a working copy that contradicts its own history, so nothing is undone and the sentence says so. + + "EXACTLY AS IT WAS" ONLY FOR WHAT WAS COMPARED (#302). Each step puts + back what it recorded, by its exact name. git can stage under a name no + step asks for: with `core.ignorecase` set, a mount whose directory the + index already holds in another case is staged in that case, and the + commit's pathspec then matches nothing. So the WHOLE index is recorded + too, and `undo` compares it after its steps. Only when the two agree + does the sentence say the holder is as it was. When they do not, it + names what differs and sends the human to `git status`, and the one + command it names, `git rm -r --cached`, touches no file. """ def __init__(self, root: Path, path: str) -> None: @@ -1367,6 +1428,9 @@ def __init__(self, root: Path, path: str) -> None: #: and takes a commit it names now, and did not then, as the one #: commit having landed. self.head = self._head() + #: The whole index, which `undo` compares with the index after its + #: steps (#302). + self.index = _index_records(root) self.gitlink = self._entries(path) self.files = {name: (self._bytes(name), self._entries(name)) for name in (GITMODULES, MANIFEST)} @@ -1476,9 +1540,34 @@ def _undo_registration(self) -> bool: cwd=self.root) return True + def _index_moved(self) -> str: + """What the index holds now that it did not hold before the mount, + what it has lost, and what it holds staged differently, each named + up to five times in the index's order. Returns "" when the index is + the one the constructor recorded (#302).""" + now = _index_records(self.root) + if now == self.index: + return "" + parts = [] + for said, names in ( + ("new in it", [name for name in now + if name not in self.index]), + ("gone from it", [name for name in self.index + if name not in now]), + ("staged differently", [name for name in now + if name in self.index + and now[name] != self.index[name]])): + if names: + shown = ", ".join(f"`{name.decode('utf-8', 'replace')}`" + for name in names[:5]) + more = f" and {len(names) - 5} more" if len(names) > 5 else "" + parts.append(f"{said}: {shown}{more}") + return "; ".join(parts) + def undo(self) -> str: """Put the holder back. Returns the sentence a refusal ends with: - what was put back, or — if any of it could not be — what is left.""" + what was put back, or — if any of it could not be — what is left, + and, where the index is not the one recorded, what differs in it.""" # NO HEAD IS NOT A COMMIT THAT LANDED: a holder with no commit yet # reads None before and after, and so does one whose HEAD names no # readable commit. Only a commit HEAD now names and did not before @@ -1514,12 +1603,31 @@ def undo(self) -> str: f"was: {_reason(exc.cause)}") except (CommandFailed, Refusal, OSError) as exc: failed.append(f"{said}: {_reason(exc)}") + # THE WHOLE INDEX, once every step has been asked (#302). A partial + # unwind already says it is one, and names `git status`. An index + # that cannot be read back is one more thing not shown to be back. + moved = "" + if not failed: + try: + moved = self._index_moved() + except Refusal as exc: + failed.append("the index read back to compare with the one " + f"recorded before the mount: {_reason(exc)}") if failed: return (" The mount was undone only in part" + (" (" + ", ".join(restored) + ")" if restored else "") + "; NOT " + "; NOT ".join(failed) + ". Put those back " "by hand — `git status` shows what is left — before " "running `add` again.") + if moved: + return ((" Nothing was committed: the mount has been undone — " + + ", ".join(restored) + " —" if restored else + " Nothing was committed,") + + " but the holder's index is NOT as it was before this " + f"command ({moved}). Put it back before running `add` " + "again: `git status` shows each difference, and `git rm " + "-r --cached -- ` takes a path new in it back out " + "and touches no file.") if restored: return (" Nothing was added: the mount has been undone — " + ", ".join(restored) + " — and the holder is exactly as " diff --git a/tests/test_family_add_refusals.py b/tests/test_family_add_refusals.py index 0d1b7627..def16c69 100644 --- a/tests/test_family_add_refusals.py +++ b/tests/test_family_add_refusals.py @@ -1280,3 +1280,225 @@ def test_the_first_add_to_a_holder_with_no_commit_makes_its_root_commit( assert row["pin"]["commit"] == tip assert git("status", "--porcelain", cwd=root).stdout == "" offline.assert_left_nothing() + + +# --- a case variant of the mount path, and the whole index (#302) ----------- + +#: `add`'s mount path for the spare member. +SPARE_MOUNT = f"members/{SPARE}" + +#: What a refused `add` ends with when the unwind put back everything it +#: recorded but the index still differs: the middle names what was put back, +#: and `{moved}` what differs. +INDEX_MOVED = ( + "Nothing was committed: the mount has been undone — {restored} — but the " + "holder's index is NOT as it was before this command ({moved}). Put it " + "back before running `add` again: `git status` shows each difference, and " + "`git rm -r --cached -- ` takes a path new in it back out and " + "touches no file.") + +#: Each case variant of the mount path the index can hold, as `(mode, name, +#: whether HEAD holds it as well)`: a file under it and a gitlink at it, +#: staged, and a file under it that a commit already holds. +CASE_VARIANTS = { + "a file under it, staged": ("100644", f"Members/{SPARE}/README", False), + "a submodule at it, staged": ("160000", f"members/{SPARE.lower()}", False), + "a file under it, committed": ("100644", f"Members/{SPARE}/README", True), +} + + +def index_bytes(root: Path) -> bytes: + """`git ls-files -s -z` of `root`, as bytes: the whole index, every + record and every byte of each name, which is the strongest form of "the + index is as it was".""" + return subprocess.run(["git", "ls-files", "-s", "-z"], cwd=str(root), + capture_output=True, check=True).stdout + + +def plant(root: Path, mode: str, name: str) -> None: + """An index entry `name` and nothing on disk, by `update-index + --cacheinfo`: a case variant needs no case-insensitive disk this way, so + the state is the same on every OS. A gitlink names a commit nothing has; + a file is a blob written as BYTES. `core.protectNTFS` is off for this + one command, so git for Windows indexes a `*` it would refuse to check + out.""" + oid = "1" * 40 + if mode != "160000": + oid = subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(root), input=b"a file\n", + capture_output=True, + check=True).stdout.decode("ascii").strip() + git("-c", "core.protectNTFS=false", "update-index", "--add", "--cacheinfo", + f"{mode},{oid},{name}", cwd=root) + + +@pytest.mark.parametrize("ignorecase", ["true", "false"]) +@pytest.mark.parametrize("variant", list(CASE_VARIANTS)) +def test_a_case_variant_of_the_mount_path_in_the_index_is_refused_first( + family, holder, offline, variant, ignorecase): + """THE ISSUE'S STATE (#302): the index holds the mount path in another + case, and nothing is on disk. With `core.ignorecase` set, `git submodule + add` staged the member's own files under the variant (19 of them beside + `Members/IRQS/README`). The commit's pathspec then matched nothing, and + the unwind, asking for exact names, left all 19 staged while saying the + holder was exactly as it was. Now it is refused before the member is + even read, on every disk and whatever `core.ignorecase` says, because a + holder is cloned on macOS and Windows too, where the two spellings are + one path. The refusal names the entry as the index spells it, and its + remediation, run as printed, lets the same command through with the + member under one spelling only. Where HEAD holds the variant, the + removal is committed first: the add's own commit names `members/IRQS` + alone and would keep both.""" + mode, name, committed = CASE_VARIANTS[variant] + git("config", "--local", "core.ignorecase", ignorecase, cwd=holder) + plant(holder, mode, name) + if committed: + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", + f"Track {name}", cwd=holder) + spelled = name[:len(SPARE_MOUNT)] + assert spelled != SPARE_MOUNT and spelled.lower() == SPARE_MOUNT.lower() + kind = "a submodule" if mode == "160000" else "a file" + before, index = snapshot(holder), index_bytes(holder) + + result = offline.add(holder, SPARE, remotes=family["remotes"]) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stdout == "" + assert refusal(result.stderr) == ( + f"REFUSED member-path-taken: the family root's index holds `{name}` " + f"({kind})" + ("" if name == spelled else f" under {spelled}") + + f", the same path as {SPARE_MOUNT} on a case-insensitive disk, " + f"though nothing is at {SPARE_MOUNT} on disk and no `members:` row " + "names it", + "Remediation: this is a half-finished add or a hand edit. `git rm -r " + f"--cached -- {spelled}` takes it out of the index and touches no " + "file; where HEAD holds it too, commit that removal first, since the " + f"add's own commit names {SPARE_MOUNT} alone and would keep both " + "spellings; then re-run.") + assert index_bytes(holder) == index + assert snapshot(holder) == before + offline.assert_left_nothing() + + git("rm", "-r", "-q", "--cached", "--", spelled, cwd=holder) + if committed: + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", + f"Untrack {spelled}", cwd=holder) + again = offline.add(holder, SPARE, remotes=family["remotes"]) + assert again.returncode == 0, again.stdout + again.stderr + held = [line for line in git("ls-tree", "-r", "--name-only", "HEAD", + cwd=holder).stdout.splitlines() + if line.lower() == SPARE_MOUNT.lower() + or line.lower().startswith(SPARE_MOUNT.lower() + "/")] + assert held == [SPARE_MOUNT] + offline.assert_left_nothing() + + +def test_a_gitlink_git_stages_under_another_spelling_is_named_not_hidden( + family, holder, offline): + """THE CLOSING SENTENCE SAYS "EXACTLY AS IT WAS" ONLY FOR WHAT IT + COMPARED (#302). The index holds `Members/README`, a case variant of + `members/` and not of the mount path, so `member-path-taken` lets it + by: it is not at `members/IRQS` or under it in any spelling. With + `core.ignorecase` set, git stages the new gitlink in the spelling the + index already gives the directory, `Members/IRQS`, and the commit, whose + pathspec is `members/IRQS`, matches nothing. The unwind puts back every + piece it recorded by name, and the gitlink it never recorded is still + staged. That used to end "the holder is exactly as it was". The whole + index is compared now, so the sentence names the entry, and the command + it names takes it back out, touching no file.""" + git("config", "--local", "core.ignorecase", "true", cwd=holder) + plant(holder, "100644", "Members/README") + tip = git("rev-parse", "main", + cwd=family["remotes"] / f"{SPARE}.git").stdout.strip() + before, index = snapshot(holder), index_bytes(holder) + + result = offline.add(holder, SPARE, remotes=family["remotes"]) + assert result.returncode == 2, result.stdout + result.stderr + assert result.stdout == (f"checking {ORG}/{SPARE} before mounting it\n" + f"mounting {ORG}/{SPARE} at {SPARE_MOUNT}\n") + # The step that failed, and not the command's own spelling, which is the + # commit's business and changes with it. + lines = result.stderr.splitlines() + assert lines[0] == (f"REFUSED committing {SPARE} to the family. THE " + "EXACT COMMAND WAS:") + assert lines[-1] == INDEX_MOVED.format( + restored=f"{SPARE_MOUNT} removed, .git/modules/{SPARE_MOUNT} removed, " + ".gitmodules restored byte for byte, family.yaml restored " + f"byte for byte, `submodule.{SPARE_MOUNT}` dropped from the " + "holder's git config", + moved=f"new in it: `Members/{SPARE}`") + # What it says is what is there: one record more, the member's gitlink + # in the other spelling, and nothing lost. + was, now = set(index.split(b"\0")), set(index_bytes(holder).split(b"\0")) + assert now - was == {f"160000 {tip} 0\tMembers/{SPARE}".encode()} + assert was - now == set() + offline.assert_left_nothing() + + git("rm", "-r", "-q", "--cached", "--", f"Members/{SPARE}", cwd=holder) + assert index_bytes(holder) == index + assert snapshot(holder) == before + + +def test_the_closing_sentence_names_what_the_index_gained_lost_and_changed( + holder, family_module): + """THE SENTENCE AT UNIT LEVEL (#302), for the differences no mount makes + on its own. The rollback records the index, which is then changed by + hand. With nothing changed, `undo` keeps the sentence it had. Then six + entries are new, one is gone and one is staged differently. `undo` has + nothing of its own to put back, and it names each kind, five names at + most, never calling the holder unchanged.""" + rollback = family_module.MountRollback(holder, SPARE_MOUNT) + assert rollback.undo() == ( + " Nothing was added, and nothing in the holder had changed.") + for n in range(1, 7): + plant(holder, "100644", f"new/{n}") + git("update-index", "--force-remove", "--", "README.md", cwd=holder) + plant(holder, "100644", "Makefile") + assert rollback.undo() == ( + " Nothing was committed, but the holder's index is NOT as it was " + "before this command (new in it: `new/1`, `new/2`, `new/3`, `new/4`, " + "`new/5` and 1 more; gone from it: `README.md`; staged differently: " + "`Makefile`). Put it back before running `add` again: `git status` " + "shows each difference, and `git rm -r --cached -- ` takes a " + "path new in it back out and touches no file.") + + +def test_an_index_that_cannot_be_read_back_is_never_called_as_it_was( + holder, family_module, monkeypatch): + """The comparison is a git command too. If the index cannot be read + back after the unwind, it has not been shown to be as it was, so the + sentence is the partial one: it names git's reason and sends the human + to `git status`, and the refusal it ends is still the one printed.""" + rollback = family_module.MountRollback(holder, SPARE_MOUNT) + + def unreadable(root): + raise family_module.Refusal( + "git-failed", f"`git ls-files -s -z` in {root} exited 128: " + "fatal: index file corrupt") + + monkeypatch.setattr(family_module, "_index_records", unreadable) + assert rollback.undo() == ( + " The mount was undone only in part; NOT the index read back to " + "compare with the one recorded before the mount: `git ls-files -s -z` " + f"in {holder} exited 128: fatal: index file corrupt. Put those back " + "by hand — `git status` shows what is left — before running `add` " + "again.") + + +def test_the_index_is_asked_at_the_mount_path_in_any_case_and_literally( + holder, family_module): + """`_index_entries` (#302): every entry AT the path or UNDER it, in any + case, each as the index spells it. Never a neighbour the path is only a + prefix of (`members/IRQSX`), never a variant of `members/` alone, and + never what a glob would add: `members/IRQ*` is a name, so it selects + itself in any case, where `git --icase-pathspecs` would read it as a + glob and select `members/IRQS`, `members/IRQSX/b` and `MEMBERS/irqs/a` + besides. (The entries are planted, with nothing on disk.)""" + for name in ("MEMBERS/irqs/a", SPARE_MOUNT, f"{SPARE_MOUNT}X/b", + "Members/README", "members/IRQ*/c", "members/irq*"): + plant(holder, "100644", name) + plant(holder, "160000", f"members/{SPARE.lower()}/d") + assert family_module._index_entries(holder, SPARE_MOUNT) == [ + ("MEMBERS/irqs/a", "a file"), (SPARE_MOUNT, "a file"), + (f"members/{SPARE.lower()}/d", "a submodule")] + assert family_module._index_entries(holder, "members/IRQ*") == [ + ("members/IRQ*/c", "a file"), ("members/irq*", "a file")] From 4302d85a8c3c590d390a9cf5aa5f13c4d4583b09 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:54:33 +0000 Subject: [PATCH 2/3] family.py: a refused `add` whose index differs points to `git ls-files -s` rather than to a `git status` that cannot show a vanished staged entry, an index that cannot be read back is called unconfirmed rather than partly undone, the commit-first clause says what skipping it does under `core.ignorecase` too, and `member-path-taken` counts a conflicted entry once per stage again, as main did (#302) The review of 80050a9 found it mergeable with nits: no MUST, one SHOULD and six NITs. This commit answers the SHOULD and five of the NITs. SHOULD-1, the record comparison is pinned. `_index_moved` calls the index unchanged only when names AND records agree. No test told the two apart: the unit test restaged `Makefile` in the same change that added six entries and removed one. So a mutant comparing names alone passed all 47 tests and said "nothing in the holder had changed" over a restaged entry. That false claim is the one #302 exists to stop. The unit test now restages `Makefile` and nothing else first, and asserts "staged differently: `Makefile`". N1, the reason for "commit that removal first". The clause said the add's commit "would keep both spellings". That is so only with `core.ignorecase` false. Probed with HEAD holding `Members/Atlas/README`, `git rm -r --cached` run and the commit skipped: - with `core.ignorecase` false, HEAD holds `Members/Atlas/README` and `160000 members/Atlas`; - with it true, the macOS and Windows default, HEAD holds `160000 Members/Atlas` alone, while `.gitmodules` and `family.yaml` say `members/Atlas`. `validate-family.py` passes both. The clause now names both outcomes. The tool side of the second is follow-up F2. N2. "`git status` shows each difference" was false for an entry that was staged, not in HEAD, and then vanished: status compares the index with HEAD and prints nothing for it. The sentence now names `git ls-files -s`, which lists what the index holds. N3. An index that cannot be read back after the unwind was reported as "The mount was undone only in part; NOT the index read back ...", though every step may have succeeded. It now says the mount was undone, that the index could not be read back to confirm it is as it was, with git's reason, and to check it with `git status` before running `add` again. N4. `_index_entries` returns one entry per index record again, as main's pathspec read did. So a path conflicted at three stages is "and 2 more", as it was, and an exact-case refusal is byte-identical to main's. A new unit test pins that text. It passes with c0ed615's `family.py` and fails with 80050a9's. N5. A comment says the fold is ASCII-only, as git's `icase` is, and that APFS and NTFS fold more. No code change. N6 is left as it was. With three spellings held, the detail's "and 2 more under MEMBERS/ATLAS" scopes the count to the first spelling. Listing every spelling there reads worse, and the remediation already names all three and clears them. Mutants, run in a scratch clone: - names compared, records not (the review's R6): 47 passed at 80050a9's tests, and 1 failed (the restage-only step) at these; - the earlier five, at these bytes: the index read exact again 7 failed, the comparison dropped 3, a prefix without `/` 1, adopt's glob read 1, the remediation as the exact path 6. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 61 +++++++++++++++--------- tests/test_family_add_refusals.py | 78 +++++++++++++++++++++++-------- 2 files changed, 97 insertions(+), 42 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index 067eee9b..a613f06c 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -968,8 +968,9 @@ def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: """`(name, kind)` of every entry the holder's index holds AT `path` or UNDER it, IN ANY CASE: what `git submodule add` refuses, without `--force`, as "already exists in the index", and what a clone on a - case-insensitive disk takes for the same path. One per name, whatever - its stages, spelled as the index spells it. + case-insensitive disk takes for the same path. One per record, as git + lists them, so a conflicted path counts once per stage, and each is + spelled as the index spells it. ANY CASE, ON EVERY DISK, whatever the holder's `core.ignorecase` says, as adopt's `_index_entries` asks (#302). A holder is cloned on macOS and @@ -989,14 +990,17 @@ def _index_entries(root: Path, path: str) -> list[tuple[str, str]]: folds it: `members/IRRS` selects `MEMBERS/irrs` and `members/IRRS/...`, and never `members/IRRSX`, nor a `members/IRR?` that a glob would match. """ + # ASCII only, as git's `icase` folds; APFS and NTFS fold more (`ſ` is + # `s` to them), which no git check of a pathspec follows either. wanted = path.encode("utf-8").lower() entries = [] for name, records in _index_records(root).items(): folded = name.lower() if folded == wanted or folded.startswith(wanted + b"/"): - entries.append((name.decode("utf-8", "replace"), - INDEX_ENTRY_KINDS.get( - int(records[0].split()[0], 8), "a file"))) + entries.extend((name.decode("utf-8", "replace"), + INDEX_ENTRY_KINDS.get(int(record.split()[0], 8), + "a file")) + for record in records) return entries @@ -1075,8 +1079,11 @@ def _checked_mount_path(root: Path, project: str, path: str) -> None: # set, so the remediation names each spelling of the mount path the # index holds. That is `path` itself when the case is the same. A # removal staged under another spelling is one the add's commit, - # whose pathspec is `path`, does not carry, so where HEAD holds the - # variant it is committed first, or the holder keeps both. + # whose pathspec is `path`, does not carry. So where HEAD holds the + # variant, the removal is committed first. Otherwise the add's commit + # keeps both spellings, or, with `core.ignorecase` set, records the + # gitlink under the variant while `.gitmodules` and the manifest say + # `path`. variant = name[:len(path)] spelled = " ".join(sorted({entry[0][:len(path)] for entry in held})) raise Refusal( @@ -1093,7 +1100,9 @@ def _checked_mount_path(root: Path, project: str, path: str) -> None: + ("then " if spelled == path else "where HEAD holds it too, commit that removal first, since the " f"add's own commit names {path} alone and would keep both " - "spellings; then ") + "spellings, or, with `core.ignorecase` set, commit the gitlink " + f"under the other one while {GITMODULES} and {MANIFEST} say " + f"{path}; then ") + "re-run.") try: run(["git", "--literal-pathspecs", "add", "--dry-run", @@ -1411,8 +1420,8 @@ class MountRollback: commit's pathspec then matches nothing. So the WHOLE index is recorded too, and `undo` compares it after its steps. Only when the two agree does the sentence say the holder is as it was. When they do not, it - names what differs and sends the human to `git status`, and the one - command it names, `git rm -r --cached`, touches no file. + names what differs and sends the human to `git ls-files -s`, and the one + command that changes anything, `git rm -r --cached`, touches no file. """ def __init__(self, root: Path, path: str) -> None: @@ -1605,29 +1614,37 @@ def undo(self) -> str: failed.append(f"{said}: {_reason(exc)}") # THE WHOLE INDEX, once every step has been asked (#302). A partial # unwind already says it is one, and names `git status`. An index - # that cannot be read back is one more thing not shown to be back. - moved = "" + # that cannot be read back leaves the steps undone but unconfirmed, + # and the sentence says exactly that. + moved, unread = "", None if not failed: try: moved = self._index_moved() except Refusal as exc: - failed.append("the index read back to compare with the one " - f"recorded before the mount: {_reason(exc)}") + unread = _reason(exc) if failed: return (" The mount was undone only in part" + (" (" + ", ".join(restored) + ")" if restored else "") + "; NOT " + "; NOT ".join(failed) + ". Put those back " "by hand — `git status` shows what is left — before " "running `add` again.") + undone = ((" Nothing was committed: the mount has been undone — " + + ", ".join(restored) + " —") if restored else + " Nothing was committed,") + if unread is not None: + return (undone + " but the holder's index could not be read back " + "to confirm it is as it was before this command: " + f"{unread}. Check it with `git status` before running " + "`add` again.") + # `git ls-files -s`, NOT `git status`, for the index: status compares + # the index with HEAD, so an entry that was staged and is now gone + # shows nowhere in it. if moved: - return ((" Nothing was committed: the mount has been undone — " - + ", ".join(restored) + " —" if restored else - " Nothing was committed,") - + " but the holder's index is NOT as it was before this " - f"command ({moved}). Put it back before running `add` " - "again: `git status` shows each difference, and `git rm " - "-r --cached -- ` takes a path new in it back out " - "and touches no file.") + return (undone + " but the holder's index is NOT as it was before " + f"this command ({moved}). Put it back before running " + "`add` again: `git ls-files -s` lists what the index now " + "holds, and `git rm -r --cached -- ` takes a path " + "new in it back out and touches no file.") if restored: return (" Nothing was added: the mount has been undone — " + ", ".join(restored) + " — and the holder is exactly as " diff --git a/tests/test_family_add_refusals.py b/tests/test_family_add_refusals.py index def16c69..6b71ce12 100644 --- a/tests/test_family_add_refusals.py +++ b/tests/test_family_add_refusals.py @@ -1293,9 +1293,9 @@ def test_the_first_add_to_a_holder_with_no_commit_makes_its_root_commit( INDEX_MOVED = ( "Nothing was committed: the mount has been undone — {restored} — but the " "holder's index is NOT as it was before this command ({moved}). Put it " - "back before running `add` again: `git status` shows each difference, and " - "`git rm -r --cached -- ` takes a path new in it back out and " - "touches no file.") + "back before running `add` again: `git ls-files -s` lists what the index " + "now holds, and `git rm -r --cached -- ` takes a path new in it " + "back out and touches no file.") #: Each case variant of the mount path the index can hold, as `(mode, name, #: whether HEAD holds it as well)`: a file under it and a gitlink at it, @@ -1373,7 +1373,9 @@ def test_a_case_variant_of_the_mount_path_in_the_index_is_refused_first( f"--cached -- {spelled}` takes it out of the index and touches no " "file; where HEAD holds it too, commit that removal first, since the " f"add's own commit names {SPARE_MOUNT} alone and would keep both " - "spellings; then re-run.") + "spellings, or, with `core.ignorecase` set, commit the gitlink under " + f"the other one while .gitmodules and family.yaml say {SPARE_MOUNT}; " + "then re-run.") assert index_bytes(holder) == index assert snapshot(holder) == before offline.assert_left_nothing() @@ -1442,32 +1444,40 @@ def test_the_closing_sentence_names_what_the_index_gained_lost_and_changed( holder, family_module): """THE SENTENCE AT UNIT LEVEL (#302), for the differences no mount makes on its own. The rollback records the index, which is then changed by - hand. With nothing changed, `undo` keeps the sentence it had. Then six - entries are new, one is gone and one is staged differently. `undo` has - nothing of its own to put back, and it names each kind, five names at - most, never calling the holder unchanged.""" + hand. With nothing changed, `undo` keeps the sentence it had. With ONE + entry restaged and nothing else, its name is unchanged and its record is + not: comparing names alone would call that holder unchanged. Then six + entries are new and one is gone as well. `undo` has nothing of its own + to put back, and it names each kind, five names at most, never calling + the holder unchanged.""" rollback = family_module.MountRollback(holder, SPARE_MOUNT) assert rollback.undo() == ( " Nothing was added, and nothing in the holder had changed.") + then = ("Put it back before running `add` again: `git ls-files -s` lists " + "what the index now holds, and `git rm -r --cached -- ` " + "takes a path new in it back out and touches no file.") + plant(holder, "100644", "Makefile") + assert rollback.undo() == ( + " Nothing was committed, but the holder's index is NOT as it was " + f"before this command (staged differently: `Makefile`). {then}") for n in range(1, 7): plant(holder, "100644", f"new/{n}") git("update-index", "--force-remove", "--", "README.md", cwd=holder) - plant(holder, "100644", "Makefile") assert rollback.undo() == ( " Nothing was committed, but the holder's index is NOT as it was " "before this command (new in it: `new/1`, `new/2`, `new/3`, `new/4`, " "`new/5` and 1 more; gone from it: `README.md`; staged differently: " - "`Makefile`). Put it back before running `add` again: `git status` " - "shows each difference, and `git rm -r --cached -- ` takes a " - "path new in it back out and touches no file.") + f"`Makefile`). {then}") def test_an_index_that_cannot_be_read_back_is_never_called_as_it_was( holder, family_module, monkeypatch): """The comparison is a git command too. If the index cannot be read - back after the unwind, it has not been shown to be as it was, so the - sentence is the partial one: it names git's reason and sends the human - to `git status`, and the refusal it ends is still the one printed.""" + back after the unwind, every step may still have been undone, but the + index has not been shown to be as it was. The sentence says exactly + that, with git's reason, and sends the human to `git status`. It never + calls the holder as it was, nor the unwind partial, and the refusal it + ends is still the one printed.""" rollback = family_module.MountRollback(holder, SPARE_MOUNT) def unreadable(root): @@ -1477,11 +1487,10 @@ def unreadable(root): monkeypatch.setattr(family_module, "_index_records", unreadable) assert rollback.undo() == ( - " The mount was undone only in part; NOT the index read back to " - "compare with the one recorded before the mount: `git ls-files -s -z` " - f"in {holder} exited 128: fatal: index file corrupt. Put those back " - "by hand — `git status` shows what is left — before running `add` " - "again.") + " Nothing was committed, but the holder's index could not be read " + "back to confirm it is as it was before this command: `git ls-files " + f"-s -z` in {holder} exited 128: fatal: index file corrupt. Check it " + "with `git status` before running `add` again.") def test_the_index_is_asked_at_the_mount_path_in_any_case_and_literally( @@ -1502,3 +1511,32 @@ def test_the_index_is_asked_at_the_mount_path_in_any_case_and_literally( (f"members/{SPARE.lower()}/d", "a submodule")] assert family_module._index_entries(holder, "members/IRQ*") == [ ("members/IRQ*/c", "a file"), ("members/irq*", "a file")] + + +def test_a_conflicted_entry_at_the_mount_path_is_counted_once_per_stage( + holder, family_module): + """`member-path-taken` counts as main counted (#302): one per index + record, so a path conflicted at three stages is named "and 2 more". The + read now lists the whole index, and an exact-case entry still gets the + text #242 wrote, byte for byte. (The three stages are written as BYTES + on stdin.)""" + blob = subprocess.run(["git", "hash-object", "-w", "--stdin"], + cwd=str(holder), input=b"a file\n", + capture_output=True, + check=True).stdout.decode("ascii").strip() + subprocess.run(["git", "update-index", "--index-info"], cwd=str(holder), + input=b"".join(f"100644 {blob} {stage}\t{SPARE_MOUNT}/" + "README\n".encode("ascii") + for stage in (1, 2, 3)), + capture_output=True, check=True) + with pytest.raises(family_module.Refusal) as caught: + family_module._checked_mount_path(holder, SPARE, SPARE_MOUNT) + assert (caught.value.code, caught.value.detail, + caught.value.remediation) == ( + "member-path-taken", + f"the family root's index holds `{SPARE_MOUNT}/README` (a file) and " + f"2 more under {SPARE_MOUNT}, though nothing is at {SPARE_MOUNT} on " + "disk and no `members:` row names it", + "Remediation: this is a half-finished add or a hand edit. `git rm -r " + f"--cached -- {SPARE_MOUNT}` takes it out of the index and touches no " + "file; then re-run.") From 95a4e55bfa216ecabf860e9b2a4411736f7c1e13 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:06:48 +0000 Subject: [PATCH 3/3] family.py: the commit-first clause of `member-path-taken` says what skipping it does for every case variant, and both closing sentences for an index that differs or cannot be read back point to `git ls-files -s`, with the reason stated as what that listing does show (#302) The re-check of 4302d85 found it mergeable with two wording nits. No logic changes here. NIT-B, the commit-first clause. It said that skipping the commit would leave HEAD with "both spellings", or, under `core.ignorecase`, with the gitlink "under the other one". That is exact for a variant that differs only in `members/`, or only in the member's name. For `MEMBERS/ATLAS/README` under `core.ignorecase`, HEAD kept the variant's file AND recorded the gitlink as `MEMBERS/Atlas`, a third spelling, probed again here. The clause now says: HEAD keeps the other spelling beside the member, or, with `core.ignorecase` set, records the member's gitlink under a spelling other than the `members/

` that `.gitmodules` and `family.yaml` say, or both. That is true of all four outcomes probed. The instruction to commit the removal first is unchanged; it is right in every case. NIT-A, `git ls-files -s`. The comment justified that pointer by saying `git status` cannot show an entry that was staged and then vanished. But no listing can show such an entry, so only the sentence's own "gone from it" names it. The comment now says what is true: - `git ls-files -s` lists every record the index holds now, so a new or restaged entry can be read there; - a gone entry is named by the sentence; - `git status` compares the index with HEAD, not with the index before the command. The sentence for an index that cannot be read back pointed to `git status`. It now points to `git ls-files -s`, consistent with the other sentence. The tests that pin these sentences word for word are updated: - the six case-variant refusal rows; - the unreadable-index unit test. Lane: openreposhape-4 (openRepoShape-4) Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 31 ++++++++++++++++++------------- tests/test_family_add_refusals.py | 22 +++++++++++++--------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index a613f06c..26584b45 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -1080,10 +1080,11 @@ def _checked_mount_path(root: Path, project: str, path: str) -> None: # index holds. That is `path` itself when the case is the same. A # removal staged under another spelling is one the add's commit, # whose pathspec is `path`, does not carry. So where HEAD holds the - # variant, the removal is committed first. Otherwise the add's commit - # keeps both spellings, or, with `core.ignorecase` set, records the - # gitlink under the variant while `.gitmodules` and the manifest say - # `path`. + # variant, the removal is committed first. Otherwise HEAD keeps the + # variant beside the member, or, with `core.ignorecase` set, records + # the gitlink under a spelling other than `path` (`MEMBERS/ATLAS/...` + # gave `MEMBERS/Atlas`, and kept its file too), or both, while + # `.gitmodules` and the manifest say `path`. variant = name[:len(path)] spelled = " ".join(sorted({entry[0][:len(path)] for entry in held})) raise Refusal( @@ -1099,10 +1100,11 @@ def _checked_mount_path(root: Path, project: str, path: str) -> None: "no file; " + ("then " if spelled == path else "where HEAD holds it too, commit that removal first, since the " - f"add's own commit names {path} alone and would keep both " - "spellings, or, with `core.ignorecase` set, commit the gitlink " - f"under the other one while {GITMODULES} and {MANIFEST} say " - f"{path}; then ") + f"add's own commit names {path} alone: otherwise HEAD keeps " + "the other spelling beside the member, or, with " + "`core.ignorecase` set, records the member's gitlink under a " + f"spelling other than the {path} that {GITMODULES} and " + f"{MANIFEST} say, or both; then ") + "re-run.") try: run(["git", "--literal-pathspecs", "add", "--dry-run", @@ -1631,14 +1633,17 @@ def undo(self) -> str: undone = ((" Nothing was committed: the mount has been undone — " + ", ".join(restored) + " —") if restored else " Nothing was committed,") + # `git ls-files -s` is where both sentences send the human: it lists + # every record the index holds now, so an entry that is new, or + # staged differently, can be read there record by record. An entry + # that is gone is in no listing at all, which is why the sentence + # names it. `git status` compares the index with HEAD, not with the + # index before this command, so it is not that comparison either. if unread is not None: return (undone + " but the holder's index could not be read back " "to confirm it is as it was before this command: " - f"{unread}. Check it with `git status` before running " - "`add` again.") - # `git ls-files -s`, NOT `git status`, for the index: status compares - # the index with HEAD, so an entry that was staged and is now gone - # shows nowhere in it. + f"{unread}. Check it before running `add` again: `git " + "ls-files -s` lists what the index now holds.") if moved: return (undone + " but the holder's index is NOT as it was before " f"this command ({moved}). Put it back before running " diff --git a/tests/test_family_add_refusals.py b/tests/test_family_add_refusals.py index 6b71ce12..124aced2 100644 --- a/tests/test_family_add_refusals.py +++ b/tests/test_family_add_refusals.py @@ -1348,7 +1348,8 @@ def test_a_case_variant_of_the_mount_path_in_the_index_is_refused_first( remediation, run as printed, lets the same command through with the member under one spelling only. Where HEAD holds the variant, the removal is committed first: the add's own commit names `members/IRQS` - alone and would keep both.""" + alone, so without it HEAD keeps the variant, records the gitlink under + another spelling, or both.""" mode, name, committed = CASE_VARIANTS[variant] git("config", "--local", "core.ignorecase", ignorecase, cwd=holder) plant(holder, mode, name) @@ -1372,10 +1373,11 @@ def test_a_case_variant_of_the_mount_path_in_the_index_is_refused_first( "Remediation: this is a half-finished add or a hand edit. `git rm -r " f"--cached -- {spelled}` takes it out of the index and touches no " "file; where HEAD holds it too, commit that removal first, since the " - f"add's own commit names {SPARE_MOUNT} alone and would keep both " - "spellings, or, with `core.ignorecase` set, commit the gitlink under " - f"the other one while .gitmodules and family.yaml say {SPARE_MOUNT}; " - "then re-run.") + f"add's own commit names {SPARE_MOUNT} alone: otherwise HEAD keeps " + "the other spelling beside the member, or, with `core.ignorecase` " + "set, records the member's gitlink under a spelling other than the " + f"{SPARE_MOUNT} that .gitmodules and family.yaml say, or both; then " + "re-run.") assert index_bytes(holder) == index assert snapshot(holder) == before offline.assert_left_nothing() @@ -1475,9 +1477,10 @@ def test_an_index_that_cannot_be_read_back_is_never_called_as_it_was( """The comparison is a git command too. If the index cannot be read back after the unwind, every step may still have been undone, but the index has not been shown to be as it was. The sentence says exactly - that, with git's reason, and sends the human to `git status`. It never - calls the holder as it was, nor the unwind partial, and the refusal it - ends is still the one printed.""" + that, with git's reason, and sends the human to `git ls-files -s`, as + the sentence for an index that differs does. It never calls the holder + as it was, nor the unwind partial, and the refusal it ends is still the + one printed.""" rollback = family_module.MountRollback(holder, SPARE_MOUNT) def unreadable(root): @@ -1490,7 +1493,8 @@ def unreadable(root): " Nothing was committed, but the holder's index could not be read " "back to confirm it is as it was before this command: `git ls-files " f"-s -z` in {holder} exited 128: fatal: index file corrupt. Check it " - "with `git status` before running `add` again.") + "before running `add` again: `git ls-files -s` lists what the index " + "now holds.") def test_the_index_is_asked_at_the_mount_path_in_any_case_and_literally(